OS: I/O: Multiplexing

 11th March 2021 at 11:33pm

I/O 多路复用指的是监听多个 fd,并在其中任一 fd 可读写时通知进程进行操作。API 有:

  • select() poll():性能差,可移植性好
  • epoll():Linux 专有 API,性能好
  • kqueue():BSD 专用 API,性能好

这里主要讨论 select() poll()epoll()

在继续之前,要先讲讲两种不同类型的 readiness notification,分别是水平触发(level-triggered notification)以及边缘触发(edge-triggered notification)。

Level-triggered and edge-triggered

做了个视频讲解:

select 和 poll

这两个函数功能类似,poll() 功能多一些,签名分别是:

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
           struct timeval *timeout);
int poll(struct pollfd fds[], nfds_t nfds, int timeout);

select() 是把读、写、出错的 fd 分别以 3 个 set 传入;poll() 则是将它们都合并成一个数组,再通过数组元素的属性来区分。

什么情况下 fd 才算是 ready?

仅在可以不 block 地进行 read() / write() 的情况下。对于不同类型的 fd(如文件、FIFO、socket)等,ready 代表的含义也不同。参考 The Linux Programming Interface 一书第 63 章。

epoll

不同于 select 及 poll 需要不停地传入关心的 fd 列表,epoll 将该列表维护在内核中。epoll 的 API 如下:

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);

epoll_create() 会返回这个 epoll 的 fd;再通过 epoll_ctl() 将关心的 fd 加入进来:

int epfd;
struct epoll_event ev;
epfd = epoll_create(5);
if (epfd == -1)
    errExit("epoll_create");
ev.data.fd = fd;
ev.events = EPOLLIN;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) == -1)
    errExit("epoll_ctl");

之后调用 epoll_wait() 时,系统会把 ready 的 fd 通过参数 evlist 返回。

性能对比

可以看出 select / poll 的 CPU 消耗,基本跟监听的 fd 数量成正比;而 epoll 则不太受数量影响。性能差距非常大。

对于 select / poll,kernel 在判断哪些 fd ready 时,

  • 对于 select,kernel 需要扫描 [0, nfds-1] 一共 nfds 个 fd,即使三个 fd_set 中只有一个值为 nfds-1 的 fd
  • 对于 poll,kernel 需要扫描 fds 中指定的一共 nfds 个 fd;这些 fd 都是被关心的

这就意味着,对于 select 的场景,假如 fd_set 中的 fd 非常稀疏,比如 [1, 3, 5, 10000],kernel 仍然需要扫描 10001 个 fd;这是非常低效的。poll 则没有此问题。

当监听的 fd 非常多时:

  • 每次调用 select / poll 时,kernel 都必须 check 每个 fd 的 ready 情况,非常耗 CPU
  • 对于 select,因为参数中的 fd_set 是 value-result,即调用结果也通过修改这些参数来返回,所以每次调用完都需要重新初始化 fd_set,这带来 CPU 消耗;对于 poll,在 user space 和 kernel 间传递 fds 数组的消耗也巨大
  • 调用返回时,用户程序 需要检查每个 fd 来判断是否 ready

对于 epoll,下面的原因使得它性能高:

  • 由于监听的 fd 放在 kernel 中维护,少了传递参数和遍历的消耗
  • 由于 kernel 知道哪些 fd 是被关心的,因此当这些 fd 有状态变化时,kernel 将其放入 ready list 并等待下次 epoll_wait() 调用时返回即可

参考

  • Chapter 63, The Linux Programming Interface