epoll 中的边缘触发

如果说 poll 是 select 的简单优化,那么 epoll 就是 poll 的下一代。

典型的同步非阻塞方案

epoll 作为「次时代」的同步非阻塞 IO 模型,其真正划时代的点在于终于实现了「边缘触发」。

思考如下情况

  • (epoll_add) 监听 socketA,socketA 此时无数据
  • socketA 被写入了 2Byte
  • select / poll / epoll_wait 监听 socketA 返回结果
  • 从 socketA 读了 1Byte

如果这时,再对 socketA 执行 select 或 poll,那么它们会立刻返回,因为这时 socketA 依然「可读」。这就是水平触发(Level-Triggered),描述的是文件描述符的状态。

epoll

在 epoll 的世界中,默认和 select/poll 行为是一样的,但如果 socketA 被设定了边缘触发(在 epoll_ctl add/mod 时指定了 EPOLLET,即边缘触发的设定是被监听的文件描述符级别而不是 epoll 的文件描述符级别),那么这时 epoll_wait 不会返回,因为 socketA 在两次 wait 之间没有「变化」。这就是边缘触发(Edge-Triggered),描述的是文件描述符的变化状态。

在边缘触发的模型下写程序需要注意一点:所监听的文件描述符应当也是非阻塞的。以 read 为例,在调用 read 时需要传递一个固定大小的缓冲区,如果可读的大小大于缓冲区那么进行一次读是无法满足需求的,这就会造成除非向这个文件写了新的数据,否则之前没有读到的数据就永远无法读到了(在水平触发下则不会出现这个问题,因为该文件描述符仍然可读,因此下一次会立刻返回)。这个的解决方案就是一直读直到读完,但「读完」这一行为在网络中是受限的,如果相关文件描述符是阻塞的那么读取操作会一直阻塞直到来了新数据,因此正确的做法是将相关文件描述符设定为非阻塞而一直 read 直到出现 EAGAIN 才认为读完。

ONESHOT

但这样其实依然有改进空间,考虑如下情况

  1. epoll_ctl ADD ET socketA,socketA 此时无数据
  2. socketA 被写入了 10Byte
  3. epoll_wait 返回 socketA
  4. 从 socketA 读了 8Byte,返回的非 EAGAIN,继续读
  5. socketA 又被写入了 2Byte
  6. 从 socketA 再读 4Byte,返回 EAGIN,读完
  7. epoll_wait 再次返回 socketA
  8. 读 socketA 直接返回 EAGAIN

在 7 中返回是因为两次 epoll_wait 间隔是 3-7,而这其中在 5 时 socketA 又出现了变化,因此 7 仍然会返回

虽然 7,8 步骤不会有副作用,但在高并发情况下会造成额外的资源浪费,因此为了解决这一问题 epoll 提供了 EPOLLONESHOT 功能,当在 add/mod 时指定了这一 flag 后,一旦 epoll_wait 返回这一文件描述符则相关的变更监听会被暂停,直到处理完后再调用 epoll_ctl 恢复这一文件描述符

相关流程变成下面的

  1. epoll_ctl ADD ET,ONESHOT socketA,socketA 此时无数据
  2. socketA 被写入了 10Byte
  3. epoll_wait 返回 socketA
  4. 从 socketA 读了 8Byte,返回的非 EAGAIN,继续读
  5. socketA 又被写入了 2Byte
  6. 从 socketA 再读 4Byte,返回 EAGIN,读完
  7. epoll_ctl MOD ET,ONESHOT socketA
  8. epoll_wait 阻塞等待变动

可以看到,在 1 中加入了 EPOLLONESHOT 后,在 epoll_wait 到 epoll_ctl 之间的 socketA 的变化会被忽略,即 4-6 中的变化会被忽略,只有 epoll_ctl 调用时 socketA 依然可读或在之后 socketA 有变化后续 epoll_wait 才会返回 socketA

多个 wait 只有一个会被唤醒

在 epoll 中,如果有多个进程/线程对同一个 epollfd 进行 epoll_wait,那么在相应被监听的文件描述符出现变化时只有一个会被唤醒处理,也因此在多进程/多线程使用 epoll 时往往都会指定 ONESHOT,否则可能出现处理已经文件描述符变动时其再次变化唤醒其他进程或线程处理而导致竞争