Linux IO 多路复用的三种实现

590 阅读8分钟

一. select

最初版本的 IO 多路复用实现,其函数签名如下:

int select(int nfds,
           fd_set *restrict readfds,
           fd_set *restrict writefds,
           fd_set *restrict errorfds,
           struct timeval *restrict timeout);

具体介绍 select 之前,有几个前置概念要先说明:

  • fd:文件描述符;

操作系统会为每个进程维护一张文件描述符表,里面记录了该进程打开和创建的文件,文件描述符就是作为这张表的索引而存在的,直观地说,就是一个进程可以根据文件描述符来唯一确定一个打开的文件;
文件描述符自身是一个从 0 开始的非负整数;

  • fd_set:文件描述符集合

记录一组文件描述符的对象,其本质可想象成一个 n 位的二进制数字,最多可记录 n 个 fd,就比如当 n = 8 时,要标记 1,2 和 5 号 fd,那么 fd_set 就可以记为:00010011;
fd_set 的使用涉及以下几个 api:
int FD_ZERO(int fd, fd_set *fdset); // 将 fd_set 所有位置 0
int FD_CLR(int fd, fd_set *fdset); // 将 fd_set 某一位置 0
int FD_SET(int fd, fd_set *fd_set); // 将 fd_set 某一位置 1
int FD_ISSET(int fd, fd_set *fdset); // 检测 fd_set 某一位是否为 1

下面说明 select 的几个参数:

  • readfds: 监听其中 fd 的可读事件;
  • writefds: 监听其中 fd 的可写事件;
  • errorfds: 监听其中 fd 的异常事件;
  • nfds: nfds = n 时,内核就只监听上述三个集合中前 n 个 fd;
  • timeout: 表示调用 select 时的阻塞时长。如果所有文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的 timeout 后,返回。如果 timeout 参数设为 NULL,会无限阻塞直到某个描述符就绪;如果 timeout 参数设为 0,会立即返回,不阻塞;

select 返回值代表就绪 fd 的总数;

select 内部执行的逻辑大概如下:

int count = 0;
for (int fd : fd_set)
{
    if (!FD_ISSET(fd, fd_set))
        continue;
    FD_CLR(fd, fd_set);
    if (isReady(fd))
    {
        count++;
        FD_SET(fd, fd_set);
        berak;
    }
}
return count;

用户进程在 select 返回后,只需要通过 FD_ISSET 检查 fd_set 中哪些位是 1,就可以得出哪些文件描述符就绪了;

综上可以看出 select 的主要开销如下:

  • 把三个 fd_set 拷贝的内核;
  • 循环遍历三个 fd_set,知道有 fd 就绪或者超时;
  • 用户进程在 select 返回后不能直接得到就绪的 fd,还需要自己遍历一遍 fd_set 判断;

此外,由于受 fd_set 长度的限制,select 调用可以监听的 fd 数量有限;

下面给出一个 select 时使用示例:

二. poll

poll 与 select 没有本质的区别,其出现的目的主要在于解决 select 可同时监听的 fd 数量较少这个问题;

select 可同时监听的 fd 数量主要受限于 sizeof(fd_set),poll 解决的方式是用数组来存储所有要监听的 fd,poll 的函数签名如下:

int poll(struct pollfd *fds, 
         nfds_t nfds, 
         int timeout);

nfds 和 timeout 不用再说,下面主要来看一下 fds;

fds 的类型是 pollfd *,说明其是一个 pollfd 类型的数组,pollfd 的定义如下:

struct pollfd {
       int   fd;  /* 需要监听的文件描述符 */
       short events;  /* 需要监听的事件,可以不止一个,比如可以同时监听可读和可写事件 */
       short revents;  /* 实际发生的事件,由内核来填充 */
   };

poll 内部的执行过程和 select 大同小异,此处不再赘述;

poll 调用返回后,就可以检查 pollfds 数组中每一个 pollfd 对象的 revents 来判断其对应的 fd 数据是否就绪,从而执行相应的读写;

综上可以看出 poll 除了解决 "select 可同时监听的 fd 数量较少" 这个问题外,对 select 执行前后的主要三个开销并没有做出优化;

三. epoll

相比于 poll,epoll 才是 select 真正的改进者,对 select 主要的三个耗时操作都做出了极大的优化。

epoll 不是一个单独的系统调用,对应的 epoll 模型其实由三个函数:

epoll_create

函数签名如下:

int epoll_create(int size);

该调用会创建一个 epoll 实例,并返回该实例对应的 fd;

epoll 实例内部由两个集合:

  • 监听列表:所有要监听的 fd,数据机构为红黑树;
  • 就绪列表:所有数据就绪的 fd,数据机构为链表;

epoll_ctl

函数签名如下:

int epoll_ctl(int epfd,
              int op,
              int fd,
              struct epoll_event *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 */
};             

