网络服务要同时处理成千上万个连接时,一个连接配一个线程的做法很快就撑不住了,线程栈的内存、调度和上下文切换的开销都会上来。常见的解法是用少数几个线程统一等待大量连接上的 I/O 事件,Reactor 和 Proactor 是组织这件事的两种经典方式。
两者都是事件驱动、都用回调,容易混在一起。区分它们只需要问一个问题:
应用收到通知的时候,I/O 是“可以做了”,还是“已经做完了”?
前者是 Reactor,通知的意思是“socket 可读了,你自己去读”。后者是 Proactor,通知的意思是“你要的数据已经读到 buffer 里了”。对应到系统 API 上,epoll、kqueue 报告的是就绪,IOCP、io_uring 报告的是完成。
Reactor:等就绪
Reactor 等的是就绪(readiness)事件。socket 可读,说明内核接收缓冲区里有数据了,或者连接关闭、出错了;socket 可写,说明发送缓冲区有空间,可以继续往里写。
收到事件之后,读写还得应用自己来,调 read() / recv() 或者 write() / send()。通知只是告诉你现在去读大概率不会阻塞,数据还在内核里,没进你的 buffer。
整个事件循环就是一个圈:
flowchart TD
W["epoll_wait()"] -->|"fd 就绪"| D["分发给对应回调"]
D --> IO["应用自己 read / write"]
IO --> H["处理数据,更新连接状态"]
H -->|"下一轮"| W
展开其中一次读,能看得更清楚数据是什么时候才到应用手里的:
sequenceDiagram
participant App as 应用
participant EL as 事件循环
participant Kernel as 内核
App->>EL: 注册 fd,关心可读
EL->>Kernel: epoll_wait()
Note over Kernel: 数据到达,留在接收缓冲区
Kernel-->>EL: fd 可读
EL->>App: OnReadable(fd)
loop 直到返回 EAGAIN
App->>Kernel: read(fd, buffer)
Kernel-->>App: 拷贝数据,返回字节数
end
App->>App: 处理数据
收到“可读”时数据还在内核里,要等应用自己 read() 才拷进 buffer。图里的循环是边沿触发下的写法,后面会说。
各平台上的就绪通知接口:
| 平台 | 就绪通知 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 用边沿触发(ET)时更要小心,必须一直读到 EAGAIN 为止,否则剩下的数据不会再触发通知。
Proactor:等完成
Proactor 等的是完成(completion)事件。异步读完成时,数据已经写进了应用事先交给系统的 buffer;异步写完成时,系统会告诉你这次实际写了多少字节、有没有出错。
应用先提交 I/O 请求,系统在后台做完之后把完成事件送回来。回调里直接用数据就行,不用再调一次 read()。
它也是一个圈,只是起点从“等”变成了“提交”:
flowchart TD
S["提交 async_read,交出 buffer"] --> K["系统执行 I/O,数据写入 buffer"]
K --> Q["结果进入完成队列"]
Q --> H["回调直接处理 buffer"]
H -->|"提交下一次"| S
同样展开一次读:
sequenceDiagram
participant App as 应用
participant Proactor as Proactor
participant Kernel as 内核
App->>Proactor: async_read(buffer)
Proactor->>Kernel: 提交读请求
Note over App,Kernel: 完成之前 buffer 归系统使用,应用不能释放或改写
Kernel->>Kernel: 数据写入 buffer
Kernel-->>Proactor: 完成事件:字节数、错误码
Proactor->>App: OnReadComplete(buffer, bytes)
App->>App: 处理 buffer
和 Reactor 那张图比,应用一次 read() 都没调,但多了一个约束:buffer 交出去以后,到完成之前都不归应用管。
完成通知的接口:
| 平台 / 库 | 完成通知 API |
|---|---|
| Windows | OVERLAPPED I/O + IOCP |
| Linux | io_uring(提交 SQE,消费 CQE) |
| POSIX | aio_read / aio_write(实现质量和适用范围有限) |
| C++ | Boost.Asio 的 async_* 系列 |
Boost.Asio 需要多说一句。它对应用暴露的是 Proactor 的语义,handler 在异步操作完成之后才被调用。但在底层,Windows 上它用 IOCP,Linux 上默认用 epoll,在 epoll 之上模拟出完成语义。所以不能因为 Linux 上的 Asio 用了 epoll 就说它是 Reactor。Asio 的文档里专门讲了这个设计。
伪代码:
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 也会遇到只读了一部分的情况。一次异步读可能只读到部分字节,Asio 的 async_read_some 就是这个语义;async_read 是在它上面组合出来的,会反复发起读,直到把 buffer 填满或者出错。
还有一点:网络上的异步写“完成”,一般只是说数据已经交给了本机的协议栈,对端应用收没收到,这个通知说明不了。
放在一起比较
| 维度 | Reactor | Proactor |
|---|---|---|
| 等什么 | 就绪:readable / writable | 完成:read done / write done |
| 谁执行实际 I/O | 应用在回调里调 read/write | 应用先提交,系统异步执行 |
| 回调触发时 | 还要自己做 I/O | 可以直接处理结果和 buffer |
| 基本流程 | 通知 → 自己读写 → 处理 | 提交 → 等完成 → 处理 |
| 麻烦在哪 | 连接状态机、读写缓冲区、partial I/O | 未完成的操作、buffer 与对象生命周期 |
| 常见场景 | Linux 和跨平台的就绪驱动服务 | Windows IOCP,基于完成队列的异步 I/O |
flowchart LR
R1["Reactor:等就绪"] --> R2["通知:可读"] --> R3["应用执行 read"] --> R4["处理"]
P1["Proactor:提交 read"] --> P2["系统执行 read"] --> P3["通知:读完成"] --> P4["处理"]
表里“麻烦在哪”那一行值得展开说一下。Proactor 把读写交给了系统,但提交出去的 buffer 在操作完成之前必须一直有效,关联的连接对象也不能提前销毁。连接关闭时手上还有几个没完成的操作,这些对象什么时候能释放,是写 Proactor 代码时最常出错的地方。
容易搞混的几种情况
用了异步回调也可能是 Reactor
两种模型都可以是非阻塞、事件驱动、回调式的。判断方法很简单:看回调里还要不要为了这次事件自己去做 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) 等结果,调用线程还是被阻塞着,这就是换了个写法的同步 I/O。
典型的 Proactor 用法是把句柄关联到 IOCP,由一组 worker 线程调用 GetQueuedCompletionStatus 去取完成包。
sequenceDiagram
participant App as 应用
participant Kernel as 内核
participant IOCP as 完成端口
participant Worker as Worker 线程
App->>IOCP: CreateIoCompletionPort(socket, key)
App->>Kernel: WSARecv(socket, OVERLAPPED)
Note over App: 立即返回,线程不阻塞
Worker->>IOCP: GetQueuedCompletionStatus()
Note over Worker: 没有完成包时在这里等
Kernel->>IOCP: I/O 完成,投递完成包
IOCP-->>Worker: 字节数 + OVERLAPPED + key
Worker->>Kernel: 处理完,投递下一次 WSARecv
IOCP 的好处也不只是异步。它把大量 I/O 的完成通知汇集到一个队列里,还能控制同时处理完成包的线程数,避免线程比 CPU 核多太多导致频繁切换。细节可以看 微软的 IOCP 文档。
io_uring 更像完成模型,但也不绝对
Linux 上的高并发网络服务传统上都用 epoll。io_uring 用一对环形队列通信:应用往提交队列(SQ)里放请求,内核做完后往完成队列(CQ)里放结果,这种形式天然适合写成 Proactor。
不过 io_uring 也能提交 poll 这类操作,拿到的就是就绪通知,实际的框架也常常两种用法混着来。所以与其纠结某个 API 属于哪一类,不如直接看代码的控制流:它是在等 fd 就绪,还是在等已经提交的操作返回结果。
I/O 模型和线程模型是两回事
Reactor 和 Proactor 都可以跑在一个线程里,也都可以用多个线程。
| 线程策略 | Reactor | Proactor |
|---|---|---|
| 单线程事件循环 | 一个 epoll loop | 一个 io_uring completion loop |
| 多个事件循环 | muduo 的 one loop per thread | 按队列或连接分片 |
| 加线程池 | Reactor + 业务线程池 | IOCP + worker 线程池 |
Reactor 这边常见的是 one loop per thread:每个线程一个事件循环,一个连接从头到尾只归一个 loop 管,连接的状态只在一个线程里改,基本不用加锁。
Windows 上经典的结构是 IOCP 加线程池:一组线程等在同一个完成端口上,谁拿到完成包谁处理,处理完再给这个连接提交下一次读或写。
flowchart TD
A["连接 A"] --> K["系统执行 I/O"]
B["连接 B"] --> K
C["连接 C"] --> K
K --> I["完成端口(共享队列)"]
I --> W1["Worker 1"]
I --> W2["Worker 2"]
W1 -.->|"投递下一次 I/O"| K
W2 -.->|"投递下一次 I/O"| K
这种结构下同一个连接的完成包可能被不同线程处理,连接状态要么加锁,要么保证每个连接同时只有一个未完成的操作。
怎么选
主要看平台。Linux 服务端、要用成熟的跨平台库,选 epoll 上的 Reactor。Windows 上做高并发网络、命名管道或者文件 I/O,选 IOCP。两个平台都要支持,就用 Asio 这类统一的异步抽象,把后端差异藏起来。
如果连接不多、I/O 也不频繁,阻塞 I/O 加一个有上限的线程池就够了,代码简单得多。Linux 上想进一步减少系统调用开销,可以考虑 io_uring,但要拿真实负载压测过再下结论,它的收益跟负载特征关系很大。