主题
第 2 章 · Linux 五大 I/O 模型
学完本章你将知道:阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O、异步 I/O 这五个名词分别长什么样、解决了什么问题、以及为什么多路复用是工业界的事实标准。
1. 一句话开场
第 1 章我们说过
read()有两个阶段:等数据 + 拷数据。 五大 I/O 模型,就是这两个阶段是「同步阻塞 / 同步非阻塞 / 异步通知」的不同组合。
2. 五大 I/O 模型一表览(先有个全局印象)
| 模型 | "等数据"阶段 | "拷数据"阶段 | 谁处理通知 | 谁在工业界主流? |
|---|---|---|---|---|
| ① 阻塞 I/O(Blocking) | 阻塞 | 阻塞 | — | 客户端代码 |
| ② 非阻塞 I/O(Non-blocking) | 立即返回 EAGAIN,自己轮询 | 阻塞 | — | 很少单独用 |
| ③ I/O 多路复用(Multiplexing) | 阻塞在 select/epoll 上等多个 fd | 阻塞 | 内核统一通知就绪 fd | ⭐⭐⭐⭐⭐ Nginx / Redis |
| ④ 信号驱动 I/O(Signal-driven) | 立即返回,数据到了 SIGIO 通知 | 阻塞 | 信号 SIGIO | 很少用 |
| ⑤ 异步 I/O(AIO) | 立即返回 | 内核完成后通知 | 回调 / 完成事件 | Linux 上的 io_uring 正崛起 |
关键洞察:前 4 种都叫「同步 I/O」——因为"拷数据"那一步还是用户线程在等。只有第 5 种"异步 I/O"是真正的"啥都不等"。
3. 模型详解
3.1 ① 阻塞 I/O(Blocking I/O)
工作流程
生活类比
你去水龙头打水,但水龙头还没出水。你就一直站在那里傻等,直到水来——这期间什么都干不了。
代码长什么样
c
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
connect(sockfd, ...);
char buf[1024];
ssize_t n = read(sockfd, buf, 1024); // ⚠️ 没数据就一直卡这里
printf("got %ld bytes\n", n);优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 写法简单,符合直觉 | 一个线程同时只能等一个连接 |
| 没有 CPU 浪费 | 高并发时要开几千个线程,线程开销爆炸 |
典型问题:早期 Tomcat 的 BIO 模式就是阻塞 I/O——每个请求一个线程,到了几千 QPS 直接 OOM。
3.2 ② 非阻塞 I/O(Non-blocking I/O)
工作流程
生活类比
你去打水,水龙头没水你就马上走开干别的,但你每隔几秒就跑回来看一眼——确实没傻等了,但你的脚都跑酸了,效率反而更低。
代码长什么样
c
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); // 设为非阻塞
while (1) {
ssize_t n = read(sockfd, buf, 1024);
if (n > 0) {
printf("got %ld bytes\n", n);
break;
}
if (n == -1 && errno == EAGAIN) {
// 数据没准备好,可以做点别的事
do_other_work();
continue;
}
// 真错误
perror("read");
break;
}优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 调用立即返回,不挂起 | 用户必须手写轮询,CPU 100% 被吃光 |
| 一个线程理论上能"看着"多个 fd | 一个线程逐个问 fd,效率仍然差 |
结论:单独用没什么意义,它是为多路复用做准备的。所有 epoll 程序的 socket 都设为非阻塞。
3.3 ③ I/O 多路复用(I/O Multiplexing)⭐⭐⭐⭐⭐
核心思想
不让用户线程自己去问每个 fd,而是让内核帮忙看着一堆 fd——任何一个就绪了,内核就回报一次。
工作流程
生活类比
你去医院看病,护士(内核)让所有病人(fd)都坐在大厅等。
- 不是每个病人都派一个护士陪着(那是阻塞 I/O,护士不够)
- 不是病人自己跑来跑去问"轮到我了吗"(那是非阻塞轮询,吵死人)
- 而是叫号系统——叫到哪个号(fd 就绪)哪个去看医生
一个护士(一个线程)就能管理整个候诊大厅——这就是多路复用。
三代实现
text
1983 1997 2002
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐
│ select │ → │ poll │ → │ epoll │
│ (BSD 起源) │ │ (System V) │ │ (Linux 专属) │
├─────────────┤ ├─────────────┤ ├──────────────────┤
│ 1024 个 fd │ │ 无上限 │ │ 无上限 │
│ 位图 │ │ 数组 │ │ 红黑树+就绪链表 │
│ O(n) 遍历 │ │ O(n) 遍历 │ │ O(1) 拿就绪 │
│ 全量拷贝 │ │ 全量拷贝 │ │ 注册一次复用 │
└─────────────┘ └─────────────┘ └──────────────────┘重头戏:第 3、4、5 章会一个一个把它们扒光。
优缺点
| ✅ 优点 | ❌ 缺点 |
|---|---|
| 一个线程管理上万 fd | 写法比阻塞 I/O 复杂 |
| 减少线程数,节省内存与上下文切换 | 需要理解事件回调式编程模型 |
| epoll 性能可达百万 QPS | LT/ET 等细节容易踩坑 |
3.4 ④ 信号驱动 I/O(Signal-driven I/O)
工作流程
生活类比
你给水龙头绑了一个铃铛——平时去做别的事,水一来铃铛就响,你跑回来打水。
为什么没人用?
- 信号是异步打断,处理器函数里能用的 API 极其受限(只能用 async-signal-safe 的)
- 多个 fd 共享一个 SIGIO,无法区分是哪个 fd 就绪
- 信号会丢失(如果同时来好几个,只触发一次)
结论:理论模型,工程上几乎不用。多路复用更优雅。
3.5 ⑤ 异步 I/O(Asynchronous I/O,AIO)
工作流程
生活类比
送水上门:你给水站打电话留地址,水站搞定一切,水送到桌上时你才被叫一声——你两个阶段都没参与。
代码(POSIX AIO)
c
struct aiocb cb = {0};
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = 1024;
aio_read(&cb); // 立刻返回,内核异步搞定一切
// 用户线程做别的事...
while (aio_error(&cb) == EINPROGRESS) {
do_other_work();
}
ssize_t n = aio_return(&cb); // 取结果现实困境
- 传统 Linux AIO(libaio)只对直接 I/O(O_DIRECT)的磁盘文件好用,对 socket 几乎没用
- POSIX AIO 是用线程池模拟的,不是真异步
- Windows IOCP 是真异步的,做得很好——但跟 Linux 无关
io_uring 改变了一切(2019 起)
text
io_uring:Linux 5.1+ 引入的全新异步 I/O 框架
─────────────────────────────────────────
✅ 用户态 / 内核态共享两个无锁环形队列
✅ 一次 syscall 可以批量提交几千个请求
✅ 真异步,对 socket / 文件 / 时间事件统一支持
✅ 性能在某些场景超过 epoll
🔄 正在被 Nginx、ScyllaDB、PostgreSQL 等逐步引入结论:未来 5-10 年内 io_uring 可能成为新主流,但当下面试和业务代码 90% 都还是 epoll——这就是本系列重点讲 epoll 的原因。
4. 同步 vs 异步、阻塞 vs 非阻塞:四个词的真正含义
这是面试最常被搞混的概念,必须辨明。
4.1 标准定义
| 概念 | 定义 |
|---|---|
| 同步(Synchronous) | 调用者自己完成(或等待结果) |
| 异步(Asynchronous) | 调用者委托别人完成,自己继续干别的 |
| 阻塞(Blocking) | 调用未完成时,线程被挂起 |
| 非阻塞(Non-blocking) | 调用未完成时,立刻返回 |
4.2 用打电话来理解
- 同步阻塞:你打电话问朋友答案,电话不挂等他想完(你在等,你自己听)
- 同步非阻塞:你每分钟打一次电话问"想好了没"(你在多次问,你自己听)
- 异步阻塞:你说"想好了打回来",然后坐在电话旁边一直等他打来(罕见,奇怪的组合)
- 异步非阻塞:你说"想好了打回来",挂了电话该干啥干啥(最爽,io_uring)
4.3 五大模型的归类
| 模型 | 是否同步 | 是否阻塞 |
|---|---|---|
| 阻塞 I/O | 同步 | 阻塞 |
| 非阻塞 I/O | 同步 | 非阻塞 |
| 多路复用 | 同步 | 阻塞(select/epoll_wait 自己阻塞) |
| 信号驱动 | 同步 | 非阻塞 |
| 异步 I/O | 异步 | 非阻塞 |
重要结论:多路复用本质是「同步 + 阻塞」——它阻塞在 select/epoll_wait 上!只是它阻塞着等的是"任意 fd 就绪",而不是"某一个 fd 就绪"。这一点在面试时千万不要搞错!
5. 怎么选模型?决策树
图解说明:99% 的 Linux 后端服务直接选 epoll。跨平台(如 Node.js)通过 libuv 屏蔽 epoll/kqueue/IOCP 的差异;追求极致性能、Linux 5.1+ 可以试试 io_uring。
6. 动手实践
实践:用三种方式写"等输入" 命令
c
// ====== 阻塞版本 ======
// blocking.c
#include <unistd.h>
#include <stdio.h>
int main() {
char buf[100];
printf("阻塞读,输入点东西: ");
fflush(stdout);
ssize_t n = read(0, buf, 100); // 卡在这里直到敲键盘
buf[n] = 0;
printf("收到: %s", buf);
return 0;
}
// ====== 非阻塞版本 ======
// nonblock.c
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <errno.h>
int main() {
fcntl(0, F_SETFL, fcntl(0, F_GETFL) | O_NONBLOCK);
char buf[100];
int tries = 0;
while (1) {
ssize_t n = read(0, buf, 100);
if (n > 0) { buf[n] = 0; printf("\n收到: %s", buf); break; }
if (errno == EAGAIN) {
printf("\r第 %d 次询问,没数据,干别的事...", ++tries);
fflush(stdout);
usleep(200000);
}
}
return 0;
}
// ====== 多路复用版本 ======
// muxed.c
#include <unistd.h>
#include <stdio.h>
#include <sys/select.h>
int main() {
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(0, &rfds); // 监听 stdin
printf("多路复用读,输入点东西: ");
fflush(stdout);
select(1, &rfds, NULL, NULL, NULL);
char buf[100];
ssize_t n = read(0, buf, 100);
buf[n] = 0;
printf("收到: %s", buf);
return 0;
}bash
# 编译运行:
gcc blocking.c -o blocking && ./blocking
gcc nonblock.c -o nonblock && ./nonblock # 会看到不停打印"没数据"
gcc muxed.c -o muxed && ./muxed观察重点:
- blocking:什么也不输出,安静地等
- nonblock:CPU 100%,疯狂打印"没数据"
- muxed:和 blocking 几乎一样,但 select 可以同时监听多个 fd
7. 本章小结
text
五大 I/O 模型差异本质:
─────────────────────────────────────
阻塞 I/O → "等数据" 阻塞,"拷数据" 阻塞
非阻塞 I/O → "等数据" 非阻塞(自己轮询),"拷数据" 阻塞
多路复用 → "等多个 fd" 阻塞在 select/epoll,"拷数据" 阻塞 ⭐
信号驱动 I/O → "等数据" 通过信号回调,"拷数据" 阻塞
异步 I/O → "等" 和 "拷" 都不参与 ⭐⭐8. 真实面试题
Q1:select / epoll 是同步还是异步?
A:同步——它们是"同步阻塞"的多路复用,自己仍然要
read()把数据从内核拷到用户态。真正的异步 I/O 是 io_uring / Windows IOCP。
Q2:多路复用为什么能比多线程阻塞 I/O 更快?
A:① 省线程开销(一个线程顶上千个);② 避免上下文切换(线程切换约 1-2 μs,epoll_wait 唤醒只需 nanosecond 级);③ Cache 友好(数据热度集中在少数线程)。
Q3:什么是 C10K 问题?
A:1999 年 Dan Kegel 提出的——当单机要承担 10000 个并发连接时,传统"一连接一线程 + 阻塞 I/O"模型会在内存、上下文切换、惊群等多个维度崩溃。多路复用 + 事件驱动是解决方案。今天已发展到 C10M(千万级)。
Q4:epoll 是 select 的替代品吗?为什么 select 还存在?
A:epoll 只在 Linux 上有。
select是 POSIX 标准,跨平台——Windows、BSD、macOS 都支持。所以**跨平台库(libuv)**仍然要在 epoll 不可用时退回 select。
Q5:阻塞 I/O 真的一无是处吗?
A:不!① 客户端代码(连接数极少时,简单可靠);② 命令行工具(cat / grep);③ 进程间一对一通信(管道)。多路复用是解决高并发的,不是万能药。
下一站 → 第 3 章:select 详解,我们正式打开多路复用三剑客的第一把:select 的位图魔法。
🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