# 五种 IO 模型 —— 从阻塞到 io_uring 的演进

> [总纲](/concepts/network/overview.md)说高并发要摊薄"每次 IO 的 CPU 账"，而这笔账怎么付，取决于你用哪种 **IO 模型**。本篇讲清 Linux 下五种 IO 模型（阻塞 / 非阻塞 / IO 多路复用 / 信号驱动 / 异步）的本质区别，给出对比表，澄清"同步/异步"与"阻塞/非阻塞"这对常被混淆的正交概念，最后落到现代真异步 io_uring。多路复用是高并发主力，其内部机制细节放在 [epoll.md](/concepts/network/epoll.md)。

## 一、先看一次网络读的两个阶段

理解五种 IO 模型，先把**一次 `read()` 拆成两个阶段**——这是全篇的锚：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<w>> #FFF3E0
  BorderColor<<w>>     #EF6C00
  BackgroundColor<<c>> #E3F2FD
  BorderColor<<c>>     #1976D2
}
rectangle "网卡" as NIC
rectangle "内核 socket\n接收缓冲区" as KBUF
rectangle "用户缓冲区\n(你的 char buf[])" as UBUF
NIC --> KBUF : 阶段① 等数据到达\n(数据从网络到内核)
KBUF --> UBUF : 阶段② 数据拷贝\n(内核 → 用户态)
note bottom of KBUF
  阶段①: 数据还没来,
  socket 缓冲区是空的,
  要"等"
end note
note bottom of UBUF
  阶段②: 数据到了内核,
  还得【拷】到你的 buf 里,
  这一拷也要 CPU/时间
end note
@enduml
```

- **阶段①：等数据到达**——数据从网卡进入内核的 socket 接收缓冲区。数据没来之前，这个阶段就是"等"。
- **阶段②：数据拷贝**——数据到了内核缓冲区后，还要从内核态拷贝到用户态的 `buf`。这一拷是实打实的 CPU 开销（这也是[零拷贝](/concepts/network/zero-copy.md)想省掉的）。

**五种 IO 模型的全部区别，就在这两个阶段里：谁阻塞、谁不阻塞、谁来做拷贝、做完怎么通知你。** 记住这句话，下面五个模型都是它的变体。

> **一句话**：**一次 read = 「等数据」+「拷数据」两个阶段，IO 模型的差异全在这两阶段的分工上。**

## 二、五种 IO 模型逐个讲

### 2.1 阻塞 IO（blocking IO）

最朴素：调 `read()`，**两个阶段都阻塞**——数据没来一直睡，数据来了内核拷贝时也还在这条 `read` 里等，直到拷完才返回。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
}
participant "应用线程" as APP
participant "内核" as K
APP -> K : read(fd)   [发起]
note over APP #FFCCBC : 阻塞睡眠(阶段①等 + 阶段②拷)
K --> K : 等数据到达...
K --> K : 数据到,拷贝到用户缓冲区
K -> APP : 返回(拿到数据)
note over APP #C8E6C9 : 醒来继续处理
@enduml
```

- **优点**：编程最简单，逻辑顺序直白。
- **致命缺点**：一个线程同一时刻只能等一个 fd。要服务 N 连接就得 N 线程——正是[总纲](/concepts/network/overview.md)里 C10K 崩掉的老模型。线程一睡一醒还各摊一次[上下文切换](/concepts/process/context-switch.md)（自愿切换），连接一多切换开销就雪崩。

### 2.2 非阻塞 IO（non-blocking IO）

给 fd 设 `O_NONBLOCK`。`read()` **阶段①不再睡**：数据没来立刻返回 `EAGAIN`/`EWOULDBLOCK`，数据来了才真正读（阶段②的拷贝仍在这条 `read` 里同步完成）。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
}
participant "应用线程" as APP
participant "内核" as K
APP -> K : read(fd)
K -> APP : EAGAIN(没数据,立即返回)
APP -> K : read(fd)  (再试)
K -> APP : EAGAIN
APP -> K : read(fd)  (还在试...)
note over APP #FFCCBC : 忙轮询,空转烧 CPU
K -> APP : 数据到,返回数据
@enduml
```

- **优点**：不占着线程睡，理论上一个线程能轮着问多个 fd。
- **缺点**：**忙等（busy-poll）浪费 CPU**——大部分轮询都是空手而归的 `EAGAIN`。很少单独用，通常作为多路复用的配套（epoll 边缘触发下 fd 必须设非阻塞）。

### 2.3 IO 多路复用（select / poll / epoll）—— 高并发主流

思路转变：不再自己一个个问 fd，而是把一堆 fd 交给内核，用 **`select`/`poll`/`epoll` 一次性"帮我盯着这些 fd，哪个就绪了告诉我"**。一个线程就能同时管成千上万个连接。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
}
participant "应用线程" as APP
participant "内核" as K
APP -> K : epoll_wait(盯 1 万个 fd)
note over APP #FFCCBC : 阻塞,但一次盯【全部】fd
K --> K : 等任意 fd 就绪...
K -> APP : 返回就绪的 fd 列表(比如 37 个)
loop 对每个就绪 fd
  APP -> K : read(fd)  非阻塞,直接拿到数据
end
@enduml
```

