epoll及Linux IO多路复用机制的对比

epoll概念

  • epoll实例(epoll instance)

    epoll_create创建出来的,也是一个文件句柄,可以添加到其他epoll实例的集合中。

  • epoll集合(epoll set)

    当前epoll实例管理的句柄集合。

  • 触发模式

    有两种触发模式,边缘触发(edge triggered,ET)和水平触发(level triggered,LT)。

    ET把一个个事件看成一个整体,而不管调用者把事件处理到什么程度了, 只有当新的事件来了epoll_wait才会返回此事件。 可能这就是此模式之所以称为边缘触发的原因吧, 即只有事件之间的边缘(edge),ET模式才会告知我们有新的事件。

    而LT就会关心处理程度,只要内核缓冲区还有数据,就认为还有事件需要应用层处理。

    以生活中代收快递的便利店为例,打一个不恰当的比喻:

    1. 快递代收点LT比较靠谱,无论收了多少快递以及顾客取走了多少, 只有还有代收的快递没有拿走,顾客来问都会告知还有快递没有拿走;

    2. 而另一个代收点ET就没那么靠谱了, 只有代收了快递后才会告知客户有新的快递,但是不管顾客有没有把快递完全拿走。 如果有没拿完的快递在店里,而又没有新代收的快递, 客户询问就告诉他没有快递。

    不难看出,ET模式客户处理起来相对费劲点,需要客户自己保证每次把快递都取完, 不然丢件了代收点是不负责的:-(

    由此,也引出了ET模式建议的使用方式,不然是要丢数据(快件)的:

    1. 句柄设置为非阻塞的

    2. 每次处理时间时,需要处理干净(“压榨”出所有的数据,直到返回EAGAIN)

    LT 语义同poll,只是速度更快,是默认的触发方式。

    触发方式是针对epoll所watch的fd而言的,即一个epoll实例可以同时使用两种触发方式, 但对于特定的fd,其触发方式只能是其中一种。

  • 每个用户可监听的fd数量限制

    Linux系统可以限制每个用户所拥有的所有的epoll集合的句柄总和,以限制其在内核中消耗的内存。 据man文档,一个fd在32位系统的内核中约占90字节,64位则约占160字节。

    可以通过proc子系统/proc/sys/fs/epoll/max_user_watches查看和设置当前值, 此限制是单个用户的所有epoll实例的所有集合共享的。

    在我的Fedora 22中,此限制是1650462,相当大了。

  • 每个用户可创建的epoll实例

    epoll_create(2)man page中提到一个用户可创建的epoll实例受限于 /proc/sys/fs/epoll/max_user_instances的设置,当超过限制时, epoll_create()/epoll_create1()会返回-1且errno被设置为EMFILE。

    但是,我的Fedora 22系统中为何没有此proc文件?

epoll API

一共四个API

  1. epoll_create(2)/epoll_create1(2)

    用于创建epoll实例。

    后者(命名为epoll_create1,看来大家都有纠结函数命名、变量命名的时候嘛~)比前者多了一个可设置项EPOLL_CLOEXEC, 设置了之后调用execve(2)时会关闭epoll实例。

  2. epoll_ctl(2)控制fd

    有几种不同的动作,可以注册EPOLL_CTL_ADD、注销EPOLL_CTL_DEL和修改EPOLL_CTL_MOD一个fd。

    第4个参数略复杂,有必要说明下,其对应的结构体定义如下:

    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 */
    };
    

    data字段是一个union,可以用于携带fd的相关数据,比如fd,方便在epoll_wait(2)后的事件处理中直接拿到句柄; 又比如携带fd所属的结构体指针,方便在处理事件时找到对应上下文(记得消息队列库nanomsg就是这么干的)。

    events是一个二进制标记集(bit set),常见的标记有:

    • EPOLL_IN 对句柄读事件感兴趣
    • EPOLL_OUT 对句柄写事件感兴趣
    • EPOLL_ET 设置为边缘触发,默认是水平触发。

      同时也表明一个epoll实例中LT和ET是可共存的,触发方式是对具体的fd而言的。

    • EPOLLONESHOT

      一次事件处理标记。

      设置了此标记之后,当fd有事件可处理时,epoll_wait在报告了此fd有事件待处理之后, 内部就清除掉fd对应的事件标记(比如EPOLL_IN)。

      上层应用在处理完事件后,如果想要继续接收事件,需要通过EPOLL_CTL_MOD重新注册相应的事件(所以称为ONE SHOT)。

  3. epoll_wait(2)epoll_pwait(2)

    epoll_wait超时时间单位是毫秒(millisecond)

    两者的关系就像select(2)pselect(2)poll(2)ppoll(2)epoll_pwait(2)等同于:

    ready = epoll_pwait(epfd, &events, maxevents, timeout, &sigmask);
    // 以上等同于以下: 
    sigset_t origmask;
    sigprocmask(SIG_SETMASK, &sigmask, &origmask); // sigmask中的信号将会被阻塞。
    ready = epoll_wait(epfd, &events, maxevents, timeout);
    sigprocmask(SIG_SETMASK, &origmask, NULL);
    

    注:ppoll相比于poll,除了可以屏蔽信号外,超时的时间精度更小, 可以精确到纳秒,使用的是struct timespec{}结构体:

    struct timespec {
        long tv_sec;    /* seconds */
        long tv_nsec;   /* nanoseconds */
    }
    

    相比之下,epoll_pwait仅比epoll_wait多了一个屏蔽信号的功能。

    为何需要p版本的多路复用?man 2 select中提到:

    The reason that pselect() is needed is that if one wants to wait for either a signal or for a file descriptor to become ready, then an atomic test is needed to prevent race conditions. (Suppose the signal handler sets a global flag and returns. Then a test of this global flag followed by a call of select() could hang indefinitely if the signal arrived just after the test but just before the call. By contrast, pselect() allows one to first block signals, handle the signals that have come in, then call pselect() with the desired sigmask, avoiding the race.)

    也就是说,是为了避免出现竞态条件(race condition):

  4. close(2) 关闭epoll实例本身

