IO 多路复用
1️⃣ 同步阻塞 IO
造成的问题
- 两处阻塞: 1. 进程刚调用
recv; 2. 等待内核将 数据 拷贝到 用户缓冲区 - 两处进程切换: 1. 连接持续没有数据到达; 2. 网卡将数据从 内核 复制到
socket的 等待队列,进程会被唤醒 - 单对单连接:一个进程只能等待一条连接

2️⃣ 同步非阻塞 IO
优点
将数据从 网卡 拷贝到 socket 的内核空间 这一阶段变成 非阻塞模式。
- 用户进程调用
recvfrom()会重复发出请求,检查数据是否到达socket的 等待队列。如果没有到,会立即返回。
缺点
没有解决:当数据到达 socket 的 等待队列,用户进程需要将数据从 内核空间 拷贝到 用户空间。

3️⃣ 同步阻塞 IO 的数据接收流程
- 服务端使用系统调用
socket会陷入到内核态,内核就会创建socket内核对象,其主要包含两个重要结构体:- (进程)等待队列: 存放了进程的 进程描述符 和 回调函数。
- (数据)接收队列: 存放了网卡接收到的需要该
socket的数据
- 服务端当通过
socket调用recv函数时,会执行recvfrom系统调用。- 如果
socket的 接收队列 中没有数据,进程 A 会让出CPU执行权,进入阻塞状态。 - 同时创建一个 等待队列项 (进程描述符 + 被唤醒时的回调函数) 放入到进程 A 的等待队列中
- 如果
- 如果服务端的网卡接收到了 客户端发送的数据;
- 网卡 先通过
DMA将网络传输过来的数据复制到 内存环形缓冲区RingBuffer; - 网卡 再向
CPU发送 硬中断; CPU接收到 硬中断 ,就调用 网络驱动 注册的 中断处理函数 向 内核中断线程 发送 软中断,并释放CPU执行其他任务;- 内核中断进程 接收到 软中断 后,将 网卡 复制到 内存 的数据,根据数据报文的
IP和端口号,将其拷贝到对应socket的 接收队列; - 内核中断进程 根据
socket的 接收队列 的数据,通过 等待队列 中的回调函数,唤醒要处理该数据的进程 A,此时进程 A 进入到CPU的运行队列,等待CPU的调度。 - 进程
A获取CPU后,会回到之前调用recvfrom()函数时的 阻塞位置 继续执行,这时发现socket内核空间的 等待队列 有数据,会在内核态 将内核空间的socket等待队列的数据拷贝到用户空间,然后才会回到用户态执行进程的用户程序,从而真的解除阻塞;
- 网卡 先通过

