图 1:本文封面,Boss 与 Worker 两级事件循环的分工一目了然。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Netty 线程模型」这道题的标准答案是四个字——主从 Reactor,但面试官真正想听的是:这四个字母落地到代码里是哪几个类,连接在它们之间怎么流转,以及为什么这套结构能撑住十万级连接。IO 模型的谱系(阻塞、多路复用、异步)此前已经专门讲过,本文只落到 Netty 框架本身。
Reactor 在 Netty 里的肉身:EventLoopGroup 与 EventLoop。
Reactor 模式的核心是「少量线程看护海量连接」:一个线程通过多路复用等待很多连接上的事件,事件到了再分发处理。Netty 把这个角色实现为 EventLoop——一个绑死单线程的无限循环,每轮做两件事:用 Selector 查询注册在自己名下的所有 Channel 有没有就绪的 IO 事件,有就逐个处理;然后跑一会儿任务队列里的普通任务。一组 EventLoop 构成 EventLoopGroup。Netty 官方文档对 NioEventLoopGroup 的定义就是「处理 IO 操作的多线程事件循环」。
一条关键的绑定规则是:一个 Channel 一生只注册到一个 EventLoop。于是同一条连接上的所有事件都在同一个线程上串行执行,你的业务 handler 里不需要任何锁——这是 Netty 高性能的一半来源,另一半是多路复用本身。
主从两级:Boss 管接客,Worker 管服务。
经典服务端代码里总有两个 EventLoopGroup,官方文档给它们起了俗名。第一个叫 Boss,只负责 accept:它的 EventLoop 死循环里监听的唯一事件是 OP_ACCEPT,接到一条新连接后,把这条 NioSocketChannel 注册给 Worker 组里的某个 EventLoop,然后立刻回去等下一个连接。第二个叫 Worker,负责所有已建立连接的读写:OP_READ 来了把字节读进 ByteBuf、驱动 ChannelPipeline,写数据时 flush 到对端。
为什么要拆两级?因为 accept 的吞吐特征和读写完全不同:accept 事件稀疏而关键,一个线程(甚至一个 Boss EventLoop)就够,把它和繁重的读写 IO 混在一起,高负载时新连接会排队超时。主从分离让「接新客」和「服务老客」互不拖累。Worker 组不传线程数时,默认按 CPU 核数的两倍建 EventLoop(Netty 源码里的 DEFAULT_EVENT_LOOP_THREADS,可以用 io.netty.eventLoopThreads 系统属性覆盖)。
ChannelPipeline:每条连接一条处理流水线。
数据进了 Worker 之后怎么走?每条 Channel 挂一条 ChannelPipeline,上面按顺序串着一组 handler。入站方向从解码器流向业务 handler:先由 ByteToMessageDecoder 这类解码器处理粘包拆包、把字节流转成消息对象,再交给你写的业务逻辑;出站方向反过来,业务对象经编码器变回字节写出去。协议处理被拆成一个个可复用的小 handler,是 Netty「好维护」口碑的来源。因为同一 Channel 的事件串行,这条流水线上的所有 handler 默认都是单线程上下文,共享状态可以安全地放在 handler 成员变量里。
铁律:别阻塞 IO 线程。
主从模型有一个致命的前提:EventLoop 上只准跑快逻辑。一个 EventLoop 名下可能挂着几千条连接,只要你的 handler 里出现一次耗时操作——查库、同步 HTTP 调用、大循环计算——这个 Loop 名下所有连接的事件全部排队等它。用 Node.js 22.15.0 的单线程事件循环可以直观复现这个效应(node eventloop-block.js):
function heartbeat(name) {
setInterval(() => console.log(`${name} 心跳 @${(Date.now() / 1000).toFixed(1)}s`), 200);
}
heartbeat('连接A');
heartbeat('连接B');
setTimeout(() => {
console.log('--- 连接A 开始执行耗时业务(CPU 密集,约 1 秒)---');
const end = Date.now() + 1000;
while (Date.now() < end) { /* 忙等,阻塞事件循环 */ }
console.log('--- 连接A 耗时业务结束,心跳恢复 ---');
setTimeout(() => process.exit(0), 500);
}, 300);
实测输出里,两条连接的心跳原本每 200ms 交替出现,耗时任务开始的那一秒双双消失,任务结束后才一起恢复:
连接A 心跳 @1789461716.5s
连接B 心跳 @1789461716.5s
--- 连接A 开始执行耗时业务(CPU 密集,约 1 秒)---
--- 连接A 耗时业务结束,心跳恢复 ---
连接A 心跳 @1789461717.6s
连接B 心跳 @1789461717.6s
注意连接 B 什么都没做错,也被卡了一秒——这就是「阻塞一个 Loop,拖累全部连接」。在 Netty 里的正确做法是:handler 收到消息后把耗时业务丢给独立的业务线程池,EventLoop 立刻返回,业务算完再通过 channel.writeAndFlush 异步回写(写操作会被 Netty 投递回该 Channel 绑定的 Loop 上执行,串行性依然成立)。
图 2:连接从客户端到 Pipeline 的完整流转,以及两个高并发来源。
为什么这套模型能撑住高并发。
把零件拼起来算一笔账:万条连接摊到两倍核数的 EventLoop 上,每个 Loop 看护几百上千条连接,靠多路复用一轮 select 就知道谁有数据,不需要为每条连接配一个线程,省下了线程栈内存和上下文切换;同 Channel 串行又免去了 handler 内的锁竞争。推理网关、长连接推送这类「连接多、单连接流量不大」的场景正是它的主场——前提是每个参与者都遵守「IO 线程只做 IO」的纪律。
回到面试现场,建议的答题结构:先一句话定位「Netty 是主从 Reactor 多线程模型」,再依次讲 Boss 只管 accept、Worker 管读写、Channel 一生绑定一个 EventLoop 的串行无锁设计,最后落到 Pipeline 的 handler 链与「别阻塞 IO 线程」这条铁律及业务线程池方案。能画出连接流转图是及格线,讲清串行无锁与阻塞传染的因果关系是加分项。你在项目里遇到过 EventLoop 被业务代码卡住的故障吗?当时是怎么发现元凶的,欢迎聊聊。