Netty 的 Reactor 模式:单机 10 万连接是怎么做到的
一句话结论(30s)
Netty 的 Reactor 本质是用少量线程通过 epoll 同时管理数万连接的事件,把「一连接一线程」的阻塞模型换成「事件就绪才处理」的事件驱动模型,因为 BIO 下 1 万个连接要 1 万个线程,线程栈会压爆内存、上下文切换挤占 CPU。最关键的设计是主从 Reactor 的三层解耦:BossGroup 只 accept 新连接、WorkerGroup 轮询已连接通道的 READ/WRITE 事件、业务线程池执行耗时逻辑,任一层阻塞不拖垮其他层。核心权衡是:用更复杂的异步编程模型(Pipeline 双向责任链 + 事件回调)换取了单机 10 万连接的能力。
核心原理(2min)
主从 Reactor 分工:BossGroup(MainReactor,通常 1 个线程)监听 ServerSocketChannel 的 ACCEPT 事件,新连接建立后把 SocketChannel 注册到 WorkerGroup;WorkerGroup(SubReactor,CPU 核数个线程)轮询已注册通道的 READ/WRITE 事件,数据到达就读数据并提交业务线程池;业务线程池执行耗时逻辑(DB、RPC)后写回响应。每个 Channel 有独立 Pipeline,事件沿双向责任链传播——入站从 Head 到 Tail(Decoder → 业务 Handler),出站从 Tail 到 Head(Encoder),解码器把字节流转业务对象、编码器把响应对象转字节流。WorkerGroup 默认用 ET 模式,NioByteUnsafe.read() 内部已封装循环读直到 EAGAIN(默认最多 16 次防饿死其他 Channel)。
底层深入(5-10min)
从一连接一线程到事件驱动
BIO 模型:每个连接一个线程 → 1 万个连接 = 1 万个线程 → 线程栈压爆内存 → 上下文切换挤压 CPU。线程大部分时间在 read() 阻塞,真正干活的 CPU 时间不到 20%。
Reactor 模式的核心:用一个线程(通过 epoll)同时管理数万个连接的事件,只有 IO 就绪的连接才被处理,不阻塞等待。
思考:为什么「一连接一线程」撑不住?——因为线程是稀缺资源,且大部分时间在空等。1 万个连接 = 1 万个线程,每个线程栈默认 1MB 就是 10GB 内存,而线程真正
read()拿到数据干活的时间不到 20%,其余 80% 都在阻塞空耗。Reactor 的思路就是:让少数线程只干「有事件」的连接,把空等从线程身上拿掉。
Netty 的主从 Reactor
┌─────────────────────────────────────────────┐
│ BossGroup (MainReactor — 通常 1 个线程) │
│ - 监听 ServerSocketChannel 的 ACCEPT 事件 │
│ - 新连接建立 → 注册到 WorkerGroup │
└─────────────┬───────────────────────────────┘
│ 分配新连接的 SocketChannel
▼
┌─────────────────────────────────────────────┐
│ WorkerGroup (SubReactor — CPU 核数个线程) │
│ - 轮询已注册 SocketChannel 的 READ/WRITE 事件│
│ - 数据到达 → 读数据 → 提交到业务线程池 │
└─────────────┬───────────────────────────────┘
│ 提交读写事件
▼
┌─────────────────────────────────────────────┐
│ 业务线程池 (可配置) │
│ - 执行耗时业务逻辑(DB查询、RPC调用) │
│ - 完成后写回响应 │
└─────────────────────────────────────────────┘
三层解耦:IO 接收(BossGroup)→ IO 读写(WorkerGroup)→ 业务处理(业务线程池)。任一层的阻塞不影响其他层。
思考:为什么要拆成 BossGroup / WorkerGroup / 业务线程池三层?——因为三类活的性质不同:accept 是「低频短连接建立」,IO 读写是「高频但轻量」,业务逻辑是「低频但重量」。混在一起,一个慢查询就会卡住整个 accept 或 IO 循环。分层让任一层阻塞(比如业务线程池打满)不拖垮其他层,这是典型的「隔离」设计。
Netty 的 Pipeline:双向责任链
入站 (Inbound): Head → Decoder → Handler → Tail
↑ 从 Head 向 Tail 传播
出站 (Outbound): Head ← Encoder ← Handler ← Tail
↑ 从 Tail 向 Head 传播
每个 Channel 有独立的 Pipeline。Handler 之间用 ChannelHandlerContext.fireChannelRead(msg) 传递事件。解码器(ByteToMessageDecoder)将字节流转为业务对象 → 业务 Handler 处理业务逻辑 → 编码器(MessageToByteEncoder)将响应对象转为字节流 → 写回客户端。
思考:为什么 Pipeline 要设计成「双向」责任链,入站出站方向还相反?——因为数据在进和出时经历的「加工」是镜像的:入站要把字节流解码成业务对象(Decoder),出站要把业务对象编码成字节流(Encoder)。一个 Handler 既能处理入站又能拦截出站,方向相反是为了让「解码 → 业务 → 编码」这条链路首尾对应,复用同一套处理器。
Netty 为什么默认 ET 模式
Netty NioByteUnsafe.read() 内部已经处理了 ET 模式的复杂逻辑:
- 循环读直到
EAGAIN(缓冲区空) continueReading()判断是否继续(本次读满缓冲 + 未达最大读取次数)- 默认最多循环读 16 次(防单个 Channel 饿死其他 Channel)
Netty 在框架层封装了 ET 的复杂性,用户写 Handler 时无需关心 LT/ET 差异。选择 ET 让 epoll_wait 调用频率大幅减少(减少系统调用),吞吐更高。
总结
| 组件 | 职责 | 线程数 |
|---|---|---|
| BossGroup | accept 新连接 | 1(端口少时) |
| WorkerGroup | 处理已建立连接的 IO 事件 | CPU 核数 |
| 业务线程池 | 执行业务逻辑 | 可配置 |
章末提问
追问 1:为什么 BIO 模型撑不住 10 万连接?
回答思路:结论先行——因为「一连接一线程」会同时压爆内存和 CPU。因为每个连接独占一个线程,10 万个连接就要 10 万个线程,仅线程栈(默认 1MB)就吃掉约 10GB 内存;而且线程大部分时间阻塞在 read(),频繁的上下文切换挤占 CPU。Reactor 用少量线程通过 epoll 管理数万连接,只有就绪的连接才被处理。
追问 2:主从 Reactor 的三层是怎么分工的?为什么要拆开?
回答思路:结论先行——BossGroup 只 accept、WorkerGroup 管 IO 读写、业务线程池跑耗时逻辑,三层职责隔离。因为 accept、IO 读写、业务逻辑的性质不同(低频短连接 / 高频轻量 / 低频重量),拆开才能让任一层阻塞(如业务线程池打满)不拖垮其他层;否则一个慢查询就能卡住整个事件循环。
追问 3:Netty 的 Pipeline 是什么?入站和出站的事件流为什么方向相反?
回答思路:结论先行——Pipeline 是每个 Channel 独立的双向责任链,入站从 Head 到 Tail、出站从 Tail 到 Head。因为数据进出时经历的加工是镜像关系:入站要 Decoder 把字节流解码成业务对象、出站要 Encoder 把响应对象编码成字节流,方向相反才能让「解码 → 业务 → 编码」这条链路首尾对应,复用同一套 Handler。