4️⃣ IO 多路复用
主要用于解决 两次进程切换,单进程对单连接。
定义
IO 多路复用 主要通过 一个进程 处理 多个 socket 连接,使得不需要在每个 socket 连接的数据到达时就 切换进程,使得进程可以一直运行并只处理有数据到达的连接。
如果要监听的所有连接都没有数据到达,进程还是会进入阻塞状态,直到某个连接有数据到达时被回调函数唤醒。
select 的实现原理
select 的定义
1 | int select( |
select 会遍历每个集合的前 nfds 个描述符,分别找到可以读取、可以写入、发生错误的描述符,统称为 “就绪” 的描述符。然后用找到的 子集 替换这三个引用参数中的对应集合,返回所有就绪描述符的数量。
timeout 参数表示调用 select 时的阻塞时长。
- 如果所有
fd文件描述符都未就绪,就阻塞调用进程,直到某个描述符就绪,或者阻塞超过设置的timeout后,返回。 - 如果
timeout参数设为NULL,会无限阻塞直到某个描述符就绪; - 如果
timeout参数设为 0,会立即返回,不阻塞。
fd_set
fd_set 结构体仅包含一个整形数组,该数组的每个元素的每一位标记一个文件描述符。
当 select 返回 fd_set = 00010011 时,表示文件描述符 1、2、5 已经就绪。

缺点
- 性能开销大
- 调用
select时会陷入内核,这时需要将参数中的fd_set从用户空间拷贝到内核空间,select执行完后,还需要将fd_set从内核空间拷贝回用户空间;(epoll优化为不拷贝) - 进程被唤醒后,不知道哪些连接已就绪即收到了数据,需要遍历传递进来的所有
fd_set的每一位,不管它们是否就绪;(epoll优化为异步事件通知) select只返回就绪文件的个数,具体哪个文件可读还需要遍历;(epoll优化为只返回就绪的文件描述符,无需做无效的遍历)
- 调用
- 同时能够监听的文件描述符数量太少。受限于
sizeof(fd_set)的大小,在编译内核时就确定了且无法更改。一般是32位操作系统是1024,64位是2048。
poll 的实现原理
和 select 类似,只是描述 fd 集合的方式不同,poll 使用 pollfd 结构而非 select 的 fd_set 结构。
poll 的定义
1 | int poll( struct pollfd* fds,nfds_t nfds,int timeout ); |
1 | struct pollfd { |
epoll 的实现原理
epoll 是对 select 和 poll 的改进。解决了以下这两个缺点,是性能最高、并发量最大的多路复用实现方式。
- 性能开销大
- 文件描述符数量少
特点
- 使用 红黑树 存储文件描述符集合,每个文件描述符只需在添加时传入一次,无需用户每次都重新传入;
- 通过 异步
IO事件找到就绪的文件描述符,而不是通过轮询的方式; - 使用 就绪队列 存储 已经就绪 的文件描述符,且会按需返回就绪的文件描述符,无须再次遍历;
主要函数
1 | // 创建一个 eventpoll 内核对象 |
epoll_create
epoll_create 函数会创建一个 struct eventpoll 的内核对象,主要包含以下三个字段:
- 等待队列
wq: 如果 当前进程没有数据需要处理,会把 当前进程描述符 和 回调函数default_wake_functon构造一个等待队列项,放入当前wq对待队列; - 红黑树
rbr: 管理用户进程下添加进来的所有socket连接fd; - 就绪链表
rdllist: 当socket连接数据就绪的时候,内核会把就绪的socket连接放到rdllist链表里。这样应用进程只需要判断链表就能找出就绪进程,而不用去遍历整棵树。1
2
3
4
5
6
7
8struct eventpoll {
wait_queue_head_t wq; // 等待队列链表,存放阻塞的进程
struct list_head rdllist; // 数据就绪的文件描述符都会放到这里
struct rb_root rbr; // 红黑树,管理用户进程下添加进来的所有 socket 连接
......
}
epoll_ctl
epoll_ctl 函数主要负责把 服务端 和 客户端 建立的 socket 连接注册到 eventpoll 对象里,内核会做三件事:
分配一个红黑树节点对象
epitem1
2
3
4
5
6
7
8
9
10
11
12
13
14struct epitem {
//红黑树节点
struct rb_node rbn;
//socket文件描述符信息
struct epoll_filefd ffd;
//所归属的 eventpoll 对象
struct eventpoll *ep;
//等待队列
struct list_head pwqlist;
}添加等待事件到
socket的等待队列中,其回调函数是ep_poll_callback;这里添加的
socket的进程等待队列结构中,只有回调函数,没有设置进程描述符,因为在epoll中,进程是放在eventpoll的等待队列中,等待被epoll_wait函数唤醒,而不是放在socket的进程等待队列中;将
epitem插入到epoll对象的红黑树里

epoll_wait
epoll_wait 的逻辑比较简单:
- 它先检查
eventpoll对象的就绪链表rdllist中是否有就绪事件;- 如果没有,就为当前进程构造一个等待队列项,并将其加入
eventpoll的等待队列中,然后阻塞当前进程。 - 当
eventpoll监控的某个连接上 有数据到达 时,内核大致会按照下面的流程唤醒阻塞在epoll_wait上的进程:- 数据到达
socket的接收缓冲区后,会触发该socket等待队列上的回调函数ep_poll_callback。 ep_poll_callback会找到对应的epitem,并将其加入eventpoll对象的就绪队列rdllist中。- 随后,
ep_poll_callback会检查eventpoll的等待队列上是否有因调用epoll_wait而阻塞的进程;如果有,就通过default_wake_func将其唤醒。 - 进程被唤醒后,会从
epoll_wait挂起的位置继续执行。此时,epoll_wait会将rdllist中的就绪事件返回给用户进程。 - 用户进程拿到就绪事件后,再调用
recv等接口,把已经到达内核socket接收缓冲区中的数据拷贝到用户空间,供业务代码处理。
- 数据到达
- 如果没有,就为当前进程构造一个等待队列项,并将其加入

