本节摘要:Handler 的线程安全性取决于它有没有"跨回调的可变状态"以及被几个线程调用。本节给出状态安放的三层选择(无状态、Channel 级、全局级)、@Sharable 的判定纪律,以及业务线程池整合时保序的标准姿势。
写 Handler 前先问一句:不同回调之间要不要记东西?答案决定了全部并发策略:
// 形态一:纯无状态 —— 天然线程安全,可加 Sharable 注解做全局单例 @ChannelHandler.Sharable public class MetricsHandler extends ChannelInboundHandlerAdapter { private final LongAdder inBytes = new LongAdder(); // 只增不减的统计量,本身并发安全 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (msg instanceof ByteBuf b) inBytes.addLong(b.readableBytes()); ctx.fireChannelRead(msg); } } // 形态二:连接级状态 —— 放成员变量,但绝不加 Sharable(每连接 new 一个) public class LoginHandler extends ChannelInboundHandlerAdapter { private int retryTimes = 0; // 只属于这一条连接 @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (!checkOk(msg) && ++retryTimes >= 3) ctx.close(); else ctx.fireChannelRead(msg); } }
@ChannelHandler.Sharable 的判定纪律一句话:只要存在非线程安全的成员变量,就绝不能共享。第三章说过每个连接会各装配一次 Pipeline,非共享 Handler 的成员变量天然是"连接私有"的;一旦加了 Sharable,同一成员被几十个线程读写,重则数据错乱、轻则概率性诡异 bug——这类问题的排查成本远高于省下的那点内存。
| 状态类型 | 安放位置 | 并发前提 | 典型例子 |
|---|---|---|---|
| 处理逻辑本身无状态 | Handler 成员全 final | 可 @Sharable | 编解码、指标统计 |
| 单连接会话状态 | 非 Sharable Handler 成员 / Channel attr | 单线程访问,免锁 | 登录重试计数、半包暂存 |
| 跨连接共享状态 | 外部 ConcurrentHashMap / 数据库 | 自行保证并发安全 | 在线用户表、全局限流器 |
第二层的细节:Channel 的 attr(3.1 讲过的属性袋)与非 Sharable Handler 成员都可以放会话状态,取舍是生命周期——attr 跟着 Channel 走,Handler 成员跟着工序走(流水线热插拔后,老 Handler 的成员就丢了)。会话 ID、鉴权结果这种"贯穿全程"的放 attr;握手中间态这种"工序局部"的放 Handler 成员。
第三层的标准写法:
// 全局在线表:跨线程共享,必须用并发容器 public class SessionRegistry { private static final ConcurrentHashMap<String, Channel> ONLINE = new ConcurrentHashMap<>(); public static void login(String userId, Channel ch) { Channel old = ONLINE.put(userId, ch); if (old != null && old != ch) old.close(); // 踢旧会话 ch.closeFuture().addListener(f -> ONLINE.remove(userId, ch)); // 断开自动清 } public static Channel find(String userId) { return ONLINE.get(userId); } }
注意 remove(userId, ch) 的双参版本:防止新会话顶号后,旧连接关闭回调又误删新会话——两值删除是并发容器的标准防误删姿势。
Handler 里直接查库、调下游是卡死流水线的头号原因。标准解法是业务线程池 + 回写,但有个隐蔽要求:同一条连接的消息顺序不能乱。把两条消息扔进普通线程池并行跑,先发的可能后到。保证顺序有两种姿势:
// 姿势一:串行执行器——同连接的任务排队,不同连接并行(推荐,够用且简单) public class OrderedBizHandler extends SimpleChannelInboundHandler<MyMessage> { private static final Map<Channel, Executor> EXECUTORS = new ConcurrentHashMap<>(); @Override protected void channelRead0(ChannelHandlerContext ctx, MyMessage msg) { Executor ex = EXECUTORS.computeIfAbsent(ctx.channel(), c -> Executors.newSingleThreadExecutor()); // 每连接一个单线程队列 ex.execute(() -> { String result = slowRpcCall(msg); // 慢活在业务线程 ctx.writeAndFlush(result); // write 自动归一回 IO 线程 }); } @Override public void channelInactive(ChannelHandlerContext ctx) { Executor ex = EXECUTORS.remove(ctx.channel()); if (ex instanceof ExecutorService es) es.shutdown(); // 连接断,回收线程 } }
// 姿势二:无序并行 + 应用层序号重排——吞吐优先,实现复杂 // 响应带请求序号,发送前缓存乱序结果,缺口补齐后按序 flush。 // 只有在单连接消息互相独立且客户端能接受乱序时才考虑。
绝大多数业务用姿势一即可:每连接一个单线程队列,天然保序,线程数与在线连接数同量级(空闲线程很轻)。真正的海量连接场景,改用哈希取模的有界线程组:executors[Math.abs(chid % N)],连接间均匀分摊又不至于线程爆炸。
⚠️ 常见坑:业务线程里抛了未捕获异常——线程池把它吞掉,日志可能只有一行晦涩的告警,连接却卡在"等回包"。业务任务的 try-catch 要兜底并回写错误响应或关闭连接,别让异常无声无息。
Java 惯用的 ThreadLocal 在 Netty 里有个陷阱:同一个 EventLoop 线程服务多条 Channel,你 set 的"请求上下文"会被下一条 Channel 的请求覆盖——ThreadLocal 的边界是线程,而 Netty 的并发边界是 Channel。两种正确做法:
// 做法一:显式传参 / attr,不依赖线程(首选) ctx.channel().attr(REQ_CTX).set(requestContext); // 做法二:确实需要 ThreadLocal 语义(如埋点 traceId),用完立刻清理 FastThreadLocal<String> traceId = new FastThreadLocal<>(); traceId.set("t-123"); try { handle(msg); } finally { traceId.remove(); } // 不 remove 必串号
Netty 自家的 FastThreadLocal 用数组下标代替哈希探查,速度快一截,但语义坑与普通 ThreadLocal 相同:set 与 remove 必须成对,EventLoop 线程是复用的。
💡 关键直觉:并发问题先问两个"谁":这段状态属于谁(Channel 还是全局)?这段代码跑在谁的线程上(EventLoop 还是业务池)?两个问题都有答案,方案就自然浮出来。