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 种功能:
- 复制现有描述符 (
cmd=F_DUPFD)。 - 获得/设置文件描述符标记 (
cmd=F_GETFD或F_SETFD)。 - 获得/设置文件状态标记 (
cmd=F_GETFL或F_SETFL) —— 这是实现非阻塞的核心。 - 获得/设置异步 I/O 所有权 (
cmd=F_GETOWN或F_SETOWN)。 - 获得/设置记录锁 (
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 (微秒) */};- 取值策略:
- 阻塞 (
NULL):没有 timeout,一直死等,直到有文件描述符发生事件。 - 非阻塞 (
0):设置{0, 0},仅检测描述符集合状态,不管有没有都立即返回,不等待外部事件。 - 超时机制 (特定时间值):在
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):
- 执行
fd_set set; FD_ZERO(&set);-> 位图清空:0000,0000。 - 若
fd=5,执行FD_SET(fd, &set);-> 位图变为:0001,0000(第 5 位置为 1)。 - 再加入
fd=2, fd=1-> 位图变为:0001,0011。 - 执行
select(6, &set, 0, 0, 0);-> 进程阻塞等待。 - 若
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 具体就绪条件
- 读就绪:
- 接收缓冲区字节数 低水位标记
SO_RCVLOWAT(无阻塞读,返回>0)。 - TCP 对端关闭连接(读返回
0)。 - 监听的 socket (listensockfd) 上有新的连接请求(非常重要:新连接到来 == 读事件就绪!
accept本质就是等待并读取这个新连接)。 - socket 上有未处理的错误。
- 写就绪:
- 发送缓冲区空闲可用字节数 低水位标记
SO_SNDLOWAT。 - 写操作被关闭 (
close/shutdown),此时写会触发SIGPIPE信号。 - 使用非阻塞
connect连接成功或失败后。 - socket 上有未读取的错误。
- 异常就绪:
- socket 上收到带外数据(与 TCP 头部的紧急指针字段相关)。
4.6 select 的特点与致命缺点
-
特点:
-
可监控的 fd 个数取决于
sizeof(fd_set)。某服务器下测得为 512 字节,每 bit 对应一个 fd,则最多支持 个 fd。(注:测试代码输出1024视具体 Linux 内核配置而定。fd_set大小固定,有明确上限)。 -
四大缺点(痛点):
- 手动重置繁琐:每次调用
select返回后,以前加入但无事件的 fd 会被清空,导致必须手动使用数组维护 fd 集合并每次重新遍历、重新设置位图。 - 状态拷贝开销:每次调用
select,都需要把整个 fd 集合的位图从用户态拷贝到内核态,当 fd 很多时,这个开销极大。 - 内核遍历开销:每次调用
select,内核也必须在底层遍历传递进来的所有 fd 以检查状态,时间复杂度为 。 - 并发上限低:
select支持的文件描述符数量太小(通常宏定义FD_SETSIZE默认为 1024),难以应对高并发场景。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!

