背景:内核的阻塞和非阻塞 §
在使用 Linux 内核的 socket 的时候,我们需要考虑阻塞与非阻塞的问题。Kernel 内部维护了定长的 buffer 来保存需要传递的数据和收到的数据。因此,一般情况下,我们写这样的系统调用的时候,都是阻塞的:
这意味着,如果内核内部的 buffer 是空的,就会等到有数据时才返回。
但是网络请求与本地的数据传输不一样,网络是可能产生各种错误的。因此,如果采用纯阻塞的设计,会导致 CPU 被白白浪费掉,甚至造成死锁等更严重的问题。考虑到这一点,内核也提供了非阻塞的方式:
问题 1:concurrency §
如果使用上面的非阻塞接口,就需要有一个循环反复查询是否成功:
这种 polling 的方式会非常消耗 CPU 时间,造成能效和性能的下降。这里是一个常规的 tradeoff,我们需要通过异步的方式来降低 polling 带来的开销,例如中断的机制。如果没有请求到来,最好的办法应该是让程序进入睡眠,腾出 CPU 来做别的工作。
解决方案 1:select §
select(2)
的存在就是为了解决上面的问题:
select
的机制是,应用可以将需要管理的一系列 fd 都传入 kernel,kernel 负责监听这些 fd,一旦某些 fd 已经准备好(e.g. 有数据可读),就返回应用。应用可以自己检查哪些 fd 需要响应,并执行对应的操作。
当 kernel 返回时,可以通过检查每一个 fd,来确定哪些 fd 有事件发生:
select
机制带来了良好的 I/O 多路复用能力,且不会造成性能上的副作用。然而,select
的最大问题是采用了定值作为 fd 集合的大小,这意味着如果我们在程序中需要处理超过 fd_set
所能承受的上限,select
就无法使用了。
解决方案 2:poll §
poll(2)
就是为了解决这个问题而实现的:
NAME
poll — input/output multiplexing
SYNOPSIS
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
poll
采用了动态长度的数组来处理 fd,同时在 API 的设计方面也有一些改进,例如引入了输入和输出两个事件,同时通过参数 revent
来表示产生的事件,从而提供了更细粒度的事件控制。
然而,poll
也存在一个问题:每次处理事件,都需要遍历。内核拿到应用提供的 fd 数组,需要遍历检查哪些 fd 产生了事件;应用从内核返回时,也需要编译所有的数组来确定哪些 fd 产生的消息。当 fd 数组的数目变得非常大的时候,这显然会成为一个性能的瓶颈。
解决方案 3:epoll §
Linux kernel 为了解决这个问题,提出了 epoll(2)
:
NAME
epoll - I/O event notification facility
SYNOPSIS
#include <sys/epoll.h>
epoll
在内核中维护了一个数据结构,用户与之交互来实现 fd 的插入和删除,两者保持数据的同步。这样,内核就能够直接返回哪些 fd 产生了事件。同时,epoll
也保持了 poll
所实现的事件分离机制,能够根据不同的事件细粒度地通知。