1 基本概念
在介绍IO多路复用之前,有一些 Unix/Linux 的基本概念需要理清楚:
-
内核模式:Unix/Linux 的内核实现了最核心的操作系统功能,包括进程管理、存储管理、文件管理等。而内核模式就是指直接运行在内核中的程序集合,在内核模式下,可执行代码有完全访问底层硬件的权限,可以执行任意的 CPU 指令,可以访问任意的内存地址。内核模式一般保留作为操作系统最受信任的访问者。内核的奔溃是最致命的,会导致整个计算机的停机。
-
用户模式:在内核以外的空间属于用户空间(或应用空间)。在用户空间内运行的程序集合就是用户模式。在用户模式下,可执行代码不能直接访问硬件和内存,而是必须通过一组原语来委托内核去访问底层硬件资源,这组原语叫做系统调用。用户模式的存在用于隔离操作系统的底层复杂性,同时保证系统的安全运行,因为用户空间的错误不会导致内核的奔溃,是可恢复的。
-
文件:在 Unix 中,文件是一个抽象概念,文件不同于具体的文档。在 Unix 中,一切外设和大部分进程间通信(IPC)都可以用文件来表示,所以业内有 “Unix 中一切皆文件” 的说法。可以这样说,在 Unix 中一切可以进行字节流读写的媒介都可以表示为文件,这包括普通的文档、磁盘、CD-ROM、键盘、鼠标、显示器、终端,以及用于进程间通信的管道(pipe)、socket 等等。这些文件还有一个共同点,那就是在 Unix 中都可以表示为一个全局的命名空间(文件路径),如:/usr/local 引用本地目录,使用 /home/joe/memo.pdf 引用一个文件,使用 /mnt/cdrom 引用 CD-ROM,使用 /tmp/mysql.sock 引用 UNIX 域套接字等。有了文件这一统一的抽象之后,可以大大简化操作系统的设计。
-
文件描述符:有了文件的概念,在实现中,Unix 需要一个能够表示文件的符号,即文件描述符,它是一个非负整数(通常是小整数),任何一个打开的文件都可以通过一个文件描述符来表示和访问。在逻辑层面,文件描述符类似于程序设计语言中的指针或者引用的概念。而在物理实现层面,同一个文件实际上可以由不同的文件描述符指代,既可以是不同进程中的文件描述符,也可以是相同进程中不同的文件描述符。具体而言,每个进程都会维护一张文件描述符表,表的每一行有两个属性:
[文件描述符, 文件指针],其中文件指针会指向系统全局的一个打开文件表,所有进程打开的文件的上下文信息都会在内核系统文件表中进行记录,每行表示一个文件上下文。进程级的文件指针会指向系统文件表中该文件对应的行。所以,给定一个文件描述符,内核就能找到该文件并对其进行读写。 -
IO模型:进程之间的通信一般都要借助于文件。一般来说一个简单的 CS 通信模型的过程如下:先打开一个文件(或者多个,比如管道),进程 P1 往文件写数据;进程 P2 监控文件IO状态,一旦发现文件中有数据则将数据读出。这样就实现了简单的半双工通信功能——P2 向 P1 发送数据或通知。根据等待进程的行为以及通信的具体实现方式,可以分为以下几种IO模型:
-
阻塞IO(Blocking IO) :等待进程往文件读数据时,若文件中没有数据,等待进程让出 CPU 进入阻塞状态,等待文件中有数据后由内核唤醒。
-
非阻塞IO(Nonblocking IO) :等待进程往文件读数据时,若文件中没有数据,等待进程周期性地轮询内核直到数据写入文件为止,该方式对 CPU 资源是一种浪费。
-
IO多路复用(IO Multiplexing) :在传统的通信模型中,一个进程只监控一个文件描述符(比如 socket),若要同时监控多个文件,则同时创建多个服务进程或线程。比如在网络通信中,每个服务端口对应一个线程。这样在大量并发请求的时候,由于要创建大量的进程/线程,进程/线程间的切换代价会过高,导致效率低下。而在 IO多路复用模型中,一个进程/线程可用同时监控多个文件描述符,只要有一个文件状态发生变化,发送进程就可以进行响应处理。
-
信号驱动IO(Signal Driven IO)和异步IO(Asynchronous IO) :这两种通信模式在逻辑上和非阻塞 IO 一样,不同的是,当文件数据为空的时候,这两种模式下等待进程不需要进行轮询浪费 CPU 资源,也不用休眠阻塞,而是可以去做其它的事情,等待内核主动唤醒自己。这两种方式的差别在于具体的实现方式上。
-
2 IO多路复用技术
在 IO 多路复用技术中,通常有三种系统调用:select,poll,epoll。其中,select/poll 是传统 Unix 中的多路复用技术,其基本原理和性能都没有太大差别。而 epoll 是 Linux 中的专有 IO多路复用技术,相比 select/poll,在监控大量文件描述符的时候有显著的性能优势。
2.1 水平触发和边缘触发
当文件描述符准备就绪时,内核需要通知等待进程,通知的方式有两种:
- 水平触发通知:只要文件描述符上可以非阻塞地执行 IO系统调用,便认为已经就绪。比如,第一次发现文件中有数据可读,通知等待进程,若等待进程没有读完文件中的数据,即便第二次检查时文件中没有新输入数据,也会通知等待进程文件描述符就绪。
- 边缘触发通知:只有当文件描述符自上次状态检查以来有新的 IO活动(比如新的输入)时,才触发就绪通知。所以,若第一次通知后等待进程没有读完数据,那么除非文件有新的输入活动,否则,等待进程永远读不到剩下的数据。边缘触发通知模式下,要求等待进程在收到就绪通知后尽可能多地读完文件中的数据。
所以,水平触发类似于发了工资后按需取用,并把剩下的钱作为存款,这样每个月都有钱花;而边缘触发类似于把每个月的工资基本花完,这样必须领到新的工资后才有钱花,所以也可以叫月光触发。
2.2 select
通常,调用 select 之后,等待进程会一直阻塞,直到一个或者多个文件描述符集合成为就绪态。select 系统调用的函数原型如下:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
-
fd_set 类型:文件描述符集合,是一个最大容量为 1024 的数组。
-
nfds:所有监控的文件描述符的最大编号加1,≤ 1024。
-
readfds:用来检测输入是否就绪的文件描述符集合。
-
writefds:用来检测输出是否就绪的文件描述符集合。
-
exceptfds:用来检测异常情况是否发生的文件描述符集合。
-
timeout:一个超时结构体,若其值为0,select 调用后不会阻塞,它只是简单地轮询指定 的文件描述符集合,看看其中是否有就绪的文件描述符并立刻返回。当 timeout 设为 NULL,或其指向的结构体字段非零时,select 将阻塞直到有下列事件发生:
- readfds、writefds 或 exceptfds 中指定的文件描述符中至少有一个成为就绪态。
- 该调用被信号处理例程中断。
- timeout 中指定的时间上限已超时。
返回说明:
- 返回 -1:表示出错。
- 返回 0:表示已经超时,这时,参数中的所有文件描述符集合将被清空。
- 返回一个正整数:表示有 1 个或者多个文件描述符已经达到就绪态。返回值表示处于就绪态的文件描述符个数。在这种情况下,每个返回的文件描述符集合都需要检查,以此找出发生的 I/O 事件是什么。
在每次调用 select 之前,都必须指定要监控的文件描述符,同时在调用完成后,指定的文件描述符集合都有可能被修改。所以每次调用前都需要重新初始化各个文件描述符集合。
2.3 poll
系统调用 poll 执行的任务同 select 很相似。两者间主要的区别在于我们要如何指定待检 查的文件描述符。在 select 中,我们提供三个集合,在每个集合中标明我们感兴趣的文件描述符。而在 poll 中我们提供一列文件描述符,并在每个文件描述符上标明我们感兴趣的事件。poll 的函数原型如下:
struct pollfd {
int fd; // 监控的文件描述符
short events; // 在该文件描述符上感兴趣的事件
short revents; // 在该文件描述符上实际发生的事件
};
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
参数说明:
-
fds:文件描述符集合,一个动态数组,每个元素是一个表示文件描述符的结构体,包含待监控的文件描述符(fd)、在该文件描述符上感兴趣的事件(events)和在该文件描述符上实际发生的事件(revents)三个属性。
-
nfds:指定了数组 fds 中元素的个数。
-
timeout:决定了 poll 的阻塞行为,具体如下:
- 如果 timeout 等于−1,poll 会一直阻塞直到 fds 数组中列出的文件描述符有一个达到就绪态(定义在对应的 events 字段中)或者捕获到一个信号。
- 如果 timeout 等于 0,poll 不会阻塞,只是执行一次检查看看哪个文件描述符处于就绪态。
- 如果 timeout 大于 0,poll 至多阻塞 timeout 毫秒,直到 fds 列出的文件描述符中有一个达到就绪态,或者直到捕获到一个信号为止。
返回说明:
- 返回 -1:表示出错。
- 返回 0:表示该调用在任意一个文件描述符成为就绪态之前就超时了。
- 返回正整数:表示有 1 个或多个文件描述符处于就绪态了。返回值表示数组 fds 中拥有
非零 revents 字段的 pollfd 结构体数量。
在每次调用完 poll 之后,通过检查就绪的文件描述符然后完成响应的 IO 操作后可以将该文件描述符对应的 revents 事件清零,这样在下一次调用 poll 的时候,无需再对文件描述符集合进行初始化。除此以外,select 和 poll 还有如下异同:
- select 由于文件描述符集合类型 fd_set 的容量限制,能监控的最大文件描述符数量有限,而 select 因为是动态数组表示文件描述符集合,无此限制。
- 在性能上,如果待检查的文件描述符号码非常小,或者虽然很大,但是分布比较密集,那么两者的性能差异不大;否则如果检查的文件描述符很多,且分布很稀疏,则两者的性能差别就非常明显。原因是,在 select 中,我们传递一个或多个文件描述符集合,以及比待检查的集合中最大的文件描述符号还要大 1 的 nfds。不管我们是否要检查范围 0 到 nfds−1 之间的所有文件描述符,nfds 的值都不变。无论哪种情况,内核都必须在每个集合中检查 nfds 个元素,以此来查明到底需要检查哪个文件描述符。与之相反,当使用 poll 时,只需要指定 我们感兴趣的文件描述符即可,内核只会去检查这些指定的文件描述符。
select/poll 存在的问题:
在检查大量文件描述符时,两个系统调用有如下的一些性能问题:
- 内核检查文件描述符时间复杂度过大的问题:每次调用 select 或 poll,内核都必须检查所有被指定的文件描述符,看它们是否处 于就绪态。当检查大量处于密集范围内的文件描述符时,该操作耗费的时间将大大超过接下来的操作。
- 用户空间和内核空间来回拷贝文件描述符的问题:每次调用 select 或 poll 时,程序都必须传递一个表示所有需要被检查的文件描述符 的数据结构到内核,内核检查过描述符后,修改这个数据结构并返回给程序。(此外,对于 select 来说,我们还必须在每次调用前初始化这个数据结构。)对于 poll 来说, 随着待检查的文件描述符数量的增加,传递给内核的数据结构大小也会随之增加。当检 查大量文件描述符时,从用户空间到内核空间来回拷贝这个数据结构将占用大量的 CPU 时间。
产生以上问题的主要原因是,从内核的角度来看,每次从用户空间调用 select/poll 传递给内核的都是一系列全新的文件描述符,于是内核要去一一检查一遍,在超时范围内检查结束之后便放弃对这些文件描述符的监控,这样等到下次再调用 select/poll 时,内核又当做是一些新的文件描述符进行重新检查。而实际情况往往是程序重复调用这些系统调用所检查的文件描述符集合都是相同的。所以如果提前指示内核把这些文件描述符记录下来,告诉内核默认情况下下次到来还是检查这些文件描述符,那么内核只需要持续监控这些文件描述符的状态即可,只要有一个文件描述符的IO发生变化便向等待进程发送就绪通知。这样内核检查文件描述符的时间复杂度从 O(n) 降到了 O(1)。Linux 的 epoll 系统调用便是基于此思想实现的。
2.4 epoll
epoll 相对 select/poll 来说,当检查大量的文件描述符时,epoll 的性能比 select/poll 高很多;epoll 既支持水平触发也支持边缘触发,而 select/poll 只支持水平触发。
epoll 基本流程如下:
- 通过 epoll_create 系统调用创建 epoll 实例,epoll 实例本身也是个文件,所以会返回一个代表 epoll 实例的文件描述符 epfd。
- 通过 epoll_ctl 系统调用修改(增加、删除、更改)文件描述符兴趣列表,并记录进内核。
- 通过 epoll_wait 系统调用阻塞等待文件描述符就绪事件。
- epoll_create
int epoll_create(int size);
参数 size 指定了我们想要通过 epoll 实例来检查的文件描述符个数。该参数只是一个估计值,无需精确指定。
该函数返回一个代表 epoll 实例的文件描述符 epfd,epfd 在 epoll_ctl 和 epoll_wait 中都需要用到,整个 epoll 机制都需要在 epoll 实例上进行操作。
- epoll_ctl
typedef union epoll_data {
void *ptr; // 用户自定义数据
int fd; // 待监控的文件描述符
uint32_t u32; // 一个 32 位整数
uint64_t u64; // 一个 64 位整数
} epoll_data_t;
struct epoll_event {
uint32_t events; // 在该文件描述符上感兴趣的事件集合,用掩码表示
epoll_data_t data; // 用户数据,当描述符 fd 稍后成为就绪态时,可用来指定传回给调用进程的信息
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *ev);
参数说明:
-
epfd:表示 epoll 实例的文件描述符。
-
fd:指定感兴趣的文件描述符。
-
op:指定需要执行的操作,取值如下:
- EPOLL_CTL_ADD:将描述符 fd 添加到 epoll 实例 epfd 中的兴趣列表中去。
- EPOLL_CTL_DEL:将文件描述符 fd 从 epfd 的兴趣列表中移除。
- EPOLL_CTL_MOD:修改描述符 fd 上设定的事件,需要用到由 ev 所指向的结构体中的信息。
-
ev:指向结构体 epoll_event 的指针。
-
epoll_wait
int epoll_wait(
int epfd,
struct epoll_event *evlist,
int maxevents,
int timeout);
参数说明:
-
epfd:表示 epoll 实例的文件描述符。
-
evlist:是一个返回参数,指向的是返回的有关就绪态文件描述符的信息。其结构体元素的 events 属性表示的是在相应文件描述符上已经发生的的事件。
-
maxevents:evlist 中的元素个数。
-
timeout:用来确定 epoll_wait()的阻塞行为,有如下几种:
- 如果 timeout 等于−1,调用将一直阻塞,直到兴趣列表中的文件描述符上有事件产生,或者直到捕获到一个信号为止。
- 如果 timeout 等于 0,执行一次非阻塞式的检查,看兴趣列表中的文件描述符上产生了哪个事件。
- 如果 timeout 大于 0,调用将阻塞至多 timeout 毫秒,直到文件描述符上有事件发生,或者直到捕获到一个信号为止。
调用成功后,epoll_wait()返回数组 evlist 中的元素个数。如果在 timeout 超时间隔内没有任何文件描述符处于就绪态的话,返回 0。出错时返回−1。
2.4 epoll 与 select/poll 性能对比
epoll 比 select/poll 在监控大量文件描述符时性能表现优越的原因如下:
- 每次调用 select 和 poll 时,内核必须检查所有在调用中指定的文件描述符。与之相反,当通过epoll_ctl 指定了需要监视的文件描述符时,内核会在与打开的文件描述上下文相关联的列表中记录该描述符。之后每当执行 I/O 操作使得文件描述符成为就绪态时,内核就在 epoll 描述符的就绪列表中添加一个元素。(单个打开的文件描述上下文中的一次 I/O 事件可能导致与之相关的多个文件描述符成为就绪态。)之后的 epoll_wait 调用从就绪列表中简单地取出这些元素。
- 每次调用 select 或 poll 时,我们传递一个标记了所有待监视的文件描述符的数据结构给内核,调用返回时,内核将所有标记为就绪态的文件描述符的数据结构再传回给我们。与之相反,在 epoll 中我们使用 epoll_ctl 在内核空间中建立一个数据结构,该数据结构会将待监视的文件描述符都记录下来。一旦这个 数据结构建立完成,稍后每次调用 epoll_wait 时就不需要再传递任何与文件描述符有关的信息给内核了,而调用返回的信息中只包含那些已经处于就绪态的描述符(注意,无论是 select,poll 还是 epoll,从内核返回文件描述符集合到用户空间都需要进行内存拷贝,区别是 select/poll 需要双向拷贝,而 epoll 只需要从内核往用户空间拷贝,且只拷贝就绪文件描述符集合)。