多路IO复用:epoll
epoll简介
epoll
在linux 2.5.44内被引入,FreeBSD, MacOS中类似的实现是kqueue
,epoll
主要解决select
,poll
对于fd或事件结构需要O(n)的循环遍历。epoll
在注册事件时,会将事件添加到红黑树中并注册回调事件,对红黑树搜索寻找就绪的事件,并添加就绪链表中,因此epoll
查找就绪事件的事件复杂度为O(1),在大量事件注册和监控中,epoll
会有更好的性能。不同于select
, poll
,epoll
使用一组函数完成任务:epoll_create
,epoll_ctl
,epoll_wait
epoll_create
epoll_create
用于创建一个epoll实例的fd, 后续的函数可以使用这个fd,函数签名:
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
-
size
:自从Linux 2.6.8之后size
参数被忽略了,在最开始时候,size
参数会通知内核后续添加到epoll实例期望添加的fd数量。内核使用这个信息作为fd事件的内部数据结构中分配空间量的提示(内核可能会预留分配更多的空间以免添加fd超过给定size
)。目前这个size
的提示不再被需要了(内核对动态地分配数据结构的空间大小),但size
参数依然需要给一个大于0的数组,这是为了保证在新内核开发的程序在旧内核中前向兼容。 -
flags
:如果flags
设置成为0,epoll_create1
和epoll_create
没有什么区别,如果flags
设置为EPOLL_CLOEXEC
,类似于open
中O_CLOEXEC
(Linux 2.6.23+),设置fd标志FD_CLOEXEC
为1,即“执行时关闭”(close-on-exec),在调用exec
时,该描述符会关闭。避免fd被exec
执行进程修改和访问,造成可能的竞争问题。相关内容查阅APUE P60,P66,P201 -
return:返回fd指向新的epoll实例,后续epoll的操作接口会调用这个fd,创建失败返回-1。
epoll_clt
对epoll的事件表进行操作,函数签名:
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
-
epfd
:操作的目标epoll实例fd -
op
:具体epoll_clt
对epoll实例进行的操作,基本都是和fd事件注册有关系,主要有三种类型:
op | 操作 |
---|---|
EPOLL_CLT_ADD | 往事件表中注册fd事件 |
EPOLL_CTL_MOD | 修改fd上注册的事件 |
EPOLL_CLT_DEL | 删除fd上注册的事件 |
-
fd
:表示操作的fd -
event
指定注册的事件,是一个epoll_event
结构体指针,具体定义如下:typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; struct epoll_event { uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */ };
events
:具体epoll的事件类型,和poll的事件类型一致,在前面加上"E"就行,比如EPOLLIN
,EPOLLOUT
。另外包括了两个额外的事件类型EPOLLET
,EPOLLONESHOT
data
:一个联合体,fd
成员最多被使用,指定事件所属的目标fd,ptr
可以用于指定fd相关用户数据。由于union只能保留一个成员,同时需要fd
和ptr
可以在ptr指向用户数据中包含fd
-
return:返回操作成功(0)或者是失败(-1)
epoll_wait
在超时时间内等待一组fd的事件触发,函数签名
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);
-
epfd
:等待的epoll实例fd -
events
:包含了等待结束后,对调用者可用的就绪事件数组 -
maxevents
:指定了最多监听的事件数量,可以设置常量MAX_EVENT_NUMBER
-
timeout
:epoll等待的超时时间,和select
,poll
类似
为什么事件表构建为RB-Tree
摘录自该博客
epoll和poll的一个很大的区别在于,poll每次调用时都会存在一个将pollfd结构体数组中的每个结构体元素从用户态向内核态中的一个链表节点拷贝的过程,而内核中的这个链表并不会一直保存,当poll运行一次就会重新执行一次上述的拷贝过程,这说明一个问题:poll并不会在内核中为要监听的文件描述符长久的维护一个数据结构来存放他们,而epoll内核中维护了一个内核事件表,它是将所有的文件描述符全部都存放在内核中,系统去检测有事件发生的时候触发回调,当你要添加新的文件描述符的时候也是调用
epoll_ctl
函数使用EPOLL_CTL_ADD
宏来插入,epoll_wait
也不是每次调用时都会重新拷贝一遍所有的文件描述符到内核态。当我现在要在内核中长久的维护一个数据结构来存放文件描述符,并且时常会有插入,查找和删除的操作发生,这对内核的效率会产生不小的影响,因此需要一种插入,查找和删除效率都不错的数据结构来存放这些文件描述符,那么红黑树当然是不二的选择。
换句话说,epoll_clt
中fd事件注册的增删改查都是能在O(logN)完成的,因此选择红黑树
LT/ET
概念
epoll包含两种对fd的操作模式:LT(Level Trigger,电平触发);ET(Edge Trigger,边沿触发)。对于每个注册事件的fd,epoll默认是工作于LT模式下,在fd对应event注册时如果event->events
设置了EPOLLET
,epoll会按照ET模式操作该fd。
- LT:
epoll_wait
上检测道有事件发生,并通知调用者后,调用者可以不立即处理该事件。在下一次调用epoll_wait
时,epoll_wait
还是会向调用者通知该事件,直到事件被处理。select
,poll
同样是工作于这个模式 - ET:
epoll_wait
检测到有事件发生,并通知调用者后,调用者必须立即处理该事件,如果该次事件没有处理,epoll_wait
将不会向调用者通知这一事件。该操作方式降低了重复调用epoll_wait
的次数
ET工作机制
首先这里列出ET工作正常的两个必要条件,有一个条件不满足,epoll
在处理完一次就可能出现阻塞:
- fd对应文件状态标志
O_NONBLOCK
- fd在上次处理触发事件时,IO直到遇到
EWOULDBLOCK
错误(所谓的边沿,就是从EWOULDBLOCK
错误清空清空的突变)
为更好地理解ET,首先举一个select
+ blocking服务器读取数据的例子:
ssize_t nbytes;
for (;;) {
/* select call happens here */
if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
perror("select");
exit(EXIT_FAILURE);
}
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, &read_fds)) {
/* read call happens here */
if ((nbytes = read(i, buf, sizeof(buf))) >= 0) {
handle_read(nbytes, buf);
} else {
/* real version needs to handle EINTR correctly */
perror("read");
exit(EXIT_FAILURE);
}
}
}
}
假如此时buf
大小1024B,一次读入数据64KB,一次读不完,又要select
再继续读剩余的部分,会造成128次的系统调用。
一种优化就是把blocking fd设置成O_NONBLOCK
,select一次invoke后,允许循环调用read
,直到遇到EWOULDBLOCK
错误:
ssize_t nbytes;
for (;;) {
/* select call happens here */
if (select(FD_SETSIZE, &read_fds, NULL, NULL, NULL) < 0) {
perror("select");
exit(EXIT_FAILURE);
}
for (int i = 0; i < FD_SETSIZE; i++) {
if (FD_ISSET(i, &read_fds)) {
/* NEW: loop until EWOULDBLOCK is encountered */
for (;;) {
/* read call happens here */
nbytes = read(i, buf, sizeof(buf));
if (nbytes >= 0) {
handle_read(nbytes, buf);
} else {
if (errno != EWOULDBLOCK) {
/* real version needs to handle EINTR correctly */
perror("read");
exit(EXIT_FAILURE);
}
break;
}
}
}
}
}
具体之前说的错误码含义:
-
EWOULDBLOCK:
操作可能会阻塞 -
EAGAIN
: 资源暂时不可用(可能和EWOULDBLOCK有相同的errno值)
APUE P389:如果对一个非阻塞fd的操作不能无阻塞地完成(比如数据读完),4.3 BSD返回EWOULDBLOCK
,如今基于BSD提供POSIX.1的O_NONBLOCK
标志,并且将EWOULDBLOCK
定义为和POSIX.1的EAGAIN
定义含义相同。
最后总结一下LT/ET。
LT:
- ET模式和LT模式最大的不同在于调用
epoll_wait
时。LT模式会遍历每个fd,判断fd的状态是否匹配了event注册的目标条件(如EPOLLIN
)。只要有一个fd满足条件,epoll就会解除阻塞。
ET:
-
(需要理解)不会对fd检查,调用
epoll_wait
后会立刻sleep。当有新的数据进入内核,由于epoll维护了fd的事件表,O(1)时间内可以唤醒进程。 -
ET模式要求程序员完全read/write数据,直到返回
EWOULDBLOCK
,否则容易造成死锁。比如200B待处理数据,一次ET模式的epoll_wait+read 100B后,epoll_wait不会再次invoke,server一直在等新数据,client一直在等剩余100B数据处理完。
EPOLLONESHOT (Linux 2.6.2+)
应用场景
即使是ET模式下,也不能保证只有一个线程被唤醒(需要理解:像回复所说,问题场景是有多个线程epoll_wait
同一个fd?我的理解是当一个线程在处理触发的fd时候,最后一部分数据处理完设置EWOULDBLOCK
,但在判断errno
之前又有新数据进来,导致fd对应的事件又被触发并由其他线程处理,本线程去判断errno
时又没有问题,也会变成继续去处理该fd)。对一个可用fd,如果有多个进程被唤醒并开始对fd做IO,可能会导致竞争问题。
EPOLLONESHOT
:和EPOLLET
一样设置于epoll_event.events
中的事件类型。设置对应fd事件为单次触发,这意味着当一个事件被epoll_wait
触发,相关的fd在内部被禁用,和它相关的事件不会再被epoll接口通知。当一个线程的epoll_wait
触发了一个fd事件并开始处理,那么不管之后该fd是否满足触发条件,fd对应的事件都不会再被触发,保持了本线程对fd做IO时的独占性。
触发设置EPOLLONESHOT
的fd这种独占性会一直保持下去,除非显示地使用epoll_clt
和EPOLL_CLT_MOD
恢复对应事件的epoll_event.events
事件类型。
#实例
书上代码实例:GitHub
参考文献
[1]. UNIX环境高级编程
[2]. Linux高性能服务器编程
[3]. https://zh.wikipedia.org/wiki/Epoll
[4]. https://linux.die.net/man/4/epoll
[5]. https://blog.youkuaiyun.com/Mr_H9527/article/details/99745659