Linux 五种高级 IO 模型与 select 多路转接

3289 字
16 分钟
Linux 五种高级 IO 模型与 select 多路转接

一、 IO 的本质与五种 IO 模型#

1.1 IO 的核心概念与本质#

  • 概念解释:IO (Input/Output) 在计算机科学中指数据在内存与外部设备(如磁盘、网络)之间的流动过程。在操作系统层面,IO = 等待 + 拷贝。无论是传统的 read / write 还是网络的 recv / send / recvfrom / sendto,任何 IO 过程都包含这两个步骤:第一步是等待数据准备就绪,第二步是将数据从内核空间拷贝到用户空间(或反之)。
  • 笔记:
  • 在实际应用场景中,等待消耗的时间往往远远高于拷贝的时间。
  • 核心优化结论:让 IO 更高效,最核心的办法就是让等待的时间尽量少。
  • 对于文件描述符 (fd),一般默认情况下:读事件默认是不就绪的(接受缓冲区初始为空),而写事件默认是就绪的(发送缓冲区初始是空的,有空间可写)。
  • 报错处理:一旦出错返回 -1 并设置 errno,最关键的是要查明“为什么出错”。

1.2 阻塞 IO (Blocking IO)#

  • 概念解释:最常见的 IO 模型。在内核将数据准备好之前,系统调用会一直挂起等待。所有的套接字 (Socket) 默认都是阻塞方式。
  • 笔记:
  • 执行流程:应用进程发起 recvfrom 系统调用 -> 内核无数据报准备好 -> 进程阻塞于 recvfrom 调用 -> 数据报准备好 -> 将数据从内核拷贝到用户空间 -> 返回成功指示 -> 处理数据报。
  • (类比:专心致志地守在水边钓鱼,不见鱼漂沉下去绝不离开。)

1.3 非阻塞 IO (Non-blocking IO) 与 轮询#

  • 概念解释:非阻塞 IO 是指如果内核还未将数据准备好,系统调用不会挂起,而是直接返回一个错误码(通常是 EWOULDBLOCK 或 EAGAIN)。轮询 (Polling) 则是指程序员需要用循环的方式反复尝试读取或写入数据,直到成功为止。
  • 笔记:
  • 执行流程:进程反复调用 recvfrom -> 若无数据则立即返回 EWOULDBLOCK -> 循环调用 -> 直到数据准备好 -> 拷贝数据报 -> 返回成功。
  • 缺点:轮询对 CPU 资源是较大的浪费,一般只有特定场景下(结合特定的等待机制)才使用。

1.4 信号驱动 IO (Signal-driven IO)#

  • 概念解释:内核在数据准备好的时候,主动通过发送 SIGIO 信号通知应用程序,应用程序收到信号后再去发起系统调用进行数据拷贝。
  • 笔记:
  • 执行流程:建立 SIGIO 的信号处理程序(通过 sigaction) -> 进程继续执行主逻辑(不阻塞) -> 数据准备好 -> 内核递交 SIGIO 信号 -> 应用程序调用 recvfrom -> 拷贝数据期间进程阻塞 -> 完成后处理数据。

1.5 IO 多路转接 / 多路复用 (IO Multiplexing)#

  • 概念解释:通过一种机制(如 select、poll、epoll),让单个进程能够同时等待多个文件描述符 (fd) 的就绪状态。虽然它在数据拷贝时也是阻塞的,但它的核心优势在于一次等待多个 fd。
  • 笔记:
  • 执行流程:应用进程受阻于 select 调用 -> 等待多个套接口中的任意一个变为可读 -> 返回可读条件 -> 应用程序再调用 recvfrom 进行无阻塞的数据拷贝。

1.6 异步 IO (Asynchronous IO)#

  • 概念解释:由内核负责数据的拷贝工作。内核在数据拷贝完全完成时,才通知应用程序直接去使用数据。
  • 笔记:
  • 执行流程:调用 aio_read -> 立即返回,进程继续执行 -> 内核等待数据并主动将数据拷贝到用户空间 -> 拷贝完成后递交指定信号通知进程。
  • 与信号驱动的对比:信号驱动是告诉应用程序“何时可以开始拷贝数据”(拷贝过程仍由应用程序自己阻塞完成);异步 IO 是告诉应用程序“何时数据已经拷贝完成”。
  • (类比总结《妖怪蒸唐僧》:不同小妖看守蒸笼的方式对应不同的 IO 等待模型。)

二、 高级 IO 核心理论解析#

2.1 同步通信 vs 异步通信#

  • 概念解释:关注的是消息通信机制(注意区分多线程中的同步与互斥概念)。
  • 笔记:
  • 同步 (Synchronous):发出调用时,在没有得到结果之前,该调用就不返回。一旦返回,就得到返回值。由调用者主动等待调用的结果。
  • 异步 (Asynchronous):调用发出后直接返回,没有返回结果。被调用者在完成任务后,通过状态、通知或回调函数来通知调用者。