参数说明:

  • epfd: epoll 实例对应的 fd,由 epoll_create 函数返回;
  • op: 要对 fd 执行的操作,比如为 fd 添加一个 event 或删除 fd 所有的监听事件;
  • fd: 要监听的文件描述符;
  • event: 要监听 fd 的具体事件;

返回值 0 或 -1,表示上述操作成功与否。

epoll_ctl 会将文件描述符 fd 添加到 epoll 实例的监听列表里,同时为 fd 设置一个回调函数,并监听事件 event。当 fd 上发生相应事件时,会调用回调函数,将 fd 添加到 epoll 实例的就绪队列上。

epoll_wait

其函数签名如下:

int epoll_wait(int epfd, 
               struct epoll_event *events,
               int maxevents, 
               int timeout);

这是 epoll 模型的主要函数,功能相当于 select。

参数说明:

  • events 是一个数组,保存就绪状态的文件描述符,其空间由调用者负责申请,即内核只负责往里面填数据,不会自动申请空间,所以传入的不能是 NULL;
  • maxevents指定 events的大小; 返回值表示 events 中存储的就绪描述符个数,最大不超过 maxevents。

epoll 带来的优化

下面来说明 epoll 是如何解决 select 中三个耗时操作的:

  1. 通过 epoll_ctl 调用可以细粒度的控制每个要监听的 fd,而不必像 select 一样,每次调用都要把要监听的 fd 都拷贝一份到内核(导致大量重复的拷贝);
  2. 对于 select 和 poll 调用来说,内核需要循环遍历找出其中就绪的 fd,而 epoll 则是基于事件驱动的,当有 fd 就绪时,会自动通过注册好的回调函数将其放入就绪列表,所以 epoll 只需要监听就绪集合是否为空就好;
  3. 类似于 select 和 poll,也是对传入的参数(events)进行修改,来通知调用者(用户进程)哪些 fd 就绪,不同的是 select 和 poll 要进行完整遍历操作,才能找到其中就绪的 fd,而 epoll 则是将所有就绪的 fd 准备好统一交给调用者,无需调用者再自行过滤,这就解决了使用 select 时 "用户进程在 select 返回后不能直接得到就绪的 fd,还需要自己遍历一遍 fd_set 判断" 这一耗时操作;

四. 水平触发和边缘触发

  • 水平触发(LT,Level Trigger) :当文件描述符就绪时,会触发通知,如果用户程序没有一次性把数据读/写完,下次监听时还会发出可读/可写信号进行通知。
  • 边缘触发(ET,Edge Trigger) :仅当描述符从未就绪变为就绪时,通知一次,之后不会再通知。

select 只支持水平触发,epoll 支持水平触发和边缘触发

区别:边缘触发效率更高,减少了事件被重复触发的次数

为什么边缘触发必须使用非阻塞 I/O?

每次通过 read 系统调用读取数据时,最多只能读取缓冲区大小的字节数;如果某个文件描述符一次性收到的数据超过了缓冲区的大小,那么需要对其 read 多次才能全部读取完毕;

select 可以使用阻塞 I/O。通过 select 获取到所有可读的文件描述符后,遍历每个文件描述符,read 一次数据,这些文件描述符都是可读的,因此即使 read 是阻塞 I/O,也一定可以读到数据,不会一直阻塞下去; select 采用水平触发模式,因此如果第一次 read 没有读取完全部数据,那么下次调用 select 时依然会返回这个文件描述符,可以再次 read;

select 也可以使用非阻塞 I/O。当遍历某个可读文件描述符时,使用 for 循环调用 read 多次,直到读取完所有数据为止(返回 EWOULDBLOCK)。这样做会多一次 read 调用,但可以减少调用 select 的次数;

epoll 的边缘触发模式下,只会在文件描述符的可读/可写状态发生切换时,才会收到操作系统的通知。

因此,如果使用 epoll边缘触发模式,在收到通知时,必须使用非阻塞 I/O,并且必须循环调用 read write 多次,直到返回 EWOULDBLOCK 为止,然后再调用 epoll_wait 等待操作系统的下一次通知。

如果没有一次性读/写完所有数据,那么在操作系统看来这个文件描述符的状态没有发生改变,将不会再发起通知,调用 epoll_wait 会使得该文件描述符一直等待下去,服务端也会一直等待客户端的响应,业务流程无法走完。

这样做的好处是每次调用 epoll_wait 都是有效的——保证数据全部读写完毕了,等待下次通知。在水平触发模式下,如果调用 epoll_wait 时数据没有读/写完毕,会直接返回,再次通知。因此边缘触发能显著减少事件被触发的次数。

为什么 epoll边缘触发模式不能使用阻塞 I/O?很显然,边缘触发模式需要循环读/写一个文件描述符的所有数据。如果使用阻塞 I/O,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束

参考资料