io - models
06. IO 模型
0. 本章先解决什么问题
很多程序慢,不是慢在 CPU 计算,而是慢在等 IO:
- 等文件读取。
- 等磁盘刷盘。
- 等网络数据。
- 等设备响应。
- 等远端服务。
IO 模型要回答:
线程在等待 IO 时应该怎么办?
一个线程能不能管理多个 IO 对象?
就绪通知和完成通知有什么区别?
为什么高并发连接不能简单一个连接一个线程?
本章讲阻塞、非阻塞、多路复用、异步、Reactor,并把它们连接到实际排障。
这张图怎么读
这张图把几个容易混的词拆开:阻塞/非阻塞说的是调用时线程等不等;同步/异步说的是结果由谁推进;多路复用说的是一个线程能等多个 IO 对象;Reactor 通知“可以读写”,Proactor 通知“已经完成”。
1. IO 的两个阶段
一次读操作通常有两个阶段:
等待数据准备好
把数据从内核复制到用户缓冲区
网络读:
等对端数据到达 socket buffer
从 socket buffer 复制到应用 buffer
文件读:
等页缓存或磁盘数据准备好
从内核缓冲区复制到用户缓冲区
不同 IO 模型主要差在:等待阶段由谁等,怎么通知。
2. 阻塞 IO
阻塞 IO:
read()
-> 数据没准备好
-> 当前线程睡眠
-> 数据到达
-> 线程被唤醒
-> read 返回
优点:
- 编程简单。
- 控制流直观。
缺点:
- 每个阻塞连接可能占一个线程。
- 连接多时线程数量膨胀。
- 上下文切换和栈内存成本高。
适合连接少、逻辑简单、阻塞可接受的场景。
3. 非阻塞 IO
非阻塞 IO:
read()
-> 数据没准备好
-> 立即返回 would-block 类错误
优点:
- 线程不会睡死在一个 IO 上。
- 可以继续处理其他事情。
缺点:
- 如果一直循环尝试,会忙等浪费 CPU。
- 需要配合就绪通知机制。
非阻塞本身不是完整方案,它通常和多路复用组合。
4. IO 多路复用
多路复用让一个线程等待多个 IO 对象:
等待 fd1, fd2, fd3…
哪个就绪,就处理哪个
流程:
注册感兴趣的 fd
等待事件
返回就绪 fd
对就绪 fd 执行非阻塞 read/write
继续等待
它适合大量连接但每个连接大部分时间都在等待的场景。
5. select、poll、epoll 的直觉
| 模型 | 直觉 |
|---|---|
| select | 每次传入 fd 集合,内核检查哪些就绪 |
| poll | 类似 select,但 fd 表达方式更灵活 |
| epoll | 维护注册集合,事件就绪时通知,适合大量连接 |
学习阶段不必背所有平台细节,先抓住:
多路复用减少线程数量。
事件驱动避免每个连接一个阻塞线程。
但多路复用不代表没有 CPU 成本。事件多、处理慢、回调阻塞都会出问题。
6. 水平触发和边缘触发
就绪通知常见两种语义。
水平触发:
只要缓冲区仍可读/可写
就会持续通知
边缘触发:
状态从不可读变为可读时通知一次
边缘触发通常要求:
一次性读到 would-block 为止
否则可能漏掉后续数据。
反例:边缘触发只读一次为什么会卡住
假设 socket receive buffer 里已经有 8 KB 数据,应用一次只读 4 KB:
- buffer:8 KB 可读
- 事件:从不可读 -> 可读,通知一次
- 应用:recv 4 KB
- 剩余:4 KB 仍然可读
如果是边缘触发,状态没有再次从“不可读”变成“可读”,所以可能不会再通知。剩下的 4 KB 会一直躺在缓冲区里,直到有新数据到来触发新的边缘。
正确纪律是:
收到可读事件后:
while true:
recv()
如果读到数据: 继续处理
如果 would-block: 停止,等待下次事件
这不是 API 细节,而是事件语义决定的控制流不变量。
7. 同步和异步
阻塞/非阻塞关注:
调用时线程是否等待。
同步/异步关注:
结果完成由调用方自己推进,还是完成后通知调用方。
| 模型 | 直觉 |
|---|---|
| 同步阻塞 | 调用后线程等到结果 |
| 同步非阻塞 | 调用立即返回,调用方之后再查 |
| IO 多路复用 | 调用方等待多个就绪事件 |
| 异步 IO | 发起后返回,完成时回调或通知 |
这两个维度不要混淆。
8. Reactor 模型
Reactor 模型:
事件循环等待 IO 事件
事件就绪后分发给 handler
handler 执行 read/write 和业务处理
简化:
代码块收起展开
while running:
events = poll()
for event in events:
dispatch(event.handler)纪律:
- event loop 不做长时间阻塞。
- 慢任务交给工作线程或任务队列。
- read/write 使用非阻塞方式。
- handler 处理要短。
如果 event loop 被阻塞,所有连接都会受影响。
9. Proactor 的直觉
Proactor 更接近异步完成模型:
应用发起异步 IO
系统完成 IO
通知应用处理结果
区别:
Reactor 通知“可以读/写了”
Proactor 通知“读/写已经完成了”
实际平台对异步 IO 支持差异很大。学习时先把“就绪通知”和“完成通知”分清。
10. 背压和缓冲区
IO 系统里有缓冲区:
- 应用缓冲区。
- 内核 socket buffer。
- 设备缓冲区。
- 发送队列。
- 接收队列。
如果生产速度大于消费速度:
缓冲区变满
写入阻塞或失败
延迟上升
数据被丢弃
背压是告诉上游:
下游处理不过来了,别再无限发送。
没有背压,队列会把问题隐藏到更严重。
11. 联系实际:连接很多时为什么不能只加线程
如果一个连接一个线程:
10000 个连接 -> 10000 个线程
问题:
- 每个线程有栈内存。
- 调度和上下文切换成本高。
- 大量线程大部分时间在等 IO。
- 锁和队列管理复杂。
多路复用模型:
少量事件循环线程
-> 管理大量连接就绪事件
-> 把 CPU 重任务交给工作线程
这不是因为线程不好,而是因为等待型连接不需要每个都占一个独立阻塞线程。
12. IO 排障映射
| 现象 | 可能方向 |
|---|---|
| 线程数暴涨 | 阻塞 IO 或阻塞任务太多 |
| event loop 卡住 | handler 做了慢计算或阻塞调用 |
| CPU 空转高 | 非阻塞轮询没配合等待机制 |
| 写入变慢 | 对端慢、内核发送缓冲区满 |
| 读取超时 | 对端没发、网络慢、线程没及时处理 |
| 连接很多但线程少 | 多路复用正常现象 |
| 延迟突然变高 | 队列堆积、缓冲区满、调度延迟 |
排障卡:event loop 卡住时看三类队列
事件驱动系统出现“所有连接都慢”时,不要只盯某一个连接。一个 event loop 被慢任务卡住,会让它管理的所有连接一起延迟升高。
| 队列/缓冲 | 堆积说明什么 | 典型方向 |
|---|---|---|
| 就绪事件队列 | 事件来了但处理不过来 | handler 太慢、CPU 任务占住 event loop |
| 内核 socket buffer | 对端发了或本端要发,但应用没及时读写 | 应用消费慢、背压缺失 |
| 工作线程队列 | event loop 分发出去后,下游处理慢 | 线程池不足、任务过重、锁竞争 |
最小排查路径:
- 看 event loop 是否执行了阻塞调用或长计算。
- 看 handler 是否只做短逻辑,把重任务交给工作队列。
- 看写缓冲是否持续增长,判断下游是否处理不过来。
- 看非阻塞 IO 是否读到 would-block 为止,避免边缘触发漏事件。
- 看超时是否分层设置,避免一个慢上游拖住整条链路。
IO 模型的核心不是背名词,而是保证“等待”和“慢处理”不会无声放大成全局停顿。
设计反例:event loop 里混入慢任务
事件循环最大的禁忌,是在 loop 线程里做不可预测的长任务:
| 慢任务 | 后果 |
|---|---|
| 同步读大文件 | 所有连接事件都等它读完 |
| 调用慢远端服务 | loop 线程被外部网络卡住 |
| 大量 CPU 计算 | 可读/可写事件不能及时处理 |
| 持锁等待 | 一个共享锁拖住整组连接 |
| 大量日志同步刷盘 | IO 抖动放大全局延迟 |
正确的 mental model 是:
event loop 负责接收事件、快速推进状态机
worker 负责不可预测的 CPU/阻塞任务
队列和背压负责防止 worker 堆爆
如果只把阻塞 IO 改成 epoll,但 handler 里仍然做慢操作,系统只是把“一个连接一个线程慢”变成“一个 loop 管的一批连接一起慢”。
13. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- IO 为什么通常分成等待数据和复制数据两个阶段?
- 阻塞 IO 和非阻塞 IO 的区别是什么?
- 非阻塞 IO 为什么需要多路复用配合?
- select/poll/epoll 的核心差异直觉是什么?
- 同步/异步和阻塞/非阻塞为什么不是同一维度?
- Reactor 为什么要求 event loop 不能阻塞?
- 缓冲区满和背压为什么会影响延迟?
- 高并发连接为什么常用多路复用而不是无限加线程?
IO 模型的核心是:等待很慢,线程很贵,事件通知和缓冲管理决定系统如何承受大量外部等待。