67、Proactor模式:异步完成模型、与Reactor对比、IOCP简介

聊到高性能网络编程,Reactor 和 Proactor 这两个模式是绕不开的。我个人习惯把 Reactor 叫做「同步事件分发器」,而 Proactor 则是「异步完成通知器」。名字听着玄乎,其实核心区别就一句话:谁来做真正的 I/O 操作

Reactor 模式里,你告诉框架「我关心读事件」,当数据可读时,框架通知你,然后你自己调用 read() 去读。Proactor 模式则反过来——你告诉框架「我想读 1024 字节到 buffer 里」,框架直接帮你读完,然后通知你「读完了,这是数据」。说白了,Proactor 把「等待 + 读写」这个苦活全包了。

核心要点: Proactor 模式将 I/O 操作本身也异步化了。应用程序发起操作后立即返回,操作系统在后台完成实际读写,完成后通过回调或事件通知应用。

Proactor 的工作流程

我画个图帮你理解。假设你要从 socket 读 1KB 数据:

Proactor 模式异步读操作流程 应用程序 Proactor 框架 操作系统 ① 发起异步读请求 ② 提交 I/O 请求 ③ 后台执行 I/O ④ 完成通知(回调/事件) ⑤ 处理已就绪的数据 应用发起请求后立即返回,不阻塞。OS 完成后主动通知。

你看,应用层在第①步发出请求后,就可以去干别的事了。OS 在后台默默把数据从内核缓冲区搬到用户提供的 buffer 里,然后通过 Proactor 框架回调你的处理函数。整个过程应用层不需要等待,也不需要自己调用 read()。

与 Reactor 的对比

我在项目中两种模式都用过,说说我的感受。Reactor 更像「你来我就告诉你,你自己动手」;Proactor 则是「你交代的事我办好了,结果给你」。

对比维度 Reactor 模式 Proactor 模式
I/O 操作谁执行 应用层自己调用 read/write 操作系统或框架执行
通知时机 「可以读了/可以写了」 「读完了/写完了」
编程模型 事件驱动,状态机常见 回调驱动,类似 Future/Promise
系统调用次数 每次事件需额外 read/write 一次调用完成全部
适用场景 高并发短连接、自定义协议 大文件传输、高吞吐场景
跨平台难度 容易(select/epoll/kqueue) 较难(依赖 OS 异步 I/O)

举个例子你就明白了。Reactor 模式下,epoll 告诉你 fd 可读,你调用 read() 可能只读到部分数据,还得维护一个 buffer 拼包。Proactor 模式下,你指定「读 4096 字节」,OS 要么读完 4096 字节,要么出错,然后通知你。少了很多「读一半」的麻烦。

我的经验: 如果你做的是 HTTP 服务器这类「短请求、高并发」场景,Reactor 更灵活。如果是文件服务器、数据库引擎这类「大块数据传输」,Proactor 的异步优势就体现出来了。

IOCP 简介

说到 Proactor,Windows 上的 IOCP(I/O Completion Ports)是绕不开的。这玩意儿是 Windows 下最高效的异步 I/O 模型,也是 Proactor 模式的经典实现。

IOCP 的核心思想是:你创建一个完成端口,把 socket 句柄和文件句柄关联上去,然后发起异步读写。当 I/O 操作完成时,系统把结果投递到完成端口,工作线程从端口里取出来处理。

我当年在 Windows 下写一个网关程序,用的就是 IOCP。说实话,刚开始被它的设计绕晕了——什么「重叠 I/O」「完成键」「线程池管理」,概念一堆。但用顺手之后,性能确实好。

一个典型的 IOCP 使用流程:

// 伪代码:IOCP 基本骨架
HANDLE hIocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

// 关联 socket 到完成端口
CreateIoCompletionPort((HANDLE)socket, hIocp, (ULONG_PTR)&context, 0);

// 发起异步读
WSABUF buf = { .buf = buffer, .len = 4096 };
DWORD flags = 0;
WSARecv(socket, &buf, 1, NULL, &flags, &overlapped, NULL);

// 工作线程循环
while (true) {
    DWORD bytesTransferred;
    ULONG_PTR completionKey;
    LPOVERLAPPED pOverlapped;

    BOOL ok = GetQueuedCompletionStatus(
        hIocp, &bytesTransferred,
        &completionKey, &pOverlapped, INFINITE);

    // 处理完成事件
    MyContext* ctx = (MyContext*)completionKey;
    // ... 处理 bytesTransferred 字节数据
}

这段代码看着简单,但实际坑不少。比如 GetQueuedCompletionStatus 返回 FALSE 时,你得区分是 I/O 错误还是连接关闭。我曾经在这上面栽过跟头——没检查 pOverlapped 是否为 NULL,导致野指针崩溃。

避坑指南: 使用 IOCP 时,每个重叠操作分配的 OVERLAPPED 结构体,在操作完成前绝对不能释放。我曾经图省事在栈上分配 OVERLAPPED,结果异步操作还没完成,栈帧已经销毁了……调试了一整天。

Linux 下的异步 I/O 现状

聊完 Windows,说说 Linux。Linux 原生异步 I/O(AIO)一直比较尴尬。早期的 POSIX AIO 用线程模拟,性能一般。后来内核加了 io_uring,这才算有了真正的异步 I/O 支持。

io_uring 的设计思路很有意思——它用两个环形队列(Submission Queue 和 Completion Queue)在用户态和内核态之间传递 I/O 请求和结果。应用把请求扔进 SQ,内核处理完把结果放进 CQ,全程无锁或少锁。

我个人觉得,io_uring 比 IOCP 更优雅。它不限于 socket 和文件,连 accept、connect、甚至 open 都可以异步。不过 io_uring 需要较新的内核(5.1+),生产环境普及度还在爬坡。

// io_uring 异步读示例(简化)
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, &my_context);

io_uring_submit(&ring);

// 稍后获取结果
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
struct my_context *ctx = io_uring_cqe_get_data(cqe);
// cqe->res 就是实际读取的字节数
io_uring_cqe_seen(&ring, cqe);

你看,代码风格和 IOCP 神似——提交请求、干别的事、回来取结果。这就是 Proactor 模式的精髓。

选型建议

说了这么多,到底用哪个?我的建议很简单:

  • Windows 平台:无脑 IOCP。这是 Windows 的亲儿子,性能最好,文档最全。
  • Linux 平台,内核 5.1+:优先考虑 io_uring。如果内核太老,用 epoll + 线程池模拟 Proactor。
  • 跨平台:用 libuv 或 Boost.Asio。它们封装了不同平台的异步 I/O,接口统一。
  • 简单场景:Reactor 模式就够了。别为了用 Proactor 而用,增加复杂度。

嗯,Proactor 模式就聊到这儿。它和 Reactor 没有绝对的优劣,关键看你的场景。我见过有人用 Reactor 做文件服务器,结果 read 调用频繁切换上下文,性能惨不忍睹。也见过有人用 Proactor 做聊天服务器,结果每个连接都要分配大 buffer,内存爆炸。工具没有好坏,用对地方才是本事。


公众号:蓝海资料掘金营,微信deep3321