一. 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 中三个耗时操作的:
- 通过 epoll_ctl 调用可以细粒度的控制每个要监听的 fd,而不必像 select 一样,每次调用都要把要监听的 fd 都拷贝一份到内核(导致大量重复的拷贝);
- 对于 select 和 poll 调用来说,内核需要循环遍历找出其中就绪的 fd,而 epoll 则是基于事件驱动的,当有 fd 就绪时,会自动通过注册好的回调函数将其放入就绪列表,所以 epoll 只需要监听就绪集合是否为空就好;
- 类似于 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,那么一定会在最后一次调用(没有数据可读/写)时阻塞,导致无法正常结束