三个 Handler 只有一个任务:Netty 事件传播里最精妙的一次 if 判断
给 Netty 的 pipeline 挂上业务线程组之后,我在断点里发现一个反直觉的现象:一条消息依次穿过三个绑同组的 handler,它们竟然跑在同一个调用栈 里,全程只入队了一次任务。源码里没有任何「把多个 handler 打包成一个任务」的代码------这套行为来自
AbstractChannelHandlerContext里一个只有两个分支的 if 判断。本文基于 Netty 4.1.115 源码,把这行判断背后的任务边界设计讲透:它如何用一个原语同时解决了线程切换、性能和顺序保证三件事。
S --- Situation:一个性能直觉与一次断点追踪
场景很常见:IO 线程上不能干重活,于是把业务 handler 挂到业务线程组:
java
EventExecutorGroup bizGroup = new DefaultEventExecutorGroup(16);
p.addLast(bizGroup, "h1", new Handler1());
p.addLast(bizGroup, "h2", new Handler2());
p.addLast(bizGroup, "h3", new Handler3());
当时的直觉推理是:既然 ctx.fireChannelRead(msg) 每次都要「把事件交给下一个 handler」,那这条消息要跨三次队------每次 fire 都要重新入队的话,5 个 handler 就是 5 次任务调度,Netty 还怎么撑高吞吐?(Kafka、Redis 的实践里,队列切换一次的成本可不是小事)
断点跟进去的结论和直觉完全相反 :三个 handler 的 channelRead 在同一个调用栈 里------在 h2 里打断点,调用栈下面压着 h1 的 channelRead 帧;三个栈顶都是同一个 runAllTasks。也就是说,这条消息从头到尾只入队了一次:
T --- Task:本文的目标
- 讲透机制:这次 if 判断的源码逻辑,任务边界到底画在哪里;
- 推演顺序:同组、跨组两种布局下,两个请求的完整执行顺序与线程安全结论;
- 提炼设计:这个「任务边界 = 线程边界」的原语为什么精妙,以及其他系统里同款思想的影子。
A --- Action:一次判断、两种布局、三个误解
一、唯一的一次 if:同线程直调,跨线程打包
全部秘密在 AbstractChannelHandlerContext.java:416-429 的静态方法里(简化后):
java
static void invokeChannelRead(final AbstractChannelHandlerContext next, Object msg) {
EventExecutor executor = next.executor();
if (executor.inEventLoop()) {
next.invokeChannelRead(m); // 下一站就是当前线程:普通方法调用
} else {
executor.execute(new Runnable() { // 下一站是别的线程:打包一个任务
public void run() { next.invokeChannelRead(m); }
});
}
}
每次事件从节点 A 传到节点 B,都要过这道判断,看的是下一个节点绑定的执行器 (executor() 没绑组就返回 channel 的 EventLoop,绑了组就返回该连接挑中的那个固定执行器,AbstractChannelHandlerContext.java:130-136)。关键在任务体:只有一行「从进入点的 handler 开始处理这条消息」------它不认识 h2、h3,也不需要认识。
任务的边界不是画在「一组 handler」上,而是画在「线程切换点」上:想换线程才投任务,不换线程就是普通方法调用。这就是「三个 handler 一个任务」的全部真相------没有任何显式打包代码,纯粹是传播机制的自然结果。
二、同组布局:一次渡河,同栈走完整链
先看清线程组内部的结构------组是执行器的池子,每个执行器 = 一条线程 + 一条独享的先进先出队列,连接挑的是执行器而不是裸线程:
这个结构在源码里可以逐一对号(行号来自 Netty 4.1.115):组的本质是一个执行器数组 ------基类构造里的循环创建 N 个孩子(MultithreadEventExecutorGroup.java:78-82 的 children[i] = newChild(executor, args)),DefaultEventExecutorGroup 的 newChild 返回 DefaultEventExecutor,NioEventLoopGroup 的 newChild 返回带 Selector 的 NioEventLoop------两类组共用同一副骨架,差别只在孩子长什么样。「一条线程 + 一条队列」住在 SingleThreadEventExecutor :字段 private final Queue<Runnable> taskQueue(:77,每个执行器独享一条),线程懒启动------首任务提交时 ThreadPerTaskExecutor.execute 直接 threadFactory.newThread(command).start()(ThreadPerTaskExecutor.java:31-33),这个线程从此专职跑 run 循环消费自己那条队列。轮询分配是原子递增取模:
java
// DefaultEventExecutorChooserFactory.java:53
return executors[idx.getAndIncrement() & executors.length - 1];
值得多问一句:为什么 Netty 不用 JDK 自带的 ThreadPoolExecutor? 结构取向完全不同------原生池是「全局一条队列、任务与线程无绑定、线程只是干活的」,任何任务可能被任何线程执行,同一连接的两个任务会被两条线程同时领走(这正是往共享线程池丢任务会乱序的结构性根因);Netty 的组是「每执行器独享一条队列、来源绑定挑一次终身不变、线程跑完整事件循环」,串行性被放进了结构里而不是靠锁在运行时拼回来。API 层面两者兼容(Netty 执行器也实现了 ExecutorService,还额外声明了 OrderedEventExecutor 有序执行器接口),语义却是重造的:同样的 execute(),原生池是「找个线程跑」,Netty 是「放进这条固定的队列」。同样的设计在 Go 调度器(每 P 一条本地队列)、LMAX Disruptor、Akka 的每 Actor 串行邮箱里都能找到影子。
顺着结构再往下挖一层,看这套东西是怎么搭起来的------三层类结构,每层各管一件事:
几处最值得看的实现:组的构造就三件事 (建 ThreadPerTaskExecutor、循环 newChild 填满 children 数组、按长度造 chooser,MultithreadEventExecutorGroup.java:71-111);线程是懒启动的 ------组建好时只有队列没有线程,第一个任务进来才在 doStartThread() 里被 threadFactory.newThread(command).start() 拉起,线程第一件事记下自己,然后扎进 run() 死循环永不返回(SingleThreadEventExecutor.java:984-998):
java
private void doStartThread() {
executor.execute(new Runnable() { // executor 就是 ThreadPerTaskExecutor
public void run() {
thread = Thread.currentThread(); // 记下自己这条线程
SingleThreadEventExecutor.this.run(); // 死循环从这里开始
}
});
}
同一个骨架、两种循环体 ------业务执行器的 run() 朴素到三行(takeTask() → runTask() → confirmShutdown(),DefaultEventExecutor.java:62-74),NioEventLoop 只是把它覆写成「select 等 IO + 处理就绪事件 + 按时间预算跑任务」,骨架完全复用;优雅关闭是状态机 ------ST_STARTED → ST_SHUTTING_DOWN → ST_SHUTDOWN → ST_TERMINATED 四个状态(SingleThreadEventExecutor.java:59-62)加静默期检查(:621-660):静默期内没有新任务且队列跑空才放行退出,这就是 shutdownGracefully() 的实现出处。
三个 handler 同绑组 A(连接固定用线程 X),请求 R1、R2 先后到达。一个任务的完整一生:
R2 的任务同样从 handler1 开始,排在 R1 之后(IO 线程串行入队)。于是完整顺序必然是:
scss
handler1(R1) → handler2(R1) → handler3(R1) → handler1(R2) → handler2(R2) → handler3(R2)
⚠️ 踩坑一:以为「三个 handler 是三个任务,R2 可能插进 R1 中间」。 不可能------R1 的三个 handler 是一个任务内部的普通方法调用 ,对队列而言是原子的:这个任务没执行完,R2 的任务根本不会被取出。反过来若三个 handler 分别绑了三个不同的组,R1 走到 h2 时 h3 的任务可能还没执行,R2 的 h1 任务就可能先跑------「插队」只在跨组边界处才可能发生。教训:顺序保证的粒度跟着任务边界走,任务边界跟着线程边界走。
三、跨组布局:每跨一个组再渡一次,顺序逐段传递
把 handler3 挪到另一个组 B(线程 Y),一条请求要渡两次河。此时出现流水线重叠:X 跑完 R1 的前两站就立刻开始 R2 的前两站,而 R1 的 handler3 可能还在 Y 的队列里等:
两个层级的顺序保证,结论完全不同:
- 每个节点上有序:天然保证。顺序的传递链 = IO 线程串行入队 + 队列先进先出 + 单消费者,每一段接力都不丢顺序,所以 handler3(R1) 必然先于 handler3(R2);
- 请求之间不重叠:不再保证。想要「R1 完整处理完才开始 R2」,要么三个 handler 挂同一个组(一条队列串到底),要么在协议里带请求编号由对端重组(Kafka producer 的思路)。
⚠️ 踩坑二:保序是有开关的。 「每条连接对同一个组只挑一次线程并缓存」由 ChannelOption.SINGLE_EVENTEXECUTOR_PER_GROUP 控制(默认开启,DefaultChannelPipeline.java:126-148);显式设为 false 会退化为每次轮询挑线程,上面所有顺序结论随之作废。迁移配置时千万别把它关掉。
四、一个原语,三件事全办了
回头看这次 if 判断同时解决了什么:
- 线程切换:跨线程时投任务,语义正确(目标线程的队列保证串行);
- 性能 :同线程时零开销直调------没有队列、没有唤醒、没有锁,5 个 handler 的传播就是 5 层方法调用;出站方向更进一步,write 的打包用了对象池复用的
WriteTask(AbstractChannelHandlerContext.java:963-995),连new Runnable都省了; - 顺序:任务边界 = 串行边界,顺序保证的粒度天然可推理(见踩坑一)。
同款思想在其他系统里都能找到影子:Kafka broker 的 mute(停读)是把「串行边界」画在连接上,Redis 的 IO 线程屏障是把串行边界画在主线程执行前------各家画的直线位置不同,但都是同一条原则:把需要串行的东西圈进同一个执行单位里。
R --- Result:一张对照表与三条原则
三种布局的完整对照(同一条连接、两个先后到达的请求):
| 布局 | 任务边界 | 节点内顺序 | 请求间重叠 | 适用 |
|---|---|---|---|---|
| 不挂组 | 整条 pipeline 在 IO 线程,零投递 | 全程有序 | 无 | handler 都轻快 |
| 挂同一个组 | 进组时投一次,组内同栈 | 全程有序 | 无 | 重业务且请求间有依赖 |
| 挂多个组 | 每跨一个组投一次 | 每个节点仍有序 | 有(流水线重叠) | 分段并行换吞吐 |
复盘沉淀的三条原则
- 任务边界画在线程切换点:想切换线程就投一次任务,不想切换就是普通方法调用------不需要「批量打包」这种额外机制,一个原语统一了正确性、性能与顺序;
- 顺序保证靠传递链:串行入队、先进先出、单消费者,环环相扣才有一切;链条上任何一环被打开(关掉 pin 开关、自己往别的线程池丢任务),顺序就断;
- 分组是吞吐与串行语义的交换旋钮:同组保序不重叠、跨组重叠但节点内仍有序------先想清楚业务要哪一层,再决定怎么挂。
遗留事项
WriteTask的对象池复用与 NettyRecycler的设计(延伸阅读io.netty.util.internal.Recycler);- 事件传播的掩码优化(
ChannelHandlerMask):未覆写的回调如何被跳过; - 把本文结论移植到 Dubbo / gRPC 的线程模型分析上验证通用性。
本文全部素材来自 Netty 4.1.115.Final 公开源码,所有行号均经逐一核对,不涉及任何内部系统信息。