Linux系统三种多路复用技术对比

Linux系统共有三种IO多路复用机制,分别是select(2)poll(2)以及epoll(7)

共同点

  • 都有三种返回值

    1. -1表示出错(包括被信号打断)

    2. 0表示超时

    3. 大于0表示有事件待处理

  • 都有防止出现竞态条件的p版本

不同点

  • 超时时间精度不同,select是微秒,另外两个是毫秒(不考虑p版本)

  • epoll水平扩展性好,时间复杂度是O(1),其他两个是O(n),见[1]

    为了实际测试三者的时间复杂度, 我自己专门写了测试代码linux-multiplexing-comparison放在GitHub上。 通过检测(n-1)个无读事件以及一个有读事件的套接字集合,计算出三种机制检测到可读事件所花费的时间。 不断加大n值并记录3组时间,画出不同机制在不同的fd数量下的表现情况图。

    最终的结果是这样的。

    Linux IO多路复用性能对比

    可以看出与[1]中说的时间复杂度是吻合的。

  • epoll有输出参数,用于保存有事件可处理的fd,避免像其他两种方式那样需要遍历所有fd找出哪些fd需要处理。

  • epoll实例自身是一个fd,故需要多占用一个fd。

  • select打包的四个FD_*宏,依赖于宏FD_SETSIZE(一般为1024),可处理的fd集合很小。 如果要处理>=1024的fd,一种方法是自行构造fd bit array(牺牲了可移植性)。

另外,cURL和libcurl的作者和维护者Daniel Stenberg,也写过一篇对比poll、select和事件驱动框架差异的文章[2]。 从历史、功能、速度、可移植性以及复杂度等多个方面作了对比,其中有不少独到之处,值得一看。

参考

[1] Wikipedia

[2] poll vs select vs event-based

[3] epoll(7) man page

Linux IO模式及select、poll、epoll详解

How to use epoll? A complete example in C

Comparison of Performance of Different poll implementations

Using epoll() For Asynchronous Network Programming

social