IO多路转接之epoll

相对于select来说,epoll没有描述符个数限制,使用一个文件描述符管理多个描述符,将用户关心的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll的相关系统调用:

epoll_create

1
int epoll_create(int size);

创建一个epoll的句柄。

注意:

  1. 自从linux2.6.8之后,size参数是被忽略的
  2. 用完之后,必须调用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
2
3
4
5
6
7
8
9
10
11
12
13
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;

struct epoll_event
{
uint32_t events;
epoll_data_t data;
}_EPOLL_PACKED;

events可以是以下几个宏的集合:

  1. EPOLLIN:表示对应的文件描述符可以读(包括对端socket正常关闭)
  2. EPOLLOUT:表示对应的文件描述符可以写
  3. EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
  4. EPOLLERR:表示对应的文件描述符发生错误
  5. EPOLLHUP:表示对应的文件描述符被挂断
  6. EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的
  7. 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
    8
    struct 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
2
3
4
5
6
7
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
  • 当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可
  • 如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。这个操作的时间复杂度是O(1)

总结一下,epoll的使用过程就是三步:

  1. 调用epoll_create创建一个epoll句柄
  2. 调用epoll_ctl,将要监控的文件描述符进行注册
  3. 调用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机制仍然存在“惊群”。

0%