写在前面
本文是操作系统底层系列第一篇,系统讲清楚 5 种 I/O 模型:阻塞、非阻塞、多路复用、信号驱动、异步。理解这些是理解 Nginx、Redis、Netty、Kestrel 为什么快的根基。
之前在 Nginx 系列聊过 epoll,这里从操作系统层面把整个 I/O 模型体系讲全。
一、I/O 的本质
1.1 为什么要懂 I/O 模型
1
2
3
4
5
6
7
8
9
10
| 程序 99% 的时间在等 I/O(网络、磁盘)
CPU 处理只是零头
I/O 慢的根本:
CPU:纳秒级
内存:百纳秒级
网络/磁盘:毫秒级(比 CPU 慢 100 万倍)
所以高效程序的核心:不让 CPU 陪 I/O 等
不同 I/O 模型 = 不同的"等待方式"
|
1.2 用户空间与内核空间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 操作系统分两层:
用户空间(User Space)— 应用程序运行的地方
内核空间(Kernel Space)— 操作系统内核,能直接碰硬件
应用不能直接读写硬件(网卡、磁盘),必须通过「系统调用」请求内核代劳。
一次 read() 的流程:
1. 应用调用 read()(系统调用)
2. 切换到内核态
3. 内核等数据到达网卡/磁盘
4. 内核把数据从内核缓冲区拷贝到用户缓冲区
5. 切换回用户态,应用拿到数据
两个阶段:
阶段1:等待数据就绪(数据从硬件到内核缓冲区)—— 慢,主要耗时
阶段2:数据拷贝(内核缓冲区 → 用户缓冲区)—— 快
|
1
2
3
| 所有 I/O 模型的区别,就在于「应用如何处理这两个阶段」:
阶段1(等数据):阻塞?轮询?被通知?
阶段2(拷贝):谁拷贝?什么时候拷?
|
二、阻塞 I/O(Blocking I/O)
最传统的模型。应用调用 read() 后,一直阻塞到数据读完。
1
2
| // 应用代码
int n = read(fd, buf, size); // 阻塞在这,直到数据读完
|
1
2
3
4
5
6
7
8
9
10
11
| 时间线:
应用 read() ──阻塞─────────────────────── 拿到数据
│
内核 等数据就绪 ──── 拷贝数据 ─→
(阶段1) (阶段2)
阶段1、阶段2 都阻塞应用线程
问题:一个线程只能等一个 fd
1万个连接 = 1万个线程傻等
这就是 C10K 问题的根源
|
三、非阻塞 I/O(Non-blocking I/O)
应用调用 read(),如果数据没就绪,立即返回错误(EAGAIN),不阻塞。
1
2
3
4
5
6
7
8
9
10
11
12
| // 设置 fd 为非阻塞
fcntl(fd, F_SETFL, O_NONBLOCK);
// 循环轮询
while (1) {
int n = read(fd, buf, size);
if (n == -1 && errno == EAGAIN) {
// 数据没就绪,干点别的,待会儿再问
continue;
}
// 有数据了
}
|
1
2
3
4
5
6
7
8
9
10
11
| 时间线:
应用 read()→EAGAIN read()→EAGAIN read()→数据 拿到数据
(立即返回) (立即返回) (拷贝)
│
内核 等数据就绪 ──── 拷贝 ─→
阶段1 不阻塞(轮询),但阶段2(拷贝)仍阻塞
问题:轮询浪费 CPU(空转)
不停 read 大部分返回 EAGAIN,CPU 全耗在询问上
很少单独用,通常配合多路复用
|
四、I/O 多路复用(select / poll / epoll)
一个线程同时监控多个 fd,谁有数据就处理谁。这是高并发的核心。
1
2
3
4
5
6
7
8
9
10
11
12
| // epoll 用法(简化)
int epfd = epoll_create1(0);
// 注册关心的 fd
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &event);
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &event);
// 等待(阻塞,直到有 fd 就绪)
int n = epoll_wait(epfd, events, max, -1);
// n = 就绪的 fd 数量,只返回有数据的
for (i = 0; i < n; i++)
handle(events[i]); // 处理(read 时阶段2仍阻塞)
|
1
2
3
4
5
6
7
8
9
10
| 时间线:
应用 epoll_wait() ──阻塞(等任意fd就绪)──→ 返回就绪 → read(拷贝)
│
内核 监听fd1,fd2... fd2数据到 → 通知 → 拷贝
阶段1:交给内核多路复用,应用只等通知(不轮询)
阶段2:read 拷贝时仍阻塞(但很快)
1个线程管理上万 fd,谁就绪处理谁
Nginx / Redis 的核心
|
select / poll / epoll 对比
1
2
3
4
5
6
7
8
| select poll epoll
────────────────────────────────────────────────────────────────
连接数限制 1024(FD_SETSIZE) 无限制 无限制
每次调用 传全部 fd 传全部 fd 只 epoll_wait
内核检查 遍历全部 fd O(n) 遍历全部 O(n) 就绪回调 O(1)
返回结果 全部 fd(再遍历) 全部(再遍历) 只返回就绪的
数据结构 位图 数组 红黑树+就绪链表
适用 连接少 连接多 连接多+活跃少(最佳)
|
(epoll 细节见 Nginx 系列第二篇,这里不重复。)
五、信号驱动 I/O(Signal-driven)
应用告诉内核"fd 有数据时给我发信号",然后去干别的。数据就绪时内核发 SIGIO 信号。
1
2
3
4
5
6
7
8
9
10
11
| // 开启信号驱动
fcntl(fd, F_SETFL, O_ASYNC);
fcntl(fd, F_SETOWN, getpid()); // 信号发给当前进程
// 注册信号处理函数
signal(SIGIO, handler);
// 应用继续干别的,数据就绪时 handler 被调用
void handler(int sig) {
read(fd, buf, size); // 此时数据已就绪,拷贝很快
}
|
1
2
3
4
5
6
7
8
9
| 时间线:
应用 注册信号 → 干别的 ... 被信号打断 → read(拷贝)
↑SIGIO
内核 等数据就绪 ─── 发信号 ─→ 拷贝
阶段1:不阻塞,靠信号通知
阶段2:read 仍阻塞
用得少(信号处理复杂、队列溢出问题),UDP 场景偶有使用
|
六、异步 I/O(AIO,真正的异步)
前 4 种模型,阶段2(数据拷贝)都还是要应用自己 read。异步 I/O 让内核连拷贝都做完,再通知应用。
1
2
3
4
5
6
| // POSIX AIO(Linux)/ IOCP(Windows)
struct aiocb cb = { .aio_fildes = fd, .aio_buf = buf, .aio_nbytes = size };
aio_read(&cb); // 立即返回,内核自己去等+拷贝
// 应用继续干别的
// 内核完成(等数据 + 拷贝)后,发信号/回调通知
|
1
2
3
4
5
6
7
8
9
10
11
12
| 时间线:
应用 aio_read() → 立即返回,干别的 ... → 通知(数据已在 buf)
↑
内核 等数据就绪 ─── 拷贝到 buf ─→ 通知
阶段1、阶段2 都由内核完成,应用完全不阻塞
这是真正的异步(前面 4 种都是"同步 I/O"的不同等待方式)
Linux 的 AIO:
- POSIX aio:用户态模拟,性能一般
- io_uring(Linux 5.1+):新一代高性能异步 I/O,接近 Windows IOCP
Windows IOCP:成熟的高性能异步 I/O
|
七、五种模型对比
1
2
3
4
5
6
7
| 阶段1(等数据) 阶段2(拷贝) 是否真异步
──────────────────────────────────────────────────────
阻塞 I/O 阻塞 阻塞 否
非阻塞 I/O 轮询(不阻塞) 阻塞 否
I/O 多路复用 阻塞(等通知) 阻塞 否
信号驱动 I/O 信号通知 阻塞 否
异步 I/O 内核完成 内核完成 是 ✓
|
1
2
3
4
5
6
| 前 4 种本质都是「同步 I/O」——阶段2(拷贝)都要应用自己干
只有第 5 种是「真正的异步」——内核把活全干了
Reactor 模式(Nginx/Redis/Netty)= 用 I/O 多路复用 + 同步处理
Proactor 模式(Windows IOCP) = 用异步 I/O
(详见本系列 Reactor/Proactor 篇)
|
八、概念澄清:同步/异步 vs 阻塞/非阻塞
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 这两个维度容易混,区分清楚:
阻塞 vs 非阻塞(调用方的行为):
阻塞 — 调用后线程挂起,直到有结果
非阻塞 — 调用立即返回(没有结果就返回错误)
同步 vs 异步(结果如何获取):
同步 — 调用方主动去拿结果(read 自己拷贝数据)
异步 — 被调用方做完后通知调用方(内核拷贝完通知)
组合:
同步阻塞 = read()(最传统)
同步非阻塞 = 非阻塞 read() 轮询
异步 = aio_read()(被通知)
「异步」必然涉及「通知」,调用方不等,被通知后结果已就绪
|
九、小结
- I/O 两阶段:等数据就绪(慢)+ 数据拷贝(快)
- 5 种模型:
- 阻塞:简单,一连接一线程
- 非阻塞:轮询,浪费 CPU
- 多路复用:一线程管多 fd,高并发核心(select/poll/epoll)
- 信号驱动:用得少
- 异步 I/O:内核全包,真正异步(io_uring/IOCP)
- 关键认知:前 4 种都是同步 I/O(拷贝阶段应用自己干),只有异步 I/O 是真异步
- 概念:阻塞/非阻塞是调用行为,同步/异步是结果获取方式
下一篇讲进程、线程、协程的本质与调度。