4、Reactor与Proactor模式:Reactor模式详解(单线程/多线程/主从)、Proactor模式与异步I/O、我如何选择模式
聊到高并发网络编程,有两个模式是绕不开的:Reactor 和 Proactor。说白了,它们就是解决「如何高效处理海量连接」这个问题的两种思路。
我刚开始做网络编程时,总觉得能写个 select/poll 循环就挺厉害了。后来线上一个服务连接数刚过千就卡死,我才意识到——嗯,模式选错了,后面全是坑。
Reactor 模式:事件驱动的核心思想
Reactor 模式,你可以把它理解成一个「事件分发中心」。它不主动干活,而是等着 I/O 事件发生,然后通知对应的处理器去处理。
核心流程就三步:
- 注册感兴趣的事件(读、写、连接等)到 Reactor
- Reactor 循环调用多路复用器(select/poll/epoll)等待事件
- 事件到来,Reactor 分发给对应的 Handler
一句话总结:Reactor 是「来了活再干」,不是「轮询问有没有活」。
我习惯把 Reactor 比作一个餐厅的前台。客人(连接)来了,前台(Reactor)登记一下,然后喊服务员(Handler)去接待。前台不负责端菜,只负责喊人。
单线程 Reactor:简单但有限制
所有事情都在一个线程里完成:事件监听、事件分发、业务处理。你想想看,这就像前台、服务员、厨师全是一个人。
// 伪代码示意
while (true) {
events = selector.select(); // 阻塞等待事件
for (event : events) {
handler = getHandler(event);
handler.process(event); // 同步处理
}
}
优点很明显:没有锁,没有上下文切换,代码简单到极致。
缺点也很致命:任何一个 Handler 阻塞了,整个服务就停了。我在项目中遇到过,一个慢查询把整个网关拖垮,就是因为用了单线程 Reactor,一个请求卡住,所有连接都等死。
避坑指南:我曾经在一个 IoT 网关项目里用了单线程 Reactor,设备连接数 500 以内时一切正常。后来客户接入量涨到 2000,CPU 占用率不到 30%,但响应时间从 2ms 飙升到 5s。查了半天,发现是某个设备的握手协议实现有问题,每次握手要等 3 秒超时。单线程下,一个慢设备拖死所有正常设备。
适用场景:连接数少(几百以内)、业务处理极快(纯内存计算)、没有阻塞操作。比如 Redis 的早期版本就是单线程 Reactor,但它所有操作都是内存级的,所以扛得住。
多线程 Reactor:把业务处理扔出去
核心思路:Reactor 线程只负责事件监听和分发,业务处理交给工作线程池。
// 伪代码示意
while (true) {
events = selector.select();
for (event : events) {
handler = getHandler(event);
threadPool.execute(() -> handler.process(event)); // 异步处理
}
}
这样一来,Reactor 线程永远不会被阻塞。它只管「喊人」,不管「干活」。
我个人的习惯是,工作线程数设置为 CPU 核心数的 2 倍左右。为什么?因为网络 I/O 处理中,很多操作是短暂的 CPU 计算,线程太多反而增加上下文切换开销。
| 对比项 | 单线程 Reactor | 多线程 Reactor |
|---|---|---|
| 事件监听 | 主线程 | 主线程 |
| 业务处理 | 主线程 | 工作线程池 |
| 阻塞风险 | 高 | 低 |
| 编码复杂度 | 低 | 中 |
| 适用连接数 | < 1000 | < 10000 |
小技巧:多线程 Reactor 里,Reactor 线程和业务线程之间用无锁队列传递任务。我一般用 disruptor 或者 Java 的 LinkedBlockingQueue,注意队列不能无界,否则内存会爆。
主从 Reactor:多路复用器的终极形态
当连接数到了几万甚至几十万,一个 Reactor 线程处理事件分发也会成为瓶颈。为什么?因为 select/epoll 的返回、事件的分发、新连接的 accept,全挤在一个线程里。
主从 Reactor 的思路是:
- 主 Reactor(Main Reactor):只负责 accept 新连接,然后把连接注册到从 Reactor
- 从 Reactor(Sub Reactor):每个从 Reactor 有自己的 selector,负责已连接 socket 的读写事件
你想想看,这就像机场的值机柜台。主 Reactor 是引导员,只负责把乘客分流到不同的柜台;从 Reactor 是柜台工作人员,负责具体办理业务。
// 主 Reactor 伪代码
class MainReactor {
ServerSocketChannel serverSocket;
SubReactor[] subReactors;
void run() {
while (true) {
SocketChannel client = serverSocket.accept();
SubReactor sub = subReactors[nextIndex]; // 轮询选择
sub.register(client); // 注册到从 Reactor
}
}
}
// 从 Reactor 伪代码
class SubReactor {
Selector selector;
void run() {
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
dispatch(key); // 处理读写事件
}
}
}
}
我在 Netty 的源码里看到过这种实现。Netty 的 EventLoopGroup 本质上就是主从 Reactor——BossGroup 是主 Reactor,WorkerGroup 是从 Reactor。
关键点:主 Reactor 的 accept 操作本身是轻量的,但如果不分离,当大量连接同时涌入时,accept 会阻塞 selector 的 select 调用,导致已有连接的读写事件被延迟处理。
适用场景:连接数万级以上、连接频繁建立和断开、需要高吞吐量的场景。比如即时通讯服务器、游戏网关、API 网关。
Proactor 模式与异步 I/O
Reactor 模式有一个本质特点:它是「同步非阻塞」的。什么意思?就是事件来了,你还是要自己去读数据、写数据。虽然不阻塞,但读写的操作还是由你的线程完成。
Proactor 模式则更进一步:它是「异步」的。你告诉操作系统:「帮我读 1024 字节,读完了放这个缓冲区里,然后通知我。」操作系统真的帮你把数据读好了,你直接拿来用就行。
说白了,Reactor 是「我来通知你,你自己去拿」;Proactor 是「我帮你拿好,通知你来取」。
| 对比项 | Reactor | Proactor |
|---|---|---|
| I/O 操作 | 应用程序自己读写 | 操作系统完成读写 |
| 通知方式 | 事件就绪通知 | 操作完成通知 |
| 系统调用 | select/epoll(就绪通知) | IOCP(完成通知) |
| 编程模型 | 同步非阻塞 | 异步 |
| 典型实现 | Netty(Linux epoll) | Windows IOCP、Boost.Asio |
Proactor 模式在 Windows 上表现很好,因为 Windows 的 IOCP(I/O Completion Port)是原生支持的。但在 Linux 上,异步 I/O(AIO)的实现一直不太成熟,性能甚至不如 epoll 的 Reactor 模式。
避坑指南:我曾经在一个跨平台项目中尝试用 Linux AIO 实现 Proactor,结果发现:
- 文件 AIO 还行,但 socket AIO 在 2.6 内核上 bug 很多
- 即使到了 4.x 内核,AIO 的上下文切换开销也比 epoll 大
- 很多 Linux 发行版默认不开启 AIO 支持
最后老老实实切回 epoll 的 Reactor 模式,性能反而提升了 30%。
我如何选择模式
这个问题没有标准答案,但我可以分享我的选择逻辑:
- 看操作系统:Windows 优先考虑 Proactor(IOCP),Linux 优先考虑 Reactor(epoll)
- 看连接数:几千以内用多线程 Reactor,几万以上用主从 Reactor
- 看业务复杂度:纯计算型用单线程 Reactor 就够了,有阻塞操作必须用多线程或主从
- 看团队能力:Proactor 的编程模型更复杂,调试难度更大。如果团队对异步编程不熟,Reactor 更稳妥
我的个人习惯:99% 的场景我用主从 Reactor。为什么?因为它在 Linux 上性能足够好,代码可读性强,出了问题好排查。Proactor 我只在 Windows 平台、且对延迟有极致要求时才用。
下面这张图可以帮你快速理解三种 Reactor 和 Proactor 的核心区别:
最后说一句:不要为了用模式而用模式。我见过有人用主从 Reactor 写了个只处理 100 个连接的服务,纯属杀鸡用牛刀。选模式之前,先搞清楚你的瓶颈在哪——是连接数?是 CPU?还是 I/O?
嗯,模式选对了,后面的事就顺了。