前言
I/O多路复用有不少种实现。在linux上,2.4内核前主要是select和poll,自Linux 2.6内核正式引入epoll以来,epoll已经成为了目前实现高性能网络服务器的必备技术。尽管他们的使用方法不尽相同,可是本质上却没有什么区别。本文将重点探讨将放在EPOLL的实现与使用详解。html
为何会是EPOLL
select的缺陷

1 /linux/posix_types.h: 2 3 #define __FD_SETSIZE 1024
图 1.主流I/O复用机制的benchmark
epoll高效的奥秘
epoll精巧的使用了3个方法来实现select方法要作的事:node
- 新建epoll描述符==epoll_create()
- epoll_ctrl(epoll描述符,添加或者删除全部待监控的链接)
- 返回的活跃链接 ==epoll_wait( epoll描述符 )
要深入理解epoll,首先得了解epoll的三大关键要素:mmap、红黑树、链表。linux
epoll是经过内核与用户空间mmap同一块内存实现的。mmap将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址(无论是用户空间仍是内核空间都是虚拟地址,最终要经过地址映射映射到物理地址),使得这块物理内存对内核和对用户都可见,减小用户态和内核态之间的数据交换。内核能够直接看到epoll监听的句柄,效率高。编程
红黑树将存储epoll所监听的套接字。上面mmap出来的内存如何保存epoll所监听的套接字,必然也得有一套数据结构,epoll在实现上采用红黑树去存储全部套接字,当添加或者删除一个套接字时(epoll_ctl),都在红黑树上去处理,红黑树自己插入和删除性能比较好,时间复杂度O(logN)。数组
下面几个关键数据结构的定义 服务器