三种多路复用 API 的差异是面试/调优的高频点，这里给对比表（详细序列图对比和选型指南见 [select/poll/epoll 深度对比](/concepts/network/io-multiplexing-comparison.md)，epoll 内部红黑树 + 就绪链表的机制细节见 [epoll.md](/concepts/network/epoll.md)）：

| 维度 | select | poll | epoll |
|------|--------|------|-------|
| fd 数量上限 | **1024**（`FD_SETSIZE` 硬限制） | 无硬上限（用数组） | 无硬上限（受内存/`ulimit`） |
| 每次调用传参 | 每次把**全部** fd 集合从用户态拷进内核 | 同样每次拷贝整个 fd 数组 | **一次 `epoll_ctl` 注册**，之后 `epoll_wait` 不再重复传 |
| 找就绪 fd 的复杂度 | **O(n)** 线性扫描全部 fd | **O(n)** 线性扫描 | **O(1)** 就绪链表回调，只返回就绪的 |
| 返回结果 | 改写 fd 集合，需自己遍历判断 | 遍历数组查 `revents` | **只给就绪的那几个 fd** |
| 适用场景 | 连接少、要跨平台移植 | 比 select 好点，仍不适合海量连接 | **海量连接、Linux 高并发首选** |

- **select**：老古董。三宗罪——1024 上限、每次拷贝全部 fd 集合、返回后 O(n) 扫描。
- **poll**：去掉了 1024 上限（用数组代替位图），但**每次仍拷贝全部 + O(n) 扫描**的老毛病还在。
- **epoll**：Linux 的答案。fd **注册一次**常驻内核红黑树，就绪时靠回调进就绪链表，`epoll_wait` **只把就绪的 fd 拿回来**——开销随"活跃连接"而非"总连接"增长。这正是[总纲](/concepts/network/overview.md)说的 C10K→C10M 的关键。

> 注意：多路复用本身仍是**同步**的——`epoll_wait` 告诉你"就绪了"，真正的 `read`（阶段②拷贝）还是你自己同步调的。它省的是"等哪个 fd"的成本，不是"拷贝"的成本。

### 2.4 信号驱动 IO（SIGIO）

给 socket 装 `SIGIO` 信号处理函数，数据就绪时内核发个信号通知你，你再去 `read`。阶段①不阻塞（等信号），阶段②的拷贝仍同步。

- 实践中**很少用**：信号是异步打断，处理函数里能做的事极受限（异步信号安全），且在高并发下信号会丢失/合并、难以精确对应到哪个 fd。基本被 epoll 取代，了解即可。

### 2.5 异步 IO（AIO / io_uring）—— 真正的异步