2.2 阻塞 vs 非阻塞#

  • 概念解释:关注的是程序在等待调用结果(消息、返回值)时的状态。
  • 笔记:
  • 阻塞 (Blocking):调用结果返回之前,当前线程会被挂起(休眠)。
  • 非阻塞 (Non-blocking):在不能立刻得到结果之前,该调用不会阻塞当前线程,直接返回错误标志。

【发散思考与解答】 问:同步/异步 与 阻塞/非阻塞 常常被混淆,它们到底有何组合关系? 答:同步/异步是“拿结果的方式”(我主动等结果还是你送结果过来),阻塞/非阻塞是“等结果时的状态”(我是睡觉等还是边干活边等)。例如:

  • 同步阻塞:最常见的传统 recv(主动去拿数据,没拿到就死等挂起)。

  • 同步非阻塞:设置了非阻塞标志的 recv 轮询(主动去拿数据,没拿到直接返回错误,我不挂起,一会再来问)。

  • 异步非阻塞:aio_read(告诉内核把数据准备好并送到缓冲区,我不挂起继续干活,内核弄好了通知我)。


三、 高级 IO 实践:非阻塞 IO (fcntl)#

3.1 涉及的核心函数#

  • 函数名:fcntl
  • 函数原型:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */);

功能与参数说明: 控制文件描述符的属性。cmd 决定了函数的具体行为,常用的有 5 种功能:

  1. 复制现有描述符 (cmd=F_DUPFD)。
  2. 获得/设置文件描述符标记 (cmd=F_GETFD 或 F_SETFD)。
  3. 获得/设置文件状态标记 (cmd=F_GETFL 或 F_SETFL) —— 这是实现非阻塞的核心。
  4. 获得/设置异步 I/O 所有权 (cmd=F_GETOWN 或 F_SETOWN)。
  5. 获得/设置记录锁 (cmd=F_GETLK, F_SETLK 或 F_SETLKW)。

3.2 实现 SetNoBlock 非阻塞设置#

  • 笔记:
  • 一个文件描述符默认是阻塞 IO。
  • 实现逻辑:先使用 F_GETFL 取出当前属性(位图),再附加 O_NONBLOCK 参数并通过 F_SETFL 设置回去。
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL); // 取出当前文件描述符属性
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 追加非阻塞标记并设置回内核
}
  • 应用层非阻塞轮询读取 fd=0 (标准输入) 时,如果没有输入,read 返回 <0,必须结合 sleep 进行轮询,避免 CPU 100% 占用空转。

四、 IO 多路转接底层解析:select#

4.1 select 核心定位#

  • 概念解释:select 是操作系统提供的一种复用输入/输出模型。它允许程序停在这里监视多个文件描述符的状态变化。
  • 笔记:
  • 核心职责:select 负责两件事情——“等” 和 “通知”。
  • 一次可以等待多个 fd。只要有任意一个或多个 fd 的事件就绪,select 就会通知上层调用方哪些 fd 已经可以进行 IO 操作了。
  • 什么是可读/可写就绪?
  • 可读:底层有数据,读事件就绪。
  • 可写:底层有空间,写事件就绪。

4.2 涉及的核心函数与数据结构#

  • 函数名:select 与 fd_set 操作宏
  • 函数原型:
/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
// 位图操作集接口
void FD_CLR(int fd, fd_set *set); // 清除描述词组 set 中相关 fd 的位
int FD_ISSET(int fd, fd_set *set);// 测试描述词组 set 中相关 fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 设置描述词组 set 中相关 fd 的位
void FD_ZERO(fd_set *set); // 清除描述词组 set 的全部位

功能与参数说明:

  • nfds:需要监视的最大文件描述符的值 + 1。
  • readfds / writefds / exceptfds:输入输出型参数。分别对应需要检测的可读、可写及异常文件描述符集合。
  • timeout:用来设置 select() 的等待时间策略。

4.3 关键参数详解#

4.3.1 timeout (struct timeval)#

  • 结构体原型:
struct timeval {
time_t tv_sec; /* seconds (秒) */
suseconds_t tv_usec; /* microseconds (微秒) */
};
  • 取值策略:
  1. 阻塞 (NULL):没有 timeout,一直死等,直到有文件描述符发生事件。
  2. 非阻塞 (0):设置 {0, 0},仅检测描述符集合状态,不管有没有都立即返回,不等待外部事件。
  3. 超时机制 (特定时间值):在 timeout 时间内阻塞等待;若超时仍无事件发生,则非阻塞返回(时间器会被清空)。

