高并发网络服务需要同时处理成千上万个连接。若一个连接独占一个线程,线程栈、调度和上下文切换很快就会成为瓶颈。
解决思路是让少量线程统一等待大量 I/O。Reactor 和 Proactor 是两种经典组织方式。二者的关键区别不是“是否使用异步回调”,而是:
应用收到通知时,I/O 是“已经就绪”,还是“已经完成”?
Table of contents
Open Table of contents
先给结论
- Reactor:准备好了,你自己来读。
- Proactor:读好了,数据给你用。
严格说,epoll 这类 API 是“就绪通知 / 多路复用”;IOCP、io_uring 则围绕“操作完成”传递事件。它们都常用于构建高并发 I/O 架构。
Reactor:准备好了,你自己来读
Reactor 等待的是 就绪(readiness)事件:
- socket 可读:内核接收缓冲区中通常已有数据,或连接已关闭、出错;
- socket 可写:内核发送缓冲区当前可以继续接收数据。
事件到达时,应用仍要自己调用 read() / recv() 或 write() / send()。也就是说,通知只是在说“现在可以尝试 I/O”,数据尚未由本次用户态 read() 拷入你的 buffer。
flowchart TD
R["Reactor 事件循环"] --> W["epoll_wait / poll 等待"]
W --> E["fd 就绪:可读或可写"]
E --> C["分发回调"]
C --> IO["应用调用 read / write"]
IO --> B["处理业务或更新发送缓冲区"]
B --> W
典型控制流如下:
- 应用把 fd 的读写兴趣注册到事件循环。
- 事件循环在
epoll_wait()等待。 - 内核报告 fd 可读或可写。
- Reactor 调用对应回调。
- 回调中由应用执行
read()/write()。 - 应用处理数据或更新连接状态,然后进入下一轮等待。
sequenceDiagram
participant App as 应用
participant Reactor as Reactor
participant Kernel as 内核
App->>Reactor: 注册 fd 的读写兴趣
Reactor->>Kernel: epoll_wait()
Kernel-->>Reactor: fd 可读
Reactor-->>App: OnReadable(fd)
App->>Kernel: read(fd, buffer)
Kernel-->>App: 返回已读取字节数
App->>App: 处理数据
常见 API
| 平台 | 常见就绪通知 API |
|---|---|
| POSIX / Linux | select / poll / epoll |
| BSD / macOS | kqueue |
| Windows | select / WSAPoll / WSAEventSelect |
| Java | Selector(NIO) |
| C++ 框架 | libevent、libev、muduo 等 |
伪代码
while (running) {
const auto events = WaitForReadyEvents(epoll_fd);
for (const auto& event : events) {
if (event.readable) {
const ssize_t bytes_read = read(event.fd, read_buffer, sizeof(read_buffer));
if (bytes_read > 0) {
HandleData(read_buffer, bytes_read);
}
}
if (event.writable) {
const ssize_t bytes_written =
write(event.fd, pending_data, pending_size);
UpdatePendingWrite(event.fd, bytes_written);
}
}
}
这里有一个实践重点:“可读 / 可写”不等于“一次就能把所有数据读完 / 写完”。
非阻塞 socket 可能只读到一部分、只写出一部分,也可能在下一次调用时返回 EAGAIN / EWOULDBLOCK。因此 Reactor 通常要维护输入、输出缓冲区和连接状态机;使用 epoll 的边沿触发时,尤其要持续读到 EAGAIN。
Proactor:读好了,数据在这
Proactor 等待的是 完成(completion)事件:
- 异步读已完成,读取结果已经写入应用事先提供的 buffer;
- 异步写已完成,系统报告本次操作实际处理的字节数和结果。
应用先提交 I/O 请求;操作完成后,系统把完成事件投递回来。回调中不需要再为了“取数据”调用一次 read()。
flowchart TD
P["Proactor / 异步 I/O 调度器"] --> S["提交 async_read / async_write"]
S --> K["内核执行 I/O"]
K --> Q["完成队列"]
Q --> H["分发完成回调"]
H --> B["直接处理 buffer 和结果"]
典型控制流如下:
- 应用提交一个异步读或写请求,并提供 buffer 与关联状态。
- 系统在后台执行该 I/O。
- 操作结束后,系统把完成事件放进完成队列。
- Proactor 分发完成事件。
- 回调直接处理 buffer、字节数与错误码。
- 应用视协议状态投递下一次 I/O。
sequenceDiagram
participant App as 应用
participant Proactor as Proactor
participant Kernel as 内核
App->>Proactor: async_read(buffer)
Proactor->>Kernel: 提交异步读请求
Note over App: 应用继续处理其他任务
Kernel->>Kernel: 读取数据到 buffer
Kernel-->>Proactor: 读操作完成
Proactor-->>App: OnReadComplete(buffer, bytes)
App->>App: 处理 buffer
常见 API
| 平台 / 框架 | 常见完成通知 API |
|---|---|
| Windows | OVERLAPPED I/O + IOCP |
| Linux | io_uring(提交 SQE,消费 CQE) |
| POSIX | aio_read / aio_write(实现与适用范围受平台限制) |
| C++ | Boost.Asio 的 async_* 异步操作接口 |
Boost.Asio 值得单独说明:它对应用暴露的是 Proactor 风格语义——异步操作完成后才调用 handler。其底层在不同平台可以使用 IOCP、epoll 等后端;不能简单地把“Asio 非 IOCP”归为 Reactor。
伪代码
proactor.AsyncRead(socket, buffer, buffer_size,
[&buffer](std::size_t bytes_read, ErrorCode error) {
if (!error && bytes_read > 0) {
HandleData(buffer, bytes_read);
}
});
// 完成事件循环:等待并分发已结束的操作。
proactor.Run();
Proactor 也不是“从此没有 partial I/O”。底层一次异步操作依然可能只完成部分字节;框架提供的 async_read_some 往往就是这种语义。像 Boost.Asio 的 async_read 则是组合操作,会持续发起读,直到填满请求的 buffer 或发生错误。
另外,网络异步写“完成”通常表示数据已被本机协议栈接受,而不代表对端应用已经收到。
并排对比
| 维度 | Reactor | Proactor |
|---|---|---|
| 等待什么 | 就绪:readable / writable | 完成:read done / write done |
| 谁发起实际 I/O | 应用在回调中调用 read/write | 应用先提交,系统异步执行 |
| 回调触发时 | 需要继续执行 I/O | 可直接处理结果和 buffer |
| 基本流程 | 通知 → 自己读 / 写 → 处理 | 提交 → 等完成 → 处理 |
| 主要复杂度 | 连接状态机、读写缓冲区、partial I/O | pending 操作、buffer 与对象生命周期 |
| 典型强项 | Linux / 跨平台就绪驱动服务 | Windows IOCP;完成队列驱动的异步 I/O |
flowchart LR
R1["Reactor:等就绪"] --> R2["通知:可读"] --> R3["应用 read"] --> R4["处理"]
P1["Proactor:提交 read"] --> P2["内核执行"] --> P3["通知:读完成"] --> P4["处理"]
三个容易混淆的点
有异步回调,不代表就是 Proactor
两种模型都可以是非阻塞、事件驱动、回调式的。判断方式很简单:回调里是否还要为了本次事件调用 I/O?
// Reactor:收到“可读”通知后,应用自己读。
void OnReadable(int fd) {
char buffer[4096];
const ssize_t bytes_read = read(fd, buffer, sizeof(buffer));
Process(buffer, bytes_read);
}
// Proactor:收到“读完成”通知时,数据已写入 buffer。
void OnReadComplete(char* buffer, std::size_t bytes_read) {
Process(buffer, bytes_read);
}
Overlapped I/O 不自动等于完整 Proactor
Windows 的 ReadFile / WriteFile 配合 OVERLAPPED 是异步 I/O 原语。若提交后立刻用 GetOverlappedResult(..., TRUE) 同步等待,调用线程仍然被阻塞。
典型 Proactor 做法是把操作关联到 IOCP,由 worker 线程用 GetQueuedCompletionStatus 消费完成包。
sequenceDiagram
participant App as 应用
participant Kernel as 内核
participant IOCP as IOCP
participant Pool as Worker 线程池
App->>Kernel: 提交 Overlapped I/O
Kernel->>IOCP: 投递完成包
Pool->>IOCP: GetQueuedCompletionStatus()
IOCP-->>Pool: bytes + OVERLAPPED + key
Pool->>Pool: 处理结果并投递下一次 I/O
IOCP 的价值不只是“异步”:它还能把大量 I/O 的完成通知汇聚到一个队列,并以受控的并发度交给 worker 线程处理。
io_uring 更接近完成模型,但边界并不绝对
Linux 传统高并发网络服务主要使用 epoll。io_uring 提供提交队列(SQ)与完成队列(CQ):提交一个操作后,内核完成处理并生成对应的 CQE,因此它天然适合 Proactor 风格。
不过 io_uring 也支持 poll 等操作,实际框架常混合使用就绪与完成语义。与其死记标签,不如看一段代码的真实控制流:它是在等 fd ready,还是在等已提交操作的结果?
与线程模型的关系
I/O 模型不等于线程模型。两者都可以单线程,也都可以多线程。
| 线程策略 | Reactor 示例 | Proactor 示例 |
|---|---|---|
| 单线程事件循环 | 一个 epoll loop | 一个 io_uring completion loop |
| 多事件循环 | muduo 的 one loop per thread | 可按队列或连接分片 |
| 线程池 | Reactor + 业务线程池 | IOCP + worker 线程池 |
Reactor 里常见的结构是 one event loop per thread:一个连接长期归属一个 loop,尽量把 I/O 状态限制在单线程内,减少锁竞争。
Windows 上,经典结构则是 IOCP + 线程池:线程等待同一个完成端口,收到完成包后处理,然后为连接投递下一次异步读或写。
flowchart TD
I["IOCP 完成端口"] --> W1["Worker 1"]
I --> W2["Worker 2"]
I --> W3["Worker 3"]
W1 --> N["投递下一次异步 I/O"]
W2 --> N
W3 --> N
怎么选
| 场景 | 倾向 |
|---|---|
| Linux 服务端、依赖成熟跨平台生态 | Reactor(epoll) |
| Windows 高并发网络、命名管道或文件 I/O 服务 | Proactor(IOCP) |
| 同时支持 Windows 与 Linux | 用统一异步抽象屏蔽后端差异 |
| 连接少、I/O 低频、同步语义已足够 | 阻塞 I/O + 有界线程池即可 |
| Linux 追求更低系统调用开销或统一异步 I/O | 评估 io_uring,并做真实压测 |
没有绝对更好的模型。真正需要匹配的是:目标平台、连接规模、I/O 类型、团队对生命周期管理的掌握程度,以及你的可观测性与压测结果。
记忆口诀
- Reactor:准备好了,你来取。——等 ready
- Proactor:取好了,给你用。——等 complete