前言
之前在整理 selete 和 epoll 原理时,看到了两篇关于epoll ET模式的不错的文章。因此,借用此文章来加深对epoll ET模式的理解。(文末标注原文链接)
在进行ET模式的正式分析之前,我们来举个例子简单地了解下ET和LT:
假设我们通过 fork函数创建了父子两个进程,并通过 匿名管道来通信,在子进程中,我们一次向管道写入10个字符数据,为"aaaa\nbbbb\n";每隔5s写入10个字符数据;在父进程中,我们从管道中一次读取5个字符数据:
若我们采用的是 LT模式,则在5s的周期内,会先读取5个字符数据,读完之后,因为文件描述符中仍然有数据,epoll_wait会立即返回,会继续读取接下来的5个数据,之后在5s周期以内的剩余时间内,管道中的数据都为空,如下图1。
若我们采用的是 ET模式,则当父进程先读完管道中的5个字符后,由于子进程没有立即向管道中写入字符(间隔5s后才会第二次写入),所以此时父进程会先读到5个字符"aaaa\n",隔5s之后,再读到5个字符"bbbb\n",如下图2,可以看到ET模式下,随时间推移,管道中数据会越来越多。
图1:
![](https://i-blog.csdnimg.cn/blog_migrate/7ba99982983cf70e03088357933702b7.png)
图2:
![](https://i-blog.csdnimg.cn/blog_migrate/66ab67a73989689cde98df1ed16e6f1c.png)
epoll工作流程
想要了解ET模式和LT模式的区别,首先需要熟悉epoll的工作流程:
![](https://i-blog.csdnimg.cn/blog_migrate/f051015f360743bdfe92ae1522899639.png)
【注:】上图的poll不要理解成和select相似那个poll,这是通过epoll_ctl调用的,进行回调函数注册。
下面简要分析一下epoll的工作过程:
1)epoll_wait调用ep_poll, 当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。
2)****文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),导致相应fd上的回调函数ep_poll_callback()被调用。
ep_poll_callback将相应fd对应epitem加入rdlist,导致rdlist不空,进程被唤醒,epoll_wait得以继续执行。
3)ep_events_transfer函数 将rdlist中的epitem拷贝到txlist中,并将rdlist清空。
4)ep_send_events函数(很关键),它扫描txlist中的每个epitem,调用其关联fd对应的poll方法(图中蓝线)。 此时对poll的调用仅仅是取得fd上较新的events(防止之前events被更新),之后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。
5)之后如果这个epitem对应的fd是LT模式监听且取得的events是用户所关心的, 则将其重新加入回rdlist(图中蓝线),否则(ET模式)不在加入rdlist。
ET模式 vs LT模式
区别
通过上图epoll的工作流程描述,可以看出ET模式和LT模式的区别主要发生在第5步:是否将文件描述符fd放回rdlist中,而rdlist的是否为空决定了epoll_wait函数的阻塞和非阻塞。因此,总结下二者的区别:
ET
如果是ET, epitem是不会再进入到readly list;除非fd再次发生了状态改变, ep_poll_callback被调用。 【因此,ET模式可减少epoll_wait函数的调用,从而减少系统开销,提高效率。】
LT
如果是非ET, 不管你还有没有有效的事件或者数据,都会被重新插入到ready list, 再下一次epoll_wait时, 会立即返回, 并通知给用户空间。当然如果这个被监听的fds确实没事件也没数据了, epoll_wait会返回一个0,空转一次。
加入rdlist途径分析
红线:fd状态改变才会触发,那什么情况会导致fd状态的改变呢?
对于读取操作:
1)当buffer由不可读状态变为可读的时候,即由空变为不空的时候。
2)当有新数据到达时,即buffer中的待读内容变多的时候。
对于写操作:
1)当buffer由不可写变为可写的时候,即由满状态变为不满状态的时候。
2)当有旧数据被发送走时,即buffer中待写的内容变少得时候。
蓝线:fd的events中有相应的时间(位置1)即会触发。那什么情况下会改变events的相应位呢?
对于读操作:
1)buffer中有数据可读的时候,即buffer不空的时候,fd的events的可读为就置1。
对于写操作:
2) buffer中有空间可写的时候,即buffer不满的时候fd的events的可写位就置1。
【说明:】
红线是时间驱动被动触发(fd状态改变);
蓝线是函数查询主动触发(LT和ET判断是否加入rdlist)。
ET模式如何使用
使用epoll多路复用编程时,会用epoll_wait阻塞等待事件的发生,对应有边沿触发和水平触发两种工作模式。
一、水平触发(EPOLLLT)
水平触发:只要缓冲区有数据,epoll_wait就会一直被触发,直到缓冲区为空;
水平触发(EPOLLLT)是epoll默认的工作模式,其优缺点如下:
优点:保证了数据的完整输出;
缺点:当数据较大时,需要不断从用户态和内核态切换,消耗了大量的系统资源,影响服务器性能;
应用场景:应用较少,一般用于连接请求较少及客户端发送的数据量较少的服务器,可一次性接收所有数据。此时,若使用边沿触发,会多调用一次accept/read等来判断数据是否为空。
二、边沿触发(EPOLLET)
边沿触发:只有所监听的事件状态改变或者有事件发生时,epoll_wait才会被触发;
epoll边沿触发时,假设一个客户端发送100字节的数据,而服务器设定read每次读取20字节,那么一次触发只能读取20个字节,然后内核调用epoll_wait直到下一次事件发生,才会继续从剩下的80字节读取20个字节,由此可见,这种模式其工作效率非常低且无法保证数据的完整性,因此边沿触发不会单独使用。
边沿触发通常与非阻塞IO一起使用,其工作模式为:epoll_wait触发一次,在while(1)循环内非阻塞IO读取数据,直到缓冲区数据为空(保证了数据的完整性),内核才会继续调用epoll_wait等待事件发生。
边沿触发(EPOLLET)+非阻塞IO的优缺点如下:
优点:每次epoll_wait只用触发一次,就可以读取缓冲区的所有数据,工作效率高,大大提升了服务器性能;
缺点:数据量很小时,至少需要调用两次非阻塞IO函数,而边沿触发只用调用一次。
三、什么时候用边沿触发?什么时候用水平触发?
我的答案是:任何情况都应该优先选择“边沿触发(EPOLLET)+非阻塞IO”模式。
理由如下:根据以上水平触发和边沿触发的分析,毋庸置疑,当服务端连接请求多且数据量大的时候,应该选择“边沿触发+非阻塞IO”模式,因为只用触发一次epoll_wait,就可以读取缓冲区的所有数据。那么连接请求少而且数据量也小的时候偶,为什么也优先选择边沿触发+非阻塞IO呢?在我看来,既然数据量小,那么服务端性能要求自然也不高,即使非阻塞IO读取数据多了一次判断数据为空的情况,但是其影响也不大,而且边沿触发也能满足接收大量数据的情况。
四、水平触发是否需要使用非阻塞IO?
答案是:不管水平触发还是边沿触发,都要使用非阻塞IO。
理由如下:假设水平触发使用阻塞read读取数据,且设定一次性读取20字节,现在假设客户端只发送了10个字节,那么服务端内核就会阻塞在read调用中,等待客户端再发送至少10个字节的数据,才能返回继续执行程序。但是服务端已经阻塞在系统调用read中了,无法再调用epoll_wait来监听该客户端的下一次就绪事件,也就无法接受数据,read也不可能再达到20字节了,从而就形成死锁,因此水平触发也要使用非阻塞IO。
五、服务器项目中遇到的问题
写服务器的第一个版本时,使用的是“边沿触发(EPOLLET)+非阻塞IO”模式,但是只调用了一次IO,没有循环遍历直到数据为空。这样就产生了一个问题,如下:如果给了1000个连接请求,在60S时间内,但是实际接收的连接数不到一半。这是因为每次触发只调用一次IO时,一次只能accept一个连接请求,那么需要不断的连接请求触发,才能继续accept连接,效率非常低。但是使用了while循环遍历直至数据为空之后,同样的测试,服务器能接收全部的连接请求,其原因就是一次触发就可以处理该次触发所接收的所有连接请求,大大减少了epoll_wait系统调用,减小了内核资源消耗。