前面四种，**阶段②的拷贝都是你自己同步做的**（`read` 阻塞在拷贝上）。异步 IO 不一样：**你提交一个"读请求"就返回去干别的，内核把两个阶段全干完（连拷贝都替你做完，数据已在你 buf 里），再通知你"好了"。** 这才是真异步。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
}
participant "应用线程" as APP
participant "内核" as K
APP -> K : 提交读请求(buf 地址)
K -> APP : 立即返回
note over APP #C8E6C9 : 该干嘛干嘛,不等
K --> K : 阶段①等数据 + 阶段②拷到你的 buf\n(全内核代劳)
K -> APP : 完成通知(数据已在 buf 里)
@enduml
```

- **Linux 原生 AIO（`libaio`）的局限**：只对 `O_DIRECT` 直接 IO 的**文件**有效，对网络 socket 基本没用、还常退化成同步；接口难用。所以"Linux 有 AIO 但等于没有真异步"是长期现实。
- **io_uring 才是现代真异步**：Linux 5.1+ 引入。核心思路——应用与内核**共享两个环形队列**（SQ 提交队列 / CQ 完成队列），应用把 IO 请求写进 SQ，内核处理完把结果写进 CQ：
  - **批量提交**：一次 `io_uring_enter` 提交一批请求，甚至内核 SQPOLL 模式下**连这个系统调用都省了**——大幅削减[系统调用](/concepts/process/syscall.md)次数（正是高并发瓶颈②要砍的税）。
  - **共享内存环，无需拷贝提交/完成结构**：请求和结果直接在共享环里传递。
  - 这套"共享环形缓冲区 + 批量、少陷入内核"的思路，和 [perf 的 mmap ring buffer](/tools/code/perf-internals.md) 采样机制**如出一辙**——都是用共享内存环把"频繁的内核↔用户交互"批量化、去系统调用化。

> **一句话**：**前四种模型的拷贝都要你自己同步等；只有 AIO/io_uring 让内核把「等 + 拷」全做完再通知你，io_uring 靠共享环 + 批量提交把系统调用开销也一并砍掉。**

## 三、五模型对比总表

| IO 模型 | 阶段① 等数据 | 阶段② 拷贝 | 同步/异步 | 典型 API |
|---------|-------------|-----------|-----------|----------|
| 阻塞 IO | **阻塞** | 阻塞 | 同步 | `read()`（默认） |
| 非阻塞 IO | 不阻塞（轮询 `EAGAIN`） | 阻塞（数据到才拷） | 同步 | `read()` + `O_NONBLOCK` |
| IO 多路复用 | **阻塞在 select/poll/epoll** | 阻塞 | 同步 | `select`/`poll`/`epoll_wait` + `read` |
| 信号驱动 IO | 不阻塞（等 SIGIO） | 阻塞 | 同步 | `SIGIO` + `read` |
| 异步 IO | **不阻塞** | **不阻塞（内核代劳）** | **异步** | `io_uring` / `libaio` |

关键观察：**前四种阶段②都由应用自己同步拷 → 都是同步 IO；只有最后一种阶段②由内核代劳 → 才是异步 IO。** 多路复用虽然强大，本质仍是"同步非阻塞"的范畴。

> **一句话**：**判断异步与否，只看阶段②的拷贝是不是内核替你做了——是，才叫异步。**

## 四、正交关系澄清：同步/异步 ≠ 阻塞/非阻塞

这两对概念最容易混，因为它们**描述的是两个不同阶段**：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor #E8F5E9
  BorderColor     #388E3C
}
rectangle "阻塞 vs 非阻塞\n【说的是阶段①：等数据时，线程睡不睡】" as A
rectangle "同步 vs 异步\n【说的是阶段②：拷贝谁做，做完谁通知】" as B
note bottom of A
  阻塞 = 等数据时线程睡着
  非阻塞 = 等数据时立即返回 EAGAIN,不睡
end note
note bottom of B
  同步 = 阶段②拷贝要你自己调 read 完成
  异步 = 阶段②拷贝内核替你做完再通知你
end note
@enduml
```

- **阻塞 / 非阻塞** 说的是**阶段①**：等数据时，你的线程是睡着（阻塞）还是立即返回（非阻塞）。
- **同步 / 异步** 说的是**阶段②**：数据的拷贝是你自己同步调 `read` 完成（同步），还是内核全做完再通知你（异步）。

所以会出现"非阻塞 + 同步"（非阻塞 IO、多路复用都是）这种组合——阶段①不睡，但阶段②仍得自己拷。理解了两个阶段，这对概念就不再打架。

> **一句话**：**阻塞/非阻塞管「等」，同步/异步管「拷」，两把尺子量的是两个阶段，别混。**

## 五、选型与链接

- **高并发首选**：IO 多路复用（epoll），是绝大多数高性能服务端的基座——机制见 [epoll.md](/concepts/network/epoll.md)。
- **IO 模型要配线程模型**：epoll 只解决"一个线程盯多少 fd"，处理请求还得靠合适的并发模型（Reactor / 线程池 / 协程）——见 [thread-models.md](/concepts/network/thread-models.md)。
- **每次 IO 都是系统调用**：`read`/`epoll_wait`/`io_uring_enter` 都要陷入内核，这笔固定开销正是 io_uring 批量化想省的——见 [../process/syscall.md](/concepts/process/syscall.md)。
- **想榨干拷贝开销**：阶段②的拷贝可用[零拷贝](/concepts/network/zero-copy.md)进一步消除。
- **先观测再选型**：用 [`ss`](/tools/network/ss.md) 看连接状态与数量（`ESTABLISHED`/`TIME_WAIT`、`Recv-Q`/`Send-Q` 积压），确认瓶颈确实在连接规模上，再决定要不要上多路复用。

## 一句话总结

**一次网络读 = 「等数据」+「拷数据」两阶段，五种 IO 模型就是这两阶段"谁阻塞、拷贝谁做"的不同组合：阻塞→非阻塞→多路复用（epoll，高并发主流）→信号驱动→异步（io_uring，唯一真异步）；分清"阻塞/非阻塞管等、同步/异步管拷"这两把正交的尺子，选型就不会乱。**
