相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll的相关系统调用:
epoll_create
1 | int epoll_create(int size); |
创建一个epoll的句柄。
注意:
- 自从linux2.6.8之后,size参数是被忽略的
- 用完之后,必须调用close()关闭
epoll_ctl
1 | int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event); |
epoll的时间注册函数。
- 它不同于select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型
- 第一个参数是epoll_create()的返回值(epoll的句柄)
- 第二个参数表示动作,用三个宏来表示
- 第三个参数是需要监听的fd
- 第四个参数是告诉内核需要监听什么事件
第二个参数的取值:
- EPOLL_CTL_ADD:注册新的fd到epfd中
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件
- EPOLL_CTL_DEL:从epfd中删除一个fd
struct epoll_event结构如下:
1 | typedef union epoll_data |
events可以是以下几个宏的集合:
- EPOLLIN:表示对应的文件描述符可以读(包括对端socket正常关闭)
- EPOLLOUT:表示对应的文件描述符可以写
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
- EPOLLERR:表示对应的文件描述符发生错误
- EPOLLHUP:表示对应的文件描述符被挂断
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列中。
epoll_wait
1 | int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout); |
收集在epoll监控的事件中已经发送的事件。
- 参数events是分配好的epoll_event结构体数组
- epoll将会把发生好的事件赋值到events数组中(events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存)
- maxevents告诉内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size
- 参数timeout是超时时间(毫秒,0会立即返回,-1是永久阻塞)
- 如果函数调用成功,返回对应I/O上已经准备好的文件描述符数目,如果返回0表示已经超时,返回小于0表示函数失败
epoll工作原理
就绪队列 + 红黑树 + 回调机制
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关
1
2
3
4
5
6
7
8struct eventpoll{
...
//红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
struct rb_root rbr;
//双链表中存放着将要通过epoll——wait返回给用户的满足条件的事件
struct list_head rdlist;
...
};每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法
这个回调方法在内核中叫epollcallback,它会将发生的事件添加到rdlist双链表中
在epoll中,对于每一个事件,都会建立一个epitem结构体
1 | struct epitem{ |
- 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
- 如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是O(1)
总结一下,epoll的使用过程就是三步:
- 调用epoll_create创建一个epoll句柄
- 调用epoll_ctl,将要监控的文件描述符进行注册
- 调用epoll_wait,等待文件描述符就绪
epoll的优点
- 文件描述符数目无上限:通过epoll_ctl()来注册一个文件描述符,内核中使用红黑树的数据结构来管理所有需要监控的文件描述符
- 基于时间的就绪通知方式:一旦被监听的某个文件描述符就绪,内核会采用类似于callback的回调机制,迅速激活这个文件描述符。这样随着文件描述符数量的增加,也不会影响判定就绪的性能
- 维护就绪队列:当文件描述符就绪,就会被放到内核中的一个就绪队列中。这样调用epoll_wait获取就绪文件描述符的时候,只要去队列中的元素即可,操作的时间复杂度是O(1)
- 内存映射机制:内核直接将就绪队列通过mmap的方式映射到用户态,避免了拷贝内存这样的额外性能开销
epoll工作方式
epoll有两种工作方式——水平触发(LT)和边缘触发(ET)
假如说有这样的一个例子:
我们已经将一个tcp socket添加到epoll描述符,这个时候socket的另一端被写入了2KB的数据,调用epoll_wait,并且他会返回,说明它已经准备好读取操作,然后调用read,只读取1KB的数据,继续调用epoll_wait·····
水平触发模式
epoll默认状态下就是LT工作模式。
- 当epoll检测到socket上事件就绪的时候,可以不立刻进行处理,或者只处理一部分
- 如上边的例子,由于只读取了1KB的数据,缓冲区还剩1KB数据,在第二次调用epoll_wait时,epoll_wait仍然会立刻返回并通知socket读时间就绪
- 直到缓冲区上所有的数据都被处理完,epoll_wait才不会立刻返回
- 支持阻塞读写和非阻塞读写
边缘触发模式
如果我们在第一步将socket添加到epoll描述符的时候使用了EPOLLET标志,epoll进入ET工作模式。
- 当epoll检测到socket上事件就绪时,必须立刻处理
- 如上边的例子,虽然只读了1K的数据,缓冲区还剩1K的数据,在第二次调用epoll_wait时,epoll_wait不会再返回了
- 也就是说,ET模式下,文件描述符上的事件就绪后,只有一次处理机会
- ET的性能比LT性能更高(epoll_wait返回的次数少了)。
- 只支持非阻塞的读写
ET模式非阻塞的原因
ET模式,数据就绪只会通知一次,也就是说,如果要使用ET模式,当数据就绪时,需要一直read,直到出错或完成为止。
但是这就会出现一个问题,如果当前的fd为阻塞的(默认),那么在读完缓冲区数据时,如果对端并没有关闭写端,那么该read函数会一直阻塞在此处,影响其他的fd及后续逻辑
所以在这里应该将fd设置为非阻塞,当没有数据的时候,read虽然读取不到任何内容,但是肯定不会被卡住,那么此时,说明缓冲区数据已经全部读取完毕,需要继续处理后续逻辑(读取其他的fd或者进入wait)
使用场景
epoll适用于多连接,且多连接中只有一部分连接比较活跃的时候
epoll的惊群问题
如今网络编程中经常用到多进程或多线程模型,大概的思路是父进程创建socket,bind、listen后,通过fork创建多个子进程,每个子进程继承了父进程的socket,调用accpet开始监听等待网络连接。这个时候有多个进程同时等待网络的连接事件,当这个事件发生时,这些进程被同时唤醒,就是“惊群”。这样会导致什么问题呢?我们知道进程被唤醒,需要进行内核重新调度,这样每个进程同时去响应这一个事件,而最终只有一个进程能处理事件成功,其他的进程在处理该事件失败后重新休眠或其他。网络模型如下图所示:
简而言之,惊群现象(thundering herd)就是当多个进程或线程在同时阻塞等待同一个事件时,如果这个事件发生,会唤醒所有的进程,但最终只可能有一个进程/线程对该事件进行处理,其他进程/线程会在失败后重新休眠,这种性能浪费就是惊群
在早期的Linux版本中,内核对于阻塞在epoll_wait的进程,也是采用全部唤醒的机制,所以存在和accept相似的“惊群”问题。新版本的的解决方案也是只会唤醒等待队列上的第一个进程或线程,所以,新版本Linux 部分的解决了epoll的“惊群”问题。所谓部分的解决,意思就是:对于部分特殊场景,使用epoll机制,已经不存在“惊群”的问题了,但是对于大多数场景,epoll机制仍然存在“惊群”。