Skip to content
Go back

Reactor 与 Proactor:从 I/O 就绪到 I/O 完成

应用收到通知的时候,I/O 是“可以做了”,还是“已经做完了”?

contents 6 sections
  1. 1Reactor:等就绪
  2. 2Proactor:等完成
  3. 3放在一起比较
  4. 4容易搞混的几种情况
  5. 4.1用了异步回调也可能是 Reactor
  6. 4.2用了 Overlapped I/O 不一定就是 Proactor
  7. 4.3io_uring 更像完成模型,但也不绝对
  8. 5I/O 模型和线程模型是两回事
  9. 6怎么选

网络服务要同时处理成千上万个连接时,一个连接配一个线程的做法很快就撑不住了,线程栈的内存、调度和上下文切换的开销都会上来。常见的解法是用少数几个线程统一等待大量连接上的 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 / Linuxselect / poll / epoll
BSD / macOSkqueue
Windowsselect / WSAPoll / WSAEventSelect
JavaSelector(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
WindowsOVERLAPPED I/O + IOCP
Linuxio_uring(提交 SQE,消费 CQE)
POSIXaio_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 填满或者出错。

还有一点:网络上的异步写“完成”,一般只是说数据已经交给了本机的协议栈,对端应用收没收到,这个通知说明不了。

放在一起比较

维度ReactorProactor
等什么就绪: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 都可以跑在一个线程里,也都可以用多个线程。

线程策略ReactorProactor
单线程事件循环一个 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,但要拿真实负载压测过再下结论,它的收益跟负载特征关系很大。



← Previous Post
Windows 访问控制:打开一个对象时系统在检查什么
Next Post →
RAII:为什么资源应该绑定对象生命周期