一、IO的两个阶段
任何一次IO操作,本质上都经历两个阶段:等待数据就绪 和将数据从内核拷贝到用户空间。
在实际场景里,等待往往占据了IO的绝大部分时间。因此,提升IO效率的思路,就是尽可能减少进程在等待阶段的空耗。围绕"如何等待"这一问题,操作系统演化出了五种经典的IO模型。
二、五种IO模型
2.1 阻塞IO
阻塞IO是最朴素也最常见的模型。进程调用 recvfrom 后,如果内核中数据尚未准备好,该调用就不会返回,进程被挂起,直到数据就绪并完成拷贝,才得到成功指示。

整个过程中,进程从发起调用到拿到数据全程处于阻塞状态,无法执行其他任务。
2.2 非阻塞IO
与阻塞IO不同,非阻塞IO下,如果内核数据未就绪,recvfrom 会立即返回,并带回 EWOULDBLOCK 错误码,而不是让进程挂起。

程序需要通过循环反复调用 recvfrom 来探测数据是否就绪,这一机制称为轮询。
2.3 信号驱动IO
信号驱动IO的思路是:进程提前通过 sigaction 注册一个 SIGIO 信号处理函数,然后继续执行自身任务。
当内核数据准备完毕时,主动向进程递送 SIGIO 信号,进程在信号处理函数中调用 recvfrom 完成数据拷贝。

相比轮询,信号驱动IO避免了反复探测,进程在等待期间可以正常运行。
注意:数据从内核拷贝到用户空间的阶段,进程仍然是阻塞的。
2.4 IO多路转接
IO多路转接(IO多路复用)的核心在于:一个进程可以同时监视多个文件描述符,通过 select 等系统调用等待其中任意一个变为就绪状态。
一旦有描述符可读,进程再针对该描述符执行 recvfrom。

从单条流程看,它与阻塞IO颇为相似,但本质区别在于---多路转接能以一个线程的代价同时管理大量连接。
2.5 异步IO
异步IO是最彻底的非阻塞方案。进程调用 aio_read 后立即返回,内核自行完成等待数据和拷贝数据两个阶段,全部结束后再通过信号或回调通知进程。

注意差异:信号驱动IO通知的是"数据已就绪,可以开始拷贝了";而异步IO通知的是"数据已经拷贝到用户空间,可以直接使用了"。
三、模型对比
| 模型 | 等待阶段 | 拷贝阶段 | 核心机制 | CPU利用率 | 适用场景 |
|---|---|---|---|---|---|
| 阻塞IO | 阻塞 | 阻塞 | 调用即等待 | 低 | 简单、连接数少 |
| 非阻塞IO | 不阻塞 | 阻塞 | 循环反复尝试 | 低 | 特定专用场景 |
| 信号驱动IO | 不阻塞 | 阻塞 | SIGIO信号通知 | 中 | 连接数中等 |
| IO多路转接 | 阻塞 | 阻塞 | select/poll/epoll | 高 | 高并发网络服务 |
| 异步IO | 不阻塞 | 不阻塞 | 内核完成后通知 | 最高 | 高性能异步框架 |
前四种模型在数据拷贝阶段都需要进程阻塞参与,只有异步IO实现了真正意义上的全流程非阻塞。
四、高级IO的概念
4.1 同步通信 vs 异步通信
同步与异步关注的是消息通信机制。
-
同步:调用发出后,在拿到结果之前不返回;一旦返回,必然带回结果
-
异步:调用发出后立即返回,没有结果;被调用者后续通过状态、通知或回调函数来告知调用者
注意:这里的"同步"与多线程中"同步与互斥"的同步是完全不同的概念。后者描述的是线程间为协调执行次序而产生的直接制约关系,尤其是在访问临界资源时。
4.2 阻塞 vs 非阻塞
阻塞与非阻塞关注的是进程在等待结果时的状态。
-
阻塞:结果返回之前,当前线程被挂起,只有得到结果后才会返回
-
非阻塞:不能立刻得到结果时,调用不会阻塞当前线程,立即返回
4.3 四者关系
同步/异步回答的是"结果怎么拿",阻塞/非阻塞回答的是"等的时候在干嘛"。二者可以组合:
-
同步阻塞:发起调用后干等,结果到手才返回
-
同步非阻塞:发起调用后立即返回,但要自己反复去查结果
-
异步阻塞:发起调用后立即返回,但用另一个阻塞操作去等通知
-
异步非阻塞:发起调用后立即返回,完事了对方主动通知,全程不卡
五、非阻塞IO的实现
5.1 fcntl 函数
一个文件描述符默认是阻塞IO。要将其改为非阻塞,需要借助 fcntl 函数
cpp
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
fcntl 根据 cmd 的不同具备多个功能:
| cmd | 功能 |
|---|---|
| F_DUPFD | 复制一个现有的描述符 |
| F_GETFD / F_SETFD | 获得/设置文件描述符标记 |
| F_GETFL / F_SETFL | 获得/设置文件状态标记 |
| F_GETOWN / F_SETOWN | 获得/设置异步IO所有权 |
| F_GETLK / F_SETLK / F_SETLKW | 获得/设置记录锁 |
设置非阻塞用到的是第三种功能------获取/设置文件状态标记。
5.2 SetNoBlock 函数
基于 fcntl,可以封装一个将文件描述符设为非阻塞的工具函数:
cpp
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL); // 取出当前文件状态标记(位图)
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); // 叠加O_NONBLOCK标志后写回
}
先用 F_GETFL 读出原有标记,再通过按位或加上 O_NONBLOCK,最后用 F_SETFL 写回。
5.3 轮询方式读取标准输入
下面的例子将标准输入设为非阻塞,然后循环读取:
cpp
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
SetNoBlock(0); // 将标准输入设为非阻塞
while (1) {
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0) {
perror("read"); // 无数据时返回EWOULDBLOCK,打印错误
sleep(1);
continue;
}
printf("input: %s\n", buf);
}
return 0;
}
当用户没有输入时,read 会立即返回 -1 并将 errno 置为 EWOULDBLOCK,程序打印错误后休眠1秒再继续尝试。
六、总结
五种IO模型的差异,本质上集中在"如何等待数据就绪"和"谁来完成数据拷贝"这两个问题上。
阻塞IO简单但效率低,非阻塞IO靠轮询避免挂起却浪费CPU,信号驱动IO用通知替代轮询,IO多路转接以单线程管理多连接成为高并发首选,异步IO则把两个阶段都交给内核,实现了真正的全异步。
理解这些模型的区别,是学习网络编程和高性能服务器开发的基础。