4.3.2 返回值判定#

  • >0:表示成功,返回值为状态已改变(就绪)的文件描述符的个数。
  • ==0:表示超时。在给定的 timeout 时间内没有任何 fd 就绪。
  • <0:表示 select 报错。返回 -1 且设置 errno(此时相关的集合和时间参数变为不可预测状态)。常见错误包括 EBADF (无效/已关闭 fd), EINTR (被信号中断), EINVAL (负值 nfds), ENOMEM (核心内存不足)。

4.3.3 核心位图:fd_set#

  • 底层原理:内核提供给用户的固定大小数据结构,本质是一个整数数组充当位图 (Bitmap)。

  • 比特位的位置:表示 fd 的编号。

  • 比特位的内容:表示是否关心(输入时)或是否就绪(输出时)。

  • 输入输出特性双重身份:

  • 输入时:用户告诉内核,“你要帮我关心这些位置为 1 的 fd 的相关事件”(例如 0000 1111)。

  • 返回时:内核告诉用户,“你关心的那些 fd 里,这些位置为 1 的 fd 的事件已经就绪了”(例如 0000 0010)。

4.4 select 运行过程微观剖析(核心机制)#

假设 fd_set 长度为 1 字节(最大对应 8 个 fd):

  1. 执行 fd_set set; FD_ZERO(&set); -> 位图清空:0000,0000。
  2. 若 fd=5,执行 FD_SET(fd, &set); -> 位图变为:0001,0000(第 5 位置为 1)。
  3. 再加入 fd=2, fd=1 -> 位图变为:0001,0011。
  4. 执行 select(6, &set, 0, 0, 0); -> 进程阻塞等待。
  5. 若 fd=1, fd=2 发生可读事件,select 返回,此时 set 被内核修改为:0000,0011。
  • 严重注意:没有事件发生的 fd=5 对应的位被内核清空了!

【发散思考与解答】 问:在循环调用 select 时,为什么每次都需要重新使用 FD_ZERO 和 FD_SET? 答:正如微观剖析第 5 步所示,readfds 等参数是输入输出型。内核在返回就绪状态时,会覆写原有的位图,把未就绪的位清 0。如果你下次循环直接复用这个被破坏的 fd_set,你就丢失了原本要监控的 fd=5。因此,必须在用户态维护一个独立的数组(如 array)来长久保存所有需要监控的 fd,每次调用 select 前,先将 fd_set 清零,再遍历 array 重新把所有的 fd 添加进 fd_set。

4.5 Socket 具体就绪条件#

  • 读就绪:
  1. 接收缓冲区字节数 ≥\ge 低水位标记 SO_RCVLOWAT(无阻塞读,返回 >0)。
  2. TCP 对端关闭连接(读返回 0)。
  3. 监听的 socket (listensockfd) 上有新的连接请求(非常重要:新连接到来 == 读事件就绪!accept 本质就是等待并读取这个新连接)。
  4. socket 上有未处理的错误。
  • 写就绪:
  1. 发送缓冲区空闲可用字节数 ≥\ge 低水位标记 SO_SNDLOWAT。
  2. 写操作被关闭 (close / shutdown),此时写会触发 SIGPIPE 信号。
  3. 使用非阻塞 connect 连接成功或失败后。
  4. socket 上有未读取的错误。
  • 异常就绪:
  • socket 上收到带外数据(与 TCP 头部的紧急指针字段相关)。

4.6 select 的特点与致命缺点#

  • 特点:

  • 可监控的 fd 个数取决于 sizeof(fd_set)。某服务器下测得为 512 字节,每 bit 对应一个 fd,则最多支持 512×8=4096512 \times 8 = 4096 个 fd。(注:测试代码输出 1024 视具体 Linux 内核配置而定。fd_set 大小固定,有明确上限)。

  • 四大缺点(痛点):

  1. 手动重置繁琐:每次调用 select 返回后,以前加入但无事件的 fd 会被清空,导致必须手动使用数组维护 fd 集合并每次重新遍历、重新设置位图。
  2. 状态拷贝开销:每次调用 select,都需要把整个 fd 集合的位图从用户态拷贝到内核态,当 fd 很多时,这个开销极大。
  3. 内核遍历开销:每次调用 select,内核也必须在底层遍历传递进来的所有 fd 以检查状态,时间复杂度为 O(N)O(N)。
  4. 并发上限低:select 支持的文件描述符数量太小(通常宏定义 FD_SETSIZE 默认为 1024),难以应对高并发场景。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
HandlerOne
おかえり
公告
本站已整理 138 篇技术笔记,并按 8 条主线排成学习路线(141 站);站内搜索与桌宠对话均已上线。
分类
标签
最新动态
站点统计
文章
138
分类
12
标签
42
总字数
597,044
站点信息
构建平台
腾讯云 · Nginx
博客版本
v6.16.8
文章目录