在日常应用中,一个进程通常需要在多个描述符上进行 I/O 操作,例如,程序需要向 stdout 和 stderr 输出日志,同时还要接受 socket 连接,另外还要对外提供服务。有鉴于此,我们需要实现描述符的 I/O 多路复用。
⒈ 描述符
描述符也成为文件描述符。Unix 中 I/O 的基本构成为字节序列(字节流或 I/O 流)。进程通过文件描述符来引用 I/O 流,管道、文件、队列等都是通过文件描述符引用 I/O 流的例子。
文件描述符可以显式的通过系统调用(open、pipe、socket……)创建,也可以通过继承父进程来创建。当进程退出时,文件描述符会被释放,进程还可以通过系统调用 close
来释放文件描述符。另外,如果文件描述符被标记为 close-on-exec
,那么在 exec
操作之后文件描述符也会被释放。
close-on-exec:
当一个进程被fork
时,子进程会复制当前进程的所有文件描述符。如果此时有文件描述符被标记为close-on-exec
,那么在fork
操作之后,这些被标记的文件描述符在子进程中会被关闭并且不再对子进程可用。
⒉ 文件实例
每个文件描述符都会指向一个文件实例,文件实例会为每个文件描述符维护一个偏移量(以字节为单位,从文件头开始计算)。系统调用 open
会创建一个新的文件实例,fork
操作会导致同一个文件描述符以及其偏移量在父子进程之间共享。
由于多个文件描述符会同时指向一个文件实例,所以文件实例会为每个文件描述符维护一个偏移量。文件描述符的读写操作都从相应的偏移量位置开始,并且在每次操作完成之后都会更新偏移量。当进程结束时,内核会回收被进程使用的文件描述符,如果在此之后再没有文件描述符指向文件实例,那么内核会回收文件实例。
⒊ 非阻塞文件描述符
默认情况下,文件描述符的读写操作都是阻塞的。但文件描述符可以被设置为非阻塞的状态,此时,文件描述符的 I/O 操作结果会立即返回。如果文件描述符没有就绪,那么会返回错误;否则会根据 I/O 操作的执行情况返回相应的结果(部分完成或全部完成)。
检测文件描述符是否就绪有两种方式:水平触发和边沿触发。
- 水平触发
为了检测文件描述符是否就绪,进程可以多次在文件描述符上尝试执行非阻塞的 I/O 操作。进程在检测到文件描述符就绪后,后续的 I/O 操作如何进行,完全由进程决定,这赋予了进程很大的灵活性。例如,进程可以选择读取全部可用数据,也可以读取部分可用数据,还可以不做任何操作。
上图中,进程在 t0、t1、t2 分别进行尝试 I/O 操作,但由于文件描述符没有就绪,所以返回错误。在 t3 时刻,文件描述符就绪,此时进程执行完整的 I/O 操作;在 t5 时刻,文件描述符就绪,进程执行部分 I/O 操作;t6 时刻,文件描述符就绪,但进程不进行任何 I/O 操作。
- 边沿触发
文件描述符在就绪后会向进程推送通知,但进程仅仅收到文件描述符就绪的通知,并不清楚具体有多少数据量可读/写。所以,进程会尽量多的去读/写数据,因为下次的 I/O 操作需要等到下一次文件描述符就绪的消息到达。
上图中,t1 时刻文件描述符就绪的通知到达,此时 buffer 中有 1024 字节的数据可用,而进程只读取了 500 字节,导致 buffer 中仍然有 524 字节数据待处理。等 t4 时刻文件描述符就绪的消息再次到达时,buffer 中又新来了 1024 字节的数据,此时 buffer 中共有 1548 字节可用,而进程只读取了 1024 字节的数据,此时 buffer 中剩余的 524 字节的数据只能等待下一次就绪的消息到达时再处理。
⒋ 文件描述符的 I/O 多路复用
实现 I/O 多路复用的方法有多种,可以通过非阻塞的 I/O,也可以通过信号驱动,还可以通过轮询,实际应用中用的最多的 select、poll、epoll 都是通过轮询的方式实现。
- 非阻塞 I/O
在此种模式下,所有的文件描述符都会被设置为非阻塞的状态。进程会采用水平触发的方式来检测文件描述符是否就绪,而内核会根据文件描述符的实际情况返回相应的结果:如果没有就绪则返回错误,否则返回部分或全部的结果。
这种方法面临的一个问题就是,当进程检测文件描述符是否就绪的频率较高时,进程就必须不断的对返回错误的操作进行重试,这样就增加了对 CPU 的消耗;而如果进程进行检测的频率较低,那么对于已经就绪的文件描述符,可能需要等待很长时间才能被进程操作。
鉴于以上情况,非阻塞 I/O 的应用场景主要有两种:
⓵ 输出型文件描述符上的操作通常不会被阻塞,对于这种场景,进程可以先进行 I/O 操作,当返回错误时再定时检测文件描述符是否就绪;
⓶ 对于边沿触发的情形,进程在收到文件描述符的就绪通知后可以重复的对文件描述符进行 I/O,直到操作被阻塞
- 信号驱动
在此种模式下,内核会监控一个文件描述符链表,当链表中有文件描述符就绪时,内核会向进程发送信号,而进程在收到信号后会对相应的文件描述符进行 I/O 操作。
但由于信号捕获的成本很高,所有当有大量 I/O 执行时,这种方式会显得很不切实际。其应用场景也仅限于异常捕获。
- 轮询
在此种模式下,文件描述符会被设置为非阻塞的状态,进程通过相应的系统调用采用水平触发的方式来监测文件描述符是否就绪。这里需要指出,select 和 poll 是系统调用,所有的 Unix 以及类 Unix 系统都支持,而 epoll 是一种内核数据结构,只有 Linux 支持。
- select
int select(
int nfds,
fd_set *readfds, // 读
fd_set *writefds, // 写
fd_set *exceptfds, // 异常
struct timeval *timeout
);
select 分别监控三组独立的文件描述符:读、写、异常。对于 select 不关心的操作,可以相应的将 fd_set
设置为 NULL
。timeout
定义了 select 的超时时间:
- 当
timeout
值为 0 时,select 会立即返回,永远不会被阻塞 - 当
timeout
值为 NULL 时,select 会一直阻塞,此时相应的进程会进入睡眠状态,直到 select 返回。当有文件描述符就绪或 select 调用被信号处理程序中断时 select 才会返回。 - 当
timeout
为固定的值时,select 首先会阻塞。当有文件描述符就绪或 select 被信号处理程序中断或 select 阻塞时长达到超时时间设置的固定值时 select 会返回。
相应的,select 的返回值也会有三种:
- 发生错误时返回 -1
- 超时则返回 0
- 如果有文件描述符就绪,则返回就绪的文件描述符的数量。具体哪些文件描述符就绪则由相应的
fd_set
检测
- poll
int poll(
struct pollfd *fds,
nfds_t nfds,
int timeout
);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
poll 与 select 的区别在于追踪文件描述符的方式。select 中由三组独立的文件描述符来分别监测读、写、异常;poll 中只需要一组文件描述符,每个文件描述符确定自身需要监测哪些操作,具体每个文件描述符需要监测哪些事件有 pollfd
中的 events
决定。
fds
为一个 pollfd
数组,其中,每一个 pollfd
都记录了当前文件描述符 ID 以及该文件描述符监测的事件。nfds
记录了 fds
数组的长度,即 poll 监测的文件描述符的数量。timeout
记录了 poll 操作的超时时间:
- 当 timeout 值为 -1 时,poll 操作被阻塞直到有文件描述符就绪或有信号被捕获
- 当 timeout 值为 0 时,poll 操作会立即返回,永远不会被阻塞
- 当 timeout 为一个特定值时,poll 操作会被阻塞直到超时或有文件描述符就绪或有信号被捕获
相应的,poll 操作的返回值也有三种情况:
- 当有错误发生时,返回 -1
- 超时则返回 0
- 有文件描述符就绪,则返回就绪的文件描述符的数量,具体文件描述符上的那种操作就绪,可以通过 pollfd 中 revents 字段得知
select 和 poll 的共同点在于二者都是无状态的,每次调用都需要将所有要监测的文件描述符传递给内核,内核再逐个检测传入的文件描述符的状态。亦即这两种调用方式的时间复杂度都是 。所以,当传入的文件描述符的数量非常多时,每次调用的事件就会非常长,这也就是 select 文件描述符的数量上限为 1024 的原因(poll 没有此限制)。另外,与非阻塞 I/O 和信号信号驱动的方式相比,select/poll 需要先执行一次系统调用检测哪些文件描述符处于就绪状态,然后再相应的执行 I/O 操作。
- epoll
前文已有说明,epoll 是一种内核数据结构,这种数据结构的创建、修改、删除都需要相应的系统调用来完成。
⓵ epoll_create
#include <sys/epoll.h>
int epoll_create(int size); // 从 Linux 2.6.8 开始,size 参数不再需要
epoll 实例通过改方法创建,调用成功后会返回一个 epoll 实例的文件描述符。参数 size 用来标识调用进程需要监控的文件描述符的数量,也间接的决定了 epoll 实例的大小。但最新的 epoll 数据结构可以动态的添加或删除文件描述符,所以 size 参数不再需要。
int epoll_create1(int flags)
以上方法也可以用于创建 epoll 实例,参数 flags 的取值可以为 0 或 EPOLL_CLOEXEC
。当取值为 0 时,epoll_create1
的行为与 epoll_create
相同;当取值为 EPOLL_CLOEXEC
时,当前进程 fork 产生的子进程会关闭 epoll 实例的文件描述符。
⓶ epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
所有被进程添加到 epoll 实例中的文件描述符会形成一个集合,叫做 epoll set
或 interest list
。当集合中的文件描述符的 I/O 就绪时,这些就绪的文件描述符会被放入 ready list
,ready list
为 interest list
的子集。
进程通过 epoll_ctl
实现向 epoll 实例中添加文件描述符,从 epoll 实例中移除文件描述符,以及修改已经添加到 epoll 实例中的文件描述符。其中:
epfd
即为指向 epoll 实例的文件描述符fd
为进程要操作(添加/修改/移除)的文件描述符op
为进程要对fd
执行的操作,可选的操作有添加/修改/删除event
指向结构体epoll_event
的指针,该结构体保存了进程要监听的 fd 的事件类型
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 */
};
其中,events 就以位掩码的方式标识了进程要监听的 fd 的事件类型。
⓷ epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *evlist, int maxevents, int timeout);
进程可以通过调用 epoll_wait
得知 interest list
中是否有文件描述符的 I/O 就绪。其中:
epfd
为 epoll 实例evlist
为epoll_event
数组,由调用进程分配,但在返回是会被修改为ready list
中文件描述符的相关信息(data 用来标识文件描述符,events 用来标识事件)maxevents
为evlist
的长度timeout
为超时时间,作用与 select 和 poll 中的timeout
作用相同
epoll_wait 的返回值也分为三种情况:
- 当有错误发生是返回 -1
- 超时则返回 0
- 返回 evlist 的长度
当调用进程通过 epoll_ctl
向 epoll 实例中添加要监控的文件描述符时,实际添加到 interest list
中的是文件描述符所指向的文件实例。所以,与 select 和 poll 相比,epoll 实际监控的是文件实例,当文件实例的 I/O 就绪时,内核会自动将其添加到 ready list
中而无需等待进程调用 epoll_wait
。当进程调用 epoll_wait
时,内核只需要将 ready list
中的文件描述符的相关信息返回。
另外,epoll 不需要像 select 和 poll 每次调用都传递文件描述符,内核每次也只需要返回 I/O 就绪的文件描述符的信息。
默认情况下,epoll 提供的是水平触发的通知方式,即每次调用返回所有 I/O 就绪的文件描述符集合。但在某些时候,我们可能只关心 interest list
中某些特定的文件描述符的状态,此时我们可以在调用 epoll_ctl
时在 events
参数中加入 EPOLLET
标识。