异步通信
异步通信
这一期来讲下服务器是如何与客户端进行异步通信的
- 通信,无外乎两点:1)服务器读取对端发送过来的数据;2)向对端发送数据。
- 异步,目前常用的是
epoll
+ 回调函数。
下面从非阻塞IO开始,逐步构建异步通信框架。
Non-Blocking IO
在服务端常用的IO读写操作,主要是read
、write
及其衍生函数。
所谓阻塞模式,也是read
、write
等操作的默认行为,比如read
函数从stdin
读取输入,如果用户没有从终端输入,则会一直阻塞在read
函数处。
在非阻塞模式下,read
继续从终端读取数据,如果用户没有在终端输入数据,read
函数并不会阻塞等待,而是立即返回-1,并将错误码errno
设置为EAGAIN
或者 EWOULDBLOCK
。此时read
函数返回值n
有三种可能:
n > 0
:读取到n
个字节;n == 0
:对端关闭、文件末尾;n == -1
:表示遇到问题,errno == EAGAIN/EWOULDBLOCK
:在非阻塞IO模式下,表示没有数据可读,可忽略本次read
操作;errno == EINTR
:表示被信号中断,重新读取一次即可。- 其他错误类型。
写操作write
函数也基本类似,更加详细可以man 2 read/write
查看。
因此,非阻塞IO非常适合服务器设计,不会在read/write
处发生堵塞。
将文件描述符fd设置为非阻塞模式,有如下两种方式(来自muduo)。
1 | void setNonBlockAndCloseOnExec(int sockfd) |
ET & LT
众所周知,epoll
有两种工作模式:ET
& LT
。但是在讲解 ET & LT 之前,先讲解下「高电平」和「低电平」的概念。
高低电平
对于可读事件:
- 内核中
socket
的recv_buff
为空,此时为「低电平」状态,即无数据可读 - 内核中
socket
的recv_buff
不空,此时为「高电平」状态,此时有数据可读
对于可写事件:
- 内核中
socket
的send_buff
为满 ,此时为「低电平」状态,不可发送数据 - 内核中
socket
的send_buff
不满,此时为「高电平」状态,可发送数据
简而言之,「低电平」状态下不能进行读、写操作,「高电平」则可以读、写。
为便于描述,socket
的接受缓冲区定义为recv_buff
,socket
的发送缓冲区定义为send_buff
,connfd
是服务端与对端建立连接的文件描述符。
LT(Level Triggered)
LT,电平触发,即socket缓冲区处于高电平时触发事件。此外,epoll_wait
的默认工作模式也是LT。
可读事件
为接受对端connfd
发送的数据,服务器首先要为connfd
注册可读事件EPOLLIN
。
如此,当对端发送数据过来,服务器connfd
的recv_buff
不为空,则epoll_wait
上的可读事件触发,在读回调函数HandleRead
中从socket
的recv_buff
中读取数据。
然而,即使服务端本次没有将scoketrecv_buff
中的数据全部读取,下次调用epoll_wait
时也依然会触发可读事件。
为啥?
因为,只要recv_buff
中的数据没有读取完,即为「高电平」状态,那么就会一直触发epoll_wait
的EPOLLIN
事件,直到recv_buff
变空,即为「低电平」状态。
因此LT
模式下,不用担心数据漏读的问题。
可写事件
然而,可写事件与可读事件不同:可读事件是被动触发的,即服务端不知道对端何时发送数据。因此服务端与对端建立连接之后,要立即为connfd
注册可读事件,然后在epoll_wait
上阻塞等待客户端发送数据过来。
那么,可写事件呢?是服务端主动触发的。
服务端与对端建立连接后,connfd
的send_buff
是空的,即处于高电平,是可以直接发送数据的。
如果此时为connfd
注册可写事件,这就会导致epoll_wait
一直检测到connfd
上的可写事件触发,但实际上服务端又没有数据可以发送给对端,造成服务端的CPU资源无端被消耗。
因此,在LT
模式下,当服务端要向对端发送数据时,不需要先通过epoll_ctl
注册可写事件,然后阻塞在epoll_wait
上等待可写事件的发生。 正确的做法如下:
- 直接发送数据
n = write(connfd, outbuffer, sizeof(outbuffer))
; - 如果此次数据没有发送完毕,即
n != sizeof(outbuffer)
,则为connfd
注册可写事件; - 在
epoll_wait
上等待可写事件触发; - 可写事件触发,则将剩余的数据发送完毕;
- 如果没有发送完毕,则等待下次的可写事件;发送完毕,则取消关注可写事件。
注意:当数据发送完毕,一定要取消可写事件,否则当connfd
的send_buff
变空,后面又没有可发的数据,则又会导致epoll_wait
一直触发可写事件。
ET(Edge Triggered)
ET,所谓边缘触发,即只有在电平状态发生变化时才会触发。开启ET模式,需要设置如下:
1 | struct epoll_event ev; |
可读事件
注册了可读事件后,阻塞于epoll_wait
等待对端发送数据,再触发可读事件。
注意:如果服务端没有将recv_buff
中的数据全部读取,那么进入下一轮循环并阻塞在epoll_wait
后,可读事件就再也不会触发。
为啥?
在ET
模式下,只有connfd
的recv_buffer
电平状态发生变化才会触发可读事件,但只要recv_buff
中还有数据,则一直为高电平状态,那么即便下次对端又发送数据过来,并不会改recv_buff
的电平状态,这就导致epoll_wait
就无法再检测到connfd
上的可读事件。
那么对端发送了数据,服务端迟迟无法给出回应。如果是listenfd
,那么这个服务器就不再能处理新的连接请求了。
因此,如果不熟悉ET
模式的正确使用方法,很可能导致整个服务器无法使用。
那ET
模式下,怎么处理可读事件?
一旦检测到connfd
上的可读事件,需要不停地从recv_buff
中读取数据,直到read
函数返回-1
,且错误码errno
是EAGAIN
标志。
这标志着recv_buff
中的数据已经读取完(已处于低电平),下次对端再发送数据过来(变为高电平),就能再次触发可读事件。
1 | for(; ; ;) { |
但是又有一个问题:
read
函数,什么情况下才会返回EAGAIN
?- 要是
read
函数不返回EAGAIN
,那么岂不是一直在while/for
循环?
仅在connfd
设置为非阻塞模式时,read
函数无法从空的recv_buff
继续读取到数据,此时错误码errno
就会被设置为EAGAIN
。
可写事件
ET模式下,在与客户端建立连接后,可以为connfd
注册可写事件,因为此时connfd
的send_buff
是空的,处于高电平,不会触发可写事件。
当服务端要通过connfd
向客户端发送数据时,直接发送即可:
如果应用层缓冲区
outbuffer
的数据大小小于send_buff
大小,则无须任何操作;换言之,
connfd
的send_buff
能容纳outbuffer
中的全部数据,那么send_buff
依然未满,即处于高电平状态。下次应用层有待发送的数据,直接发送即可;如果
outbuffer
中的数据大小大于send_buff
大小,那么write(connfd, outbuffer, size)
返回-1 且errno
是EAGAIN
。由于
send_buff
满了,即处于「低电平」状态,表示不可再接受来自应用层的数据。为了将outbuffer
中剩余的数据也发送到对端,此时需要为connfd
注册可写事件。当
send_buff
的数据发送出去,则会变为「高电平」状态,此时就会触发connfd
上的可写事件,进而就能继续发送outbuffer
中剩下的数据了。
总结下,写操作要一直写到应用层outBuffer
为空,或者write
函数返回EAGAIN
。
上面的 epoll LT
模式注:如果数据未发送完毕,需要注册可写事件;可写事件触发后,尝试发送outBuffer
中的剩余数据,如果数据此时还不能全部发送完,不用再次注册可写事件,若全部发送完毕,需要取消注册可写事件。
如果是 epoll ET
模:如果数据未发送完毕,注册可写事件;可写事件触发后,尝试发送剩余数据,如果数据此时还不能全部发送完,需要再次注册可写事件,以便让可写事件下次再次触发,数据全部发送完毕,不用取消注册可写事件。
LT or ET ?
说了这么多,那自己设计一个服务器,到底是选 ET
还是 LT
?
从个人的目前经验来说,看到的大多数都是LT
。
对于可读事件,ET
模式只会触发一次epoll_wait
,而LT
模式下,如果不能一次性读取完recv_buff
中的数据,则会多次触发epoll_wait
,增加系统调用开销。
如果我使用LT
模式,且一次就将recv_buff
中的数据全部读取出来,那不也就只调用一次epoll_wait
,不就和ET
模式一样了?
此外,LT模式下的read
函数可以少一次系统调用,因为ET
模式下的read
操作必须读取到返回EAGAIN
,就多了一次系统调用开销。
这就是muduo
设计了一个InputBuffer
的原因,而在redis
中也有个输入缓冲区。
muduo、libuv、redis等都是采用LT模式,其他库不太清楚。
更为重要的是,ET
模式操作不当,容易造成数据漏读、甚至服务器阻塞等问题,而良好的设计的LT
模式效率也依然很高。
下面我们从muduo源码角度还原上述过程。
muduo源码展示
确定好大的方向是「LT模式的epoll
+ 非阻塞IO」来设计异步通信之后。下面,我们就根据「网络编程」的前三期大致梳理下muduo服务端的源码。
监听客户端连接请求
当muduo的服务器TcpServer
运行时,会先在Acceptor
中创建一个非阻塞的listenfd
,用于监听客户端的连接请求,即muduo中的acceptSocket_
字段, 并为acceptSocket_
注册可读事件、设置可读回调函数 Acceptor::handleRead
。
这样,服务端就能监听客户端cli
的连接请求:
- 当监听到客户端的连接请求后,在读取回调函数
Acceptor::handleRead
中为请求连接的客户端cli
创建TcpConnection
对象conn
; - 在众多
sub-eventloops
线程中,选择一个sub-loop
线程,将conn
分发到该sub-loop
线程; - 以后服务端与该
cli
的通信,都在sub-loop
线程中完成,而Acceptor
所在的main-eventloop
线程,只是负责监听客户端的连接请求。
如此,Accptor
的作用即任务分发器dispatcher
:
整个框架逻辑如图。
1 | Acceptor::Acceptor(EventLoop* loop, const InetAddress& listenAddr, bool reuseport) |
自然,分发任务的操作就是在可读事件的回调函数Acceptor::handleRead
中完成的,其核心就是newConnectionCallback_
函数。
1 | void Acceptor::handleRead() { |
而回调函数newConnectionCallback_
最终初始化为TcpServer
类中的TcpServer::newConnection
函数:
- 创建
TcpConnection
对象conn
; TcpServer
中的connections_
记录着每个客户端,因此要把新创建的客户端记录在connections_
中;- 为
conn
设置一些回调函数; - 将
conn
放到sub-eventloop
中运行,以后服务器与该客户端的通讯就在ioLoop
中进行了。
整个逻辑如下。
1 | void TcpServer::newConnection(int sockfd, const InetAddress& peerAddr) |
在TcpConnection::connectEstablished
回调函数中,为每个刚建立连接的TcpConnection
对象注册可读事件,这是为了监听等待客户端的发送数据。
1 | void TcpConnection::connectEstablished() { |
而这个conn
的可读、可写等事件的回调函数在TcpConnection
构造函数中就完成了初始化。
1 | TcpConnection::TcpConnection(EventLoop* loop, |
因此,当conn
对应的客户端发送过来数据时,触发可读事件后,会调用TcpConnection::handleRead
来进行处理。
1 | void TcpConnection::handleRead(Timestamp receiveTime) |
到此,启动服务器到和客户端建立连接请求的过程、接受数据的流程大致结束了。
可写事件
在前面说过LT
模式下的可写事件需要注意的点,下面顺着代码注释去看就好了。
1 | void TcpConnection::sendInLoop(const void* data, size_t len) { |
到此,就差不多了。