1 struct epitem 2 { 3 struct rb_node rbn; //用于主结构管理的红黑树 4 struct list_head rdllink; //事件就绪队列 5 struct epitem *next; //用于主结构体中的链表 6 struct epoll_filefd ffd; //每一个fd生成的一个结构 7 int nwait; 8 struct list_head pwqlist; //poll等待队列 9 struct eventpoll *ep; //该项属于哪一个主结构体 10 struct list_head fllink; //连接fd对应的file链表 11 struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event 12 }

1 struct eventpoll 2 { 3 spin_lock_t lock; //对本数据结构的访问 4 struct mutex mtx; //防止使用时被删除 5 wait_queue_head_t wq; //sys_epoll_wait() 使用的等待队列 6 wait_queue_head_t poll_wait; //file->poll()使用的等待队列 7 struct list_head rdllist; //事件知足条件的链表 8 struct rb_root rbr; //用于管理全部fd的红黑树 9 struct epitem *ovflist; //将事件到达的fd进行连接起来发送至用户空间 10 }
添加以及返回事件
经过epoll_ctl函数添加进来的事件都会被放在红黑树的某个节点内,因此,重复添加是没有用的。当把事件添加进来的时候时候会完成关键的一步,那就是该事件都会与相应的设备(网卡)驱动程序创建回调关系,当相应的事件发生后,就会调用这个回调函数,该回调函数在内核中被称为:ep_poll_callback,这个回调函数其实就所把这个事件添加到rdllist这个双向链表中。一旦有事件发生,epoll就会将该事件添加到双向链表中。那么当咱们调用epoll_wait时,epoll_wait只须要检查rdlist双向链表中是否有存在注册的事件,效率很是可观。这里也须要将发生了的事件复制到用户态内存中便可。网络
epoll_wait的工做流程:数据结构
- epoll_wait调用ep_poll,当rdlist为空(无就绪fd)时挂起当前进程,直到rdlist不空时进程才被唤醒。
- 文件fd状态改变(buffer由不可读变为可读或由不可写变为可写),致使相应fd上的回调函数ep_poll_callback()被调用。
- ep_poll_callback将相应fd对应epitem加入rdlist,致使rdlist不空,进程被唤醒,epoll_wait得以继续执行。
- ep_events_transfer函数将rdlist中的epitem拷贝到txlist中,并将rdlist清空。
- ep_send_events函数(很关键),它扫描txlist中的每一个epitem,调用其关联fd对用的poll方法。此时对poll的调用仅仅是取得fd上较新的events(防止以前events被更新),以后将取得的events和相应的fd发送到用户空间(封装在struct epoll_event,从epoll_wait返回)。
小结
表 1. select、poll和epoll三种I/O复用模式的比较( 摘录自《linux高性能服务器编程》)
系统调用并发 |
selectsocket |
poll |
epoll |
事件集合 |
用哦过户经过3个参数分别传入感兴趣的可读,可写及异常等事件 内核经过对这些参数的在线修改来反馈其中的就绪事件 这使得用户每次调用select都要重置这3个参数 |
统一处理全部事件类型,所以只须要一个事件集参数。 用户经过pollfd.events传入感兴趣的事件,内核经过 修改pollfd.revents反馈其中就绪的事件 |
内核经过一个事件表直接管理用户感兴趣的全部事件。 所以每次调用epoll_wait时,无需反复传入用户感兴趣 的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件 描述符的时间复杂度 |
O(n) |
O(n) |
O(1) |
最大支持文件描述符数 |
通常有最大值限制 |
65535 |
65535 |
工做模式 |
LT |
LT |
支持ET高效模式 |
内核实现和工做效率 | 采用轮询方式检测就绪事件,时间复杂度:O(n) | 采用轮询方式检测就绪事件,时间复杂度:O(n) |
采用回调方式检测就绪事件,时间复杂度:O(1) |
行文至此,想必各位都应该已经明了为何epoll会成为Linux平台下实现高性能网络服务器的首选I/O复用调用。
须要注意的是:epoll并非在全部的应用场景都会比select和poll高不少。尤为是当活动链接比较多的时候,回调函数被触发得过于频繁的时候,epoll的效率也会受到显著影响!因此,epoll特别适用于链接数量多,但活动链接较少的状况。
接下来,笔者将介绍一下epoll使用方式的注意点。
EPOLL的使用
文件描述符的建立

1 #include <sys/epoll.h> 2 int epoll_create ( int size );
在epoll早期的实现中,对于监控文件描述符的组织并非使用红黑树,而是hash表。这里的size实际上已经没有意义。
注册监控事件

1 #include <sys/epoll.h> 2 int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );

1 struct epoll_event 2 { 3 __unit32_t events; // epoll事件 4 epoll_data_t data; // 用户数据 5 };

1 typedef union epoll_data 2 { 3 void* ptr; //指定与fd相关的用户数据 4 int fd; //指定事件所从属的目标文件描述符 5 uint32_t u32; 6 uint64_t u64; 7 } epoll_data_t;
epoll_wait函数

1 #include <sys/epoll.h> 2 int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );
EPOLLONESHOT事件
LT与ET模式
在这里,笔者强烈推荐《完全学会使用epoll》系列博文,这是笔者看过的,对epoll的ET和LT模式讲解最为详尽和易懂的博文。下面的实例均来自该系列博文。限于篇幅缘由,不少关键的细节,不能彻底摘录。
话很少说,直接上代码。
程序一:
#include <stdio.h> #include <unistd.h> #include <sys/epoll.h> int main(void) { int epfd,nfds; struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 ev.data.fd = STDIN_FILENO; ev.events = EPOLLIN|EPOLLET; //监听读状态同时设置ET模式 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件 for(;;) { nfds = epoll_wait(epfd, events, 5, -1); for(int i = 0; i < nfds; i++) { if(events[i].data.fd==STDIN_FILENO) printf("welcome to epoll's word!\n"); } } }
- 当用户输入一组字符,这组字符被送入buffer,字符停留在buffer中,又由于buffer由空变为不空,因此ET返回读就绪,输出”welcome to epoll's world!”。
- 以后程序再次执行epoll_wait,此时虽然buffer中有内容可读,可是根据咱们上节的分析,ET并不返回就绪,致使epoll_wait阻塞。(底层缘由是ET下就绪fd的epitem只被放入rdlist一次)。
- 用户再次输入一组字符,致使buffer中的内容增多,根据咱们上节的分析这将致使fd状态的改变,是对应的epitem再次加入rdlist,从而使epoll_wait返回读就绪,再次输出“Welcome to epoll's world!”。
接下来咱们将上面程序的第11行作以下修改:

1 ev.events=EPOLLIN; //默认使用LT模式
编译并运行,结果以下:
程序陷入死循环,由于用户输入任意数据后,数据被送入buffer且没有被读出,因此LT模式下每次epoll_wait都认为buffer可读返回读就绪。致使每次都会输出”welcome to epoll's world!”。
程序二:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDIN_FILENO; 11 ev.events = EPOLLIN; //监听读状态同时设置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDIN_FILENO) 19 { 20 char buf[1024] = {0}; 21 read(STDIN_FILENO, buf, sizeof(buf)); 22 printf("welcome to epoll's word!\n"); 23 } 24 } 25 } 26 }
编译并运行,结果以下:
本程序依然使用LT模式,可是每次epoll_wait返回读就绪的时候咱们都将buffer(缓冲)中的内容read出来,因此致使buffer再次清空,下次调用epoll_wait就会阻塞。因此可以实现咱们所想要的功能——当用户从控制台有任何输入操做时,输出”welcome to epoll's world!”
程序三:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDIN_FILENO; 11 ev.events = EPOLLIN|EPOLLET; //监听读状态同时设置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDIN_FILENO) 19 { 20 printf("welcome to epoll's word!\n"); 21 ev.data.fd = STDIN_FILENO; 22 ev.events = EPOLLIN|EPOLLET; //设置ET模式 23 epoll_ctl(epfd, EPOLL_CTL_MOD, STDIN_FILENO, &ev); //重置epoll事件(ADD无效) 24 } 25 } 26 } 27 }
编译并运行,结果以下:
程序依然使用ET,可是每次读就绪后都主动的再次MOD IN事件,咱们发现程序再次出现死循环,也就是每次返回读就绪。可是注意,若是咱们将MOD改成ADD,将不会产生任何影响。别忘了每次ADD一个描述符都会在epitem组成的红黑树中添加一个项,咱们以前已经ADD过一次,再次ADD将阻止添加,因此在次调用ADD IN事件不会有任何影响。
程序四:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //监听读状态同时设置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!\n"); 21 } 22 } 23 } 24 }
编译并运行,结果以下:
这个程序的功能是只要标准输出写就绪,就输出“welcome to epoll's world”。咱们发现这将是一个死循环。下面具体分析一下这个程序的执行过程:
- 首先初始buffer为空,buffer中有空间可写,这时不管是ET仍是LT都会将对应的epitem加入rdlist,致使epoll_wait就返回写就绪。
- 程序想标准输出输出”welcome to epoll's world”和换行符,由于标准输出为控制台的时候缓冲是“行缓冲”,因此换行符致使buffer中的内容清空,这就对应第二节中ET模式下写就绪的第二种状况——当有旧数据被发送走时,即buffer中待写的内容变少得时候会触发fd状态的改变。因此下次epoll_wait会返回写就绪。如此循环往复。
程序五:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //监听读状态同时设置ET模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 } 22 } 23 } 24 }
编译并运行,结果以下:
与程序四相比,程序五只是将输出语句的printf的换行符移除。咱们看到程序成挂起状态。由于第一次epoll_wait返回写就绪后,程序向标准输出的buffer中写入“welcome to epoll's world!”,可是由于没有输出换行,因此buffer中的内容一直存在,下次epoll_wait的时候,虽然有写空间可是ET模式下再也不返回写就绪。回忆第一节关于ET的实现,这种状况缘由就是第一次buffer为空,致使epitem加入rdlist,返回一次就绪后移除此epitem,以后虽然buffer仍然可写,可是因为对应epitem已经再也不rdlist中,就不会对其就绪fd的events的在检测了。
程序六:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT; //监听读状态同时设置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 } 22 } 23 } 24 }
编译并运行,结果以下:
程序六相对程序五仅仅是修改ET模式为默认的LT模式,咱们发现程序再次死循环。这时候缘由已经很清楚了,由于当向buffer写入”welcome to epoll's world!”后,虽然buffer没有输出清空,可是LT模式下只有buffer有写空间就返回写就绪,因此会一直输出”welcome to epoll's world!”,当buffer满的时候,buffer会自动刷清输出,一样会形成epoll_wait返回写就绪。
程序七:

1 #include <stdio.h> 2 #include <unistd.h> 3 #include <sys/epoll.h> 4 5 int main(void) 6 { 7 int epfd,nfds; 8 struct epoll_event ev,events[5]; //ev用于注册事件,数组用于返回要处理的事件 9 epfd = epoll_create(1); //只须要监听一个描述符——标准输入 10 ev.data.fd = STDOUT_FILENO; 11 ev.events = EPOLLOUT|EPOLLET; //监听读状态同时设置LT模式 12 epoll_ctl(epfd, EPOLL_CTL_ADD, STDOUT_FILENO, &ev); //注册epoll事件 13 for(;;) 14 { 15 nfds = epoll_wait(epfd, events, 5, -1); 16 for(int i = 0; i < nfds; i++) 17 { 18 if(events[i].data.fd==STDOUT_FILENO) 19 { 20 printf("welcome to epoll's word!"); 21 ev.data.fd = STDOUT_FILENO; 22 ev.events = EPOLLOUT|EPOLLET; //设置ET模式 23 epoll_ctl(epfd, EPOLL_CTL_MOD, STDOUT_FILENO, &ev); //重置epoll事件(ADD无效) 24 } 25 } 26 } 27 }
编译并运行,结果以下:
程序七相对于程序五在每次向标准输出的buffer输出”welcome to epoll's world!”后,从新MOD OUT事件。因此至关于每次都会返回就绪,致使程序循环输出。
通过前面的案例分析,咱们已经了解到,当epoll工做在ET模式下时,对于读操做,若是read一次没有读尽buffer中的数据,那么下次将得不到读就绪的通知,形成buffer中已有的数据无机会读出,除非有新的数据再次到达。对于写操做,主要是由于ET模式下fd一般为非阻塞形成的一个问题——如何保证将用户要求写的数据写完。
要解决上述两个ET模式下的读写问题,咱们必须实现:
- 对于读,只要buffer中还有数据就一直读;
- 对于写,只要buffer还有空间且用户请求写的数据还未写完,就一直写。
ET模式下的accept问题
请思考如下一种场景:在某一时刻,有多个链接同时到达,服务器的 TCP 就绪队列瞬间积累多个就绪链接,因为是边缘触发模式,epoll 只会通知一次,accept 只处理一个链接,致使 TCP 就绪队列中剩下的链接都得不处处理。在这种情形下,咱们应该如何有效的处理呢?
解决的方法是:解决办法是用 while 循环抱住 accept 调用,处理完 TCP 就绪队列中的全部链接后再退出循环。如何知道是否处理完就绪队列中的全部链接呢? accept 返回 -1 而且 errno 设置为 EAGAIN 就表示全部链接都处理完。
关于ET的accept问题,这篇博文的参考价值很高,若是有兴趣,能够连接过去围观一下。
ET模式为何要设置在非阻塞模式下工做
由于ET模式下的读写须要一直读或写直到出错(对于读,当读到的实际字节数小于请求字节数时就能够中止),而若是你的文件描述符若是不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。这样就不能在阻塞在epoll_wait上了,形成其余文件描述符的任务饥饿。
epoll的使用实例
这样的实例,网上已经有不少了(包括参考连接),笔者这里就略过了。
小结
LT:水平触发,效率会低于ET触发,尤为在大并发,大流量的状况下。可是LT对代码编写要求比较低,不容易出现问题。LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,所以不用担忧事件丢失的状况。
ET:边缘触发,效率很是高,在并发,大流量的状况下,会比LT少不少epoll的系统调用,所以效率高。可是对编程要求高,须要细致的处理每一个请求,不然容易发生丢失事件的状况。
从本质上讲:与LT相比,ET模型是经过减小系统调用来达到提升并行效率的。
总结
epoll使用的梳理与总结到这里就告一段落了。限于篇幅缘由,不少细节都被略过了。后面参考给出的连接,强烈推荐阅读。疏谬之处,万望斧正!
备注
本文有至关分量的内容参考借鉴了网络上各位网友的热心分享,特别是一些带有彻底参考的文章,其后附带的连接内容更直接、更丰富,笔者只是作了一下概括&转述,在此一并表示感谢。
参考
《Linux高性能服务器编程》
《完全学会使用epoll》(系列博文)
《epoll源码分析(全) 》