I/O 多路复用

文件描述符

在 Linux 中所有的 I/O 设备都被抽象成文件,每个文件对应一个文件描述符,是一个int类型整数,从 0 开始

当操作系统创建一个进程时,会初始化 3 个打开的文件描述符,分别是:标准输入 (0)、标准输出 (1)、标准错误 (2)。后续如果进程自己打开文件,就会接着往后分配文件描述符

举个例子,如果打开一个文件,会分配文件描述符 3;再打开一个文件,会分配文件描述符 4,以此类推;如果关闭文件描述符 3,然后再打开一个文件,会分配文件描述符 3,会复用前面关闭的文件描述符

socket 网络通信

先从最简单的 socket 通信说起。客户端/服务端架构的通信是基于 socket 实现,socket 封装了网络通信的细节,对程序员提供简单易用的接口,两者通信的过程:

13

稍微解释一下上图中的步骤:

下面给出一个小 demo:

事件驱动

I/O 多路复用是基于事件驱动的模型,可以将 socket 网络通信过程抽象成两类事件:

由于通信过程中,客户端始终只和一个服务端建立通信,不需要 I/O 多路复用,但服务端会接收来自多个客户端的请求,需要 I/O 多路复用。所以客户端是事件的产生者,服务端接收事件并处理它

在传统的 I/O 模型中,都是基于连接驱动,在单线程中每次只能处理完一个客户端连接后才能和其它客户端建立新的连接,处理流程如下:

14

客户端保持连接过程中可能会产生多个事件,基于事件驱动的模型相当于使应用程序处理任务的粒度变细。假设两个连接分别会产生三个事件:A1、A2、A3、B1、B2、B3

在传统的 I/O 模型中,按照连接时间的先后顺序,假设 A 先与服务端建立连接,那么必须处理完 A1、A2、A3 三个事件后才可以断开连接,然后和 B 建立连接处理 B1、B2、B3

一旦 A1、A2、A3 三个任务中有任务处理时间过长,直接会增加后面准备连接服务端的客户端的阻塞时间。在基于事件驱动的模型中,只要 A1 处理完就可以处理 A2 或者 B1,不需要处理完 A 的所有事件

至于事件如何调度,也就是如何判断先处理哪个事件,完全依赖于哪个事件先发生,先发生的事件先处理,基于事件驱动的模型处理流程如下:

15

select

说完了事件驱动,也就间接的引出了 I/O 多路复用,本部分主要侧重于从细节和实现角度介绍 select。关于 select 实现 I/O 多路复用的概念可见 I/O 多路复用

在多线程/多进程的 Socket 服务端模式下:(更多可见 Socket 服务端模式)

在 I/O 多路复用下,客户端和服务端全都阻塞在 select 函数,当有事件发生,select 函数返回

即然 select 可以在有事件发生时立刻返回,那么肯定需要保存所有建立连接的 socket,然后根据是否有事件发生来决定 select 是否返回,所以有一个fd_set集合专门保存 socket 描述符

另外一个问题,哪些 socket 需要放到fd_set集合中呢?

下面是 select 的处理流程:

16

下面给出一个小 demo:

fd_set

fd_set 是专门用来存放 socket 的集合,是一个位图,每一位表示一个 socket,1 表示集合中有对应的 socket 描述符,0 表示集合中没有对应的 socket 描述符

fd_set 一共有 1024 位,也就是说 select 最多处理 1024 个 socket

问题:为什么需要将 fd_set 拷贝一份,然后把拷贝后的 fd_set 传入 select 中?

首先 fd_set 中保存了所有的连接到服务端的套接字描述符,而 select 要从 fd_set 中找出有事件发生的描述符,然后交由服务端处理

select 会直接在传入的 fd_set 集合中修改,将没有发生事件的描述符置 0,保留发生事件的描述符为 1,所以如果没有传入副本,将丢失记录的套接字描述符

为了画图方便,假设 fd_set 只有 32 位,但实际有 1024 位。假设目前 fd_set 有套接字描述符 0、1、2、3、5、10,且套接字描述符 0、3、10 有事件发生,那么如下图所示:

17

缺点

对于第 1 点和第 3 点是此长彼消的关系,当支持描述符多了意味着遍历更耗时,当遍历耗时低了意味着支持描述符少了

问题:为什么 I/O 多路复用要搭配 NIO?

在 select 实现的 I/O 多路复用中,所有连接服务端的 socket 都会加入到 fd_set 中,并阻塞在 select 系统调用处,当有 socket 发生事件时,也就是可读或可写时,会从 select 函数返回

假设有一个可读事件发生,表示应用程序可读数据,那么这个读操作是设置成阻塞的还是非阻塞的呢?有人觉得设置成阻塞或非阻塞效果都一样,因为 select 返回表示数据可读

但是「select 返回」和「正式开始读数据」之间存在时间窗口,可能存在数据可读后由于校验错误等原因会将数据丢弃,此时就无法读到数据,如果是阻塞 I/O,这种情况就会阻塞整个应用程序

在 Linux 中通过man select命令可以看到 select 的介绍,在 BUGS 部分解释了为什么可读但不一定读的到数据!!

关于该问题更详细的解释可见 为什么 IO 多路复用要搭配非阻塞 IO?

poll

poll 和 select 几乎一模一样,唯一不同的在于存储套接字描述符集合的数据结构

下面给出一个小 demo:

缺点

epoll

epoll 是在 2.6 内核中提出,是 select/poll 的增强版

相比于 select/poll 更加的灵活,没有描述符数量的限制,使用 1 个描述符管理多个描述符,并将这些描述符存放到内核中,这样就可以只在用户空间和内核空间 copy 一次,select/poll 需要两次

epoll 提供了三个函数调用接口:

下面给出一个小 demo:

对比 select/poll

在 select/poll 中,只有在进程调用一定的方法后,内核才对所有监视的描述符扫描,寻找有事件发生的描述符;而 epoll 是先通过epoll_ctl()注册一个描述符,一旦基于某个描述符就绪时,内核就会采用类似 callback 的回调机制,迅速激活这个描述符,唤醒epoll_wait()处阻塞的进程

在 select/poll 中,会有两次用户空间和内核空间的 copy 过程 (一次为用户空间拷贝到内核空间,一次为内核空间拷贝到用户空间),而 epoll 中由于描述符直接存放在内核中,所以只会有一次用户空间和内核空间的 copy 过程 (内核空间拷贝到用户空间)

在 select/poll 中,需要遍历整个集合寻找发生事件的描述符,而 epoll 中epoll_wait()返回的数组中只有发生事件的描述符

参考文章