本节目标:判断一个 handler 能不能被多条连接共享,以及不能共享时把每连接状态放到哪里。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-common模块)。
承接的问题
4.2 的 HandshakeHandler 每条连接一个实例,state 字段只被当前连接的 EventLoop 访问,不需要锁。连接数上来之后,有人会问:每连接 new 一个 handler 是不是浪费?能不能只创建一个 HandshakeHandler,标上 @Sharable,让所有连接共用?
试过的结果是:连接 A 发 HELLO 之后,连接 B 没握手直接发 REQUEST 也被放行。原因是 state 是实例字段,两条连接拿到的是同一个对象、同一个字段。更隐蔽的情况是两条连接都完成了握手,但 REJECTED 状态被其中一条连接写进去,另一条正常连接随后被拒绝。
这不是 @Sharable 失效,而是用错了对象。本节回答四个问题:什么算无状态,@Sharable 在添加时到底检查什么,每连接状态应该放在哪里,DefaultEventExecutorGroup 能不能把有状态 handler 变成可共享。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容来自这台机器,单次运行,未做统计。
@Sharable 的适用条件
@ChannelHandler.Sharable 是 ChannelHandler 的嵌套注解,标在 handler 类上。它表达的是一个承诺:这个实例被多条连接同时使用时,行为仍然正确。
Netty 只在「添加」时做一件事:如果同一个 handler 实例已经存在于另一条 pipeline,而类上没有 @Sharable,就抛异常。本机输出(类名随实际类变化):
io.netty.channel.ChannelPipelineException: com.plume.linkd.v2.server.StatefulHandler is not a @Sharable handler, so can't be added or removed multiple times.
这条检查只看注解,不看字段。把 HandshakeHandler 标上 @Sharable,异常立刻消失,但 state 仍然被所有连接共用。注解是许可证,不是正确性证明。
可以共享的 handler 通常满足下面至少一条:
- 没有实例字段,所有行为只依赖方法参数。
- 只有
final的不可变配置,例如超时时间、路径前缀、固定的AttributeKey。 - 字段本身线程安全,并且语义上属于进程而不是连接,例如
ConnectionRegistry内部的ConcurrentHashMap、ChannelGroup、日志对象。
不能共享的典型字段是「随连接事件变化」的:握手状态、已读字节数、临时缓冲、解析到一半的 StringBuilder、当前用户 ID。这些字段即使加了 volatile 也不能共享,因为问题首先是连接之间的语义串扰,其次才是数据竞争。
无状态与有状态的判别
逐个字段问一句:「这个值会不会因为某条连接的事件而改变?」会,就不能放在共享 handler 的实例字段里。
| 字段 | 可共享 | 原因 |
|---|---|---|
private final ConnectionRegistry registry | 是 | 容器自身并发安全,handler 只读引用 |
private final AttributeKey<HandshakeHandler.State> stateKey | 是 | key 不可变,值挂在每条 Channel 上 |
private final ChannelGroup group | 是 | 线程安全集合,按 Channel 保存成员 |
private State state | 否 | 状态跨连接串扰 |
private int handshakes | 否 | ++ 不是原子操作,计数还会混淆连接 |
private final ByteBuf buffer | 否 | 读写索引和引用计数都属于单条连接 |
private final StringBuilder line | 否 | 非线程安全,内容会混在一起 |
final 只固定引用,不固定对象可变性。private final Map<String, Session> sessions = new HashMap<>() 仍然不能共享,因为 HashMap 会被并发修改。反过来,private final Map<...> = new ConcurrentHashMap<>() 可以共享,但「先查再写」的复合操作仍要自己处理原子性。
还有一个容易忽略的点:线程安全不等于连接隔离。假设所有连接恰好落在同一个 EventLoop 上,共享 handler 的字段访问是串行的,不会有数据竞争;但 A 连接的 state = READY 仍然会让 B 连接跳过握手。@Sharable 首先要过的是逻辑隔离这一关。
错误共享:一个反例
把 4.2 的 HandshakeHandler 直接改成共享:
// 反例:有状态 handler 加了 @Sharable
@ChannelHandler.Sharable
public final class BrokenHandshakeHandler extends SimpleChannelInboundHandler<LinkdFrame> {
private HandshakeHandler.State state = HandshakeHandler.State.WAIT_HELLO;
private int handshakes;
public BrokenHandshakeHandler() {
super(false);
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
if (state == HandshakeHandler.State.WAIT_HELLO) {
state = HandshakeHandler.State.READY;
handshakes++;
ctx.writeAndFlush(new LinkdFrame(FrameType.HELLO_ACK, 0, frame.requestId(),
"ready".getBytes(StandardCharsets.UTF_8)));
return;
}
ctx.fireChannelRead(frame);
}
}
这段代码有四个问题,严重程度依次上升:
handshakes++在多条 EventLoop 线程上并发执行,会丢失更新;这个计数本身也没有业务意义,它统计的是「进程里发生过多少次握手」,不是「当前连接握手了几次」。state没有同步,不同线程的读写没有 happens-before 关系,可能一条连接已经写了READY,另一条连接仍读到WAIT_HELLO。- 连接 B 在连接 A 握手后直接进入
READY,未认证的请求被转发给业务 handler。这是安全问题,不是性能问题。 - 一条连接被拒绝后把
state写成REJECTED,其他正常连接也跟着不可用。
修法有三种,按场景选:
- 每连接新建:有状态、状态生命周期与连接一致,最直接。
HandshakeHandler属于这一类。 - 共享无状态 handler +
AttributeKey:handler 逻辑复杂、想复用,状态搬到 Channel 上。 - 共享 handler +
ChannelGroup:状态是「连接集合」而不是「单连接字段」,例如广播。
用 AttributeKey 保留每连接状态
AttributeKey 是 Channel 上的类型化属性键。把状态从 handler 字段搬到 channel.attr(...),handler 就可以共享:
package com.plume.linkd.v2.server;
import io.netty.util.AttributeKey;
public final class HandshakeAttributes {
public static final AttributeKey<HandshakeHandler.State> STATE =
AttributeKey.valueOf("linkd.handshake.state");
private HandshakeAttributes() {
}
}
AttributeKey.valueOf(name) 对同名 key 返回同一个实例;本机输出 valueOf same=true。AttributeKey.newInstance(name) 在名字已存在时抛 IllegalArgumentException: 'linkd.handshake.state' is already in use。所以 key 必须是 static final,在类加载时创建一次,不能放进每次请求都会执行的方法。
读取和写入都通过当前 Channel:
HandshakeHandler.State state =
ctx.channel().attr(HandshakeAttributes.STATE).get();
if (state == null) {
state = HandshakeHandler.State.WAIT_HELLO;
ctx.channel().attr(HandshakeAttributes.STATE).set(state);
}
属性读写本身是线程安全的,但「读出来、判断、再写回去」不是原子操作。状态迁移仍然应该发生在该 Channel 的 EventLoop 上;如果业务线程要改状态,用 channel.eventLoop().execute(...) 提交回去,而不是在业务线程里直接比较再写。
属性跟着 Channel 走,Channel 被回收后属性也随之消失。如果属性里放了大对象(例如会话缓存),在 channelInactive 里显式 set(null) 或移除,能更早释放,也便于泄漏排查。
用 ChannelGroup 管理连接集合
广播类 handler 的状态不是「一条连接的字段」,而是「一组连接」。这时用 ChannelGroup,它本身就是线程安全的,可以放在共享 handler 的字段里。
WebSocketHub 是 linkd-v2 的广播入口,包名 com.plume.linkd.v2.server:
package com.plume.linkd.v2.server;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.util.concurrent.GlobalEventExecutor;
@ChannelHandler.Sharable
public final class WebSocketHub extends SimpleChannelInboundHandler<TextWebSocketFrame> {
private final ChannelGroup group =
new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
@Override
public void channelActive(ChannelHandlerContext ctx) {
group.add(ctx.channel());
ctx.fireChannelActive();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
group.writeAndFlush(new TextWebSocketFrame(frame.text()));
}
}
ChannelGroup 的包是 io.netty.channel.group,不是 io.netty.channel。DefaultChannelGroup 会自动移除已关闭的 Channel,所以 channelInactive 里不写 remove 也不会留下死连接;显式移除的好处是日志和 size() 更及时。
group.writeAndFlush(msg) 会把消息写给组内每个 Channel,并在完成后释放传入的消息;它内部为每个 Channel 保留一份引用。调用方不要再 release 同一个消息,否则引用计数会多减一次。这一点和第 5 章的引用计数规则一致:谁创建谁释放,ChannelGroup 接管之后就不再由调用方释放。
WebSocketHub 可以标 @Sharable,因为它的可变状态在 ChannelGroup 里,按 Channel 隔离;handler 自身只有 final 的 group 引用。把「当前用户」「当前帧计数」再塞进这个类,它立刻变回有状态 handler。
不可变 LinkdFrame 的共享边界
LinkdFrame 是 record:
public record LinkdFrame(int type, int flags, int requestId, byte[] payload) {
}
record 的字段是 final,引用可以安全地传给多个 handler,甚至多个 Channel。但 byte[] 的内容是可变的:如果某个 handler 在原地修改 payload,所有持有同一个数组的接收方都会看到变化。共享帧的前提是「构造后不再修改 payload」。
需要修改时复制出新数组,而不是原地写:
byte[] original = frame.payload();
byte[] copy = original.clone();
copy[0] = (byte) (copy[0] ^ 0x01);
LinkdFrame changed = new LinkdFrame(frame.type(), frame.flags(), frame.requestId(), copy);
LinkdFrameEchoHandler 直接把同一个 frame writeAndFlush 回去,没有修改 payload,所以共享是安全的;ConnectionRegistry.broadcast 把同一个帧发给多个连接时,也要遵守这条规则。第 5 章会讨论 ByteBuf 的 retainedDuplicate();record 没有引用计数,复制是最直接的隔离手段。
DefaultEventExecutorGroup 的能力边界
pipeline.addLast(group, handler) 可以让指定 handler 不在 Channel 的 EventLoop 上执行:
EventExecutorGroup blocking = new DefaultEventExecutorGroup(4);
pipeline.addLast(blocking, "lookup", new BlockingLookupHandler());
它的作用是把阻塞操作从 EventLoop 挪到另一组线程,避免一条慢查询卡住同一 EventLoop 上的其他连接(3.3)。它不改变两件事:
- 不改变 handler 的共享语义。 同一个实例加到多条 pipeline,仍然需要
@Sharable,仍然要自己处理字段安全。不同 Channel 的 context 可能被分配到组里的不同 executor,同一个 handler 实例反而更容易被并发调用。 - 不把有状态 handler 变成线程安全。
state、计数器、缓冲区还是共享的;线程换了,竞态和串状态都还在,只是更难复现。
它还会引入线程切换和任务排队。一个本来只做解码的 handler 没必要套执行器组;只有确实会阻塞、且已经无法拆成异步的操作才值得。DefaultEventExecutorGroup 要显式 shutdownGracefully(),它不随 EventLoopGroup 一起关闭;忘记关会让这些线程一直存活,进程无法正常退出。
在 linkd-v2 里落地
按状态归属给现有 handler 分类:
| handler | 能否共享 | 状态放在哪里 |
|---|---|---|
HandshakeHandler | 否 | 实例字段 state,每连接新建 |
LinkdHttpHandler | 可以 | 只有 final ConnectionRegistry,无跨请求状态 |
LinkdFrameEchoHandler | 可以 | 无状态,帧本身只读 |
WebSocketHub | 是 | ChannelGroup,按 Channel 隔离 |
RegistryHandler | 可以 | 只有 final ConnectionRegistry |
pipeline 的组装因此分成两种写法:
// 有状态:每条连接新建
pipeline.addLast("handshake", new HandshakeHandler());
// 无状态或状态在 ChannelGroup:共享同一个实例
pipeline.addLast("hub", sharedWebSocketHub);
判断标准不是「new 一个对象贵不贵」,而是「这个对象里有没有随连接变化的东西」。handler 实例本身很轻,真正的成本在字段里的缓冲区和集合;为了省一次 new 而共享有状态 handler,换来的是串连接和并发 bug,不划算。
常见错误与边界
错误一:把 @Sharable 当成线程安全声明。 Netty 只检查注解是否存在,不检查字段。标了 @Sharable 的 handler 如果还有 int count、State state,它只是「被允许共享」,不是「共享后正确」。
错误二:共享 handler 后把每连接状态放在 ThreadLocal 里。 EventLoop 线程会被多条连接复用,ThreadLocal 里的值在连接 A 和连接 B 之间共享,和实例字段的问题一样,而且更难清理。每连接状态用 Channel.attr(),不要用 ThreadLocal。
错误三:每次调用 AttributeKey.newInstance。
// 反例:每次处理消息都尝试创建同名 key
AttributeKey<HandshakeHandler.State> key =
AttributeKey.newInstance("linkd.handshake.state");
第二次调用就会抛 IllegalArgumentException: 'linkd.handshake.state' is already in use。key 必须是 static final,用 valueOf 获取同一个实例。
错误四:ChannelGroup.writeAndFlush 之后又手动释放消息。 ChannelGroup 接管了消息的所有权,写完会释放;再 release 一次会让引用计数多减一次,轻则提前回收,重则抛 IllegalReferenceCountException。需要保留原消息,就在传入前自己 retain(),并清楚谁负责最后释放。
边界:共享 handler 不等于共享业务状态。 即使 handler 无状态,ConnectionRegistry 里的会话表、ChannelGroup 里的连接集合仍然是跨连接的共享状态。它们的并发安全由容器负责,业务上的「先判断再修改」仍要自己设计原子性。
小结
@Sharable只表示「允许同一个实例加入多条 pipeline」,添加时的检查只看注解,不看字段。- 判断标准是字段是否随连接事件变化;
final只固定引用,不固定对象线程安全。 - 有状态 handler 默认每连接新建;需要复用时,把每连接状态放进
Channel.attr(AttributeKey)。 AttributeKey必须是static final,用valueOf获取;读写线程安全,但「读-改-写」仍要在 EventLoop 上串行。ChannelGroup位于io.netty.channel.group,适合保存连接集合并广播;它接管消息所有权,调用方不要重复释放。LinkdFrame的引用可共享,但payload数组不能原地修改;需要变更时复制新数组。DefaultEventExecutorGroup只隔离执行线程,不提供线程安全,也不改变 handler 的共享规则。
到这里,pipeline 的结构、handler 的时间线和共享规则都齐了。下一节进入 ByteBuf:读索引与写索引如何移动,slice、duplicate、retainedDuplicate 三种视图有什么区别,以及为什么视图的释放责任最容易写错。
阅读导航:上一节:4.2 Handler 生命周期与状态 · 下一节:5.1 ByteBuf 索引与视图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。