深蓝黑背景封面。左侧原创示意图:三个客户端连接节点从左侧汇入中间的 Boss 组方框(标注「1 个 EventLoop,只管 accept」),再经箭头指向右侧的 Worker 组大方框,框内纵向排列三个 EventLoop 芯片(标注「读写 + 管线」),表示 Boss 接收连接后注册给 Worker 的事件循环处理读写。底部一行口诀。右侧大标题「Netty 线程模型 · 主从 Reactor 拆解」,副标题「Boss 收连接,Worker 跑读写」;左上标注「面试经典 · 系统设计」,右上圆角胶囊标注「Day 03 · 11-10」 图 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 上执行,串行性依然成立)。

深蓝黑背景概念图。主体是一条从左到右的连接流转流水线:最左客户端卡画出大量并发连接节点;箭头指向 Boss EventLoopGroup 卡(一个 NioEventLoop 死循环,职责只有 OP_ACCEPT 接收连接,产物是把 NioSocketChannel 注册给某个 Worker);箭头再指向 Worker EventLoopGroup 卡(线程数默认约为 CPU 核数的两倍,卡内画出两个 EventLoop 芯片,各自管理若干 Channel 的 OP_READ 与 OP_WRITE,并运行 ChannelPipeline,一个 Channel 只绑定一个事件循环);最后指向 ChannelPipeline 卡(每条连接一条管线,Inbound 方向是解码器到业务 handler,Outbound 方向是业务写回到编码器,同一 Channel 的事件在绑定的 Loop 上串行执行,handler 内不用加锁)。下方左侧卡片写铁律:别阻塞 IO 线程,EventLoop 里跑耗时业务会卡住它名下所有连接,耗时逻辑应交给业务线程池并异步回写;下方右侧卡片解释高并发来源:I/O 多路复用让少量线程管理海量连接,串行无锁免去 handler 内的同步开销。底部总结条:Boss 只负责接客,Worker 负责服务,IO 线程只做 IO 图 2:连接从客户端到 Pipeline 的完整流转,以及两个高并发来源。

为什么这套模型能撑住高并发。

把零件拼起来算一笔账:万条连接摊到两倍核数的 EventLoop 上,每个 Loop 看护几百上千条连接,靠多路复用一轮 select 就知道谁有数据,不需要为每条连接配一个线程,省下了线程栈内存和上下文切换;同 Channel 串行又免去了 handler 内的锁竞争。推理网关、长连接推送这类「连接多、单连接流量不大」的场景正是它的主场——前提是每个参与者都遵守「IO 线程只做 IO」的纪律。

回到面试现场,建议的答题结构:先一句话定位「Netty 是主从 Reactor 多线程模型」,再依次讲 Boss 只管 accept、Worker 管读写、Channel 一生绑定一个 EventLoop 的串行无锁设计,最后落到 Pipeline 的 handler 链与「别阻塞 IO 线程」这条铁律及业务线程池方案。能画出连接流转图是及格线,讲清串行无锁与阻塞传染的因果关系是加分项。你在项目里遇到过 EventLoop 被业务代码卡住的故障吗?当时是怎么发现元凶的,欢迎聊聊。