io - models

06. IO 模型

0. 本章先解决什么问题

很多程序慢,不是慢在 CPU 计算,而是慢在等 IO:

  • 等文件读取。
  • 等磁盘刷盘。
  • 等网络数据。
  • 等设备响应。
  • 等远端服务。

IO 模型要回答:

线程在等待 IO 时应该怎么办?
一个线程能不能管理多个 IO 对象?
就绪通知和完成通知有什么区别?
为什么高并发连接不能简单一个连接一个线程?

本章讲阻塞、非阻塞、多路复用、异步、Reactor,并把它们连接到实际排障。

IO 模型对照

这张图怎么读

这张图把几个容易混的词拆开:阻塞/非阻塞说的是调用时线程等不等;同步/异步说的是结果由谁推进;多路复用说的是一个线程能等多个 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 和业务处理

简化:

代码块PLAINTEXT · 4 行收起展开
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 分发出去后,下游处理慢线程池不足、任务过重、锁竞争

最小排查路径:

  1. 看 event loop 是否执行了阻塞调用或长计算。
  2. 看 handler 是否只做短逻辑,把重任务交给工作队列。
  3. 看写缓冲是否持续增长,判断下游是否处理不过来。
  4. 看非阻塞 IO 是否读到 would-block 为止,避免边缘触发漏事件。
  5. 看超时是否分层设置,避免一个慢上游拖住整条链路。

IO 模型的核心不是背名词,而是保证“等待”和“慢处理”不会无声放大成全局停顿。

设计反例:event loop 里混入慢任务

事件循环最大的禁忌,是在 loop 线程里做不可预测的长任务:

慢任务后果
同步读大文件所有连接事件都等它读完
调用慢远端服务loop 线程被外部网络卡住
大量 CPU 计算可读/可写事件不能及时处理
持锁等待一个共享锁拖住整组连接
大量日志同步刷盘IO 抖动放大全局延迟

正确的 mental model 是:

event loop 负责接收事件、快速推进状态机
worker 负责不可预测的 CPU/阻塞任务
队列和背压负责防止 worker 堆爆

如果只把阻塞 IO 改成 epoll,但 handler 里仍然做慢操作,系统只是把“一个连接一个线程慢”变成“一个 loop 管的一批连接一起慢”。

13. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. IO 为什么通常分成等待数据和复制数据两个阶段?
  2. 阻塞 IO 和非阻塞 IO 的区别是什么?
  3. 非阻塞 IO 为什么需要多路复用配合?
  4. select/poll/epoll 的核心差异直觉是什么?
  5. 同步/异步和阻塞/非阻塞为什么不是同一维度?
  6. Reactor 为什么要求 event loop 不能阻塞?
  7. 缓冲区满和背压为什么会影响延迟?
  8. 高并发连接为什么常用多路复用而不是无限加线程?

IO 模型的核心是:等待很慢,线程很贵,事件通知和缓冲管理决定系统如何承受大量外部等待。

延伸阅读