《Netty 高性能网络编程》4.3 Handler 共享与线程安全

本节用 linkd-v2 的 HandshakeHandler 说明哪些字段让 handler 变成有状态,@Sharable 在添加时检查什么,共享实例会带来哪些并发与串状态风险,并给出 AttributeKey、ChannelGroup 与不可变 LinkdFrame 的正确用法,以及 DefaultEventExecutorGroup 的能力边界。

本节目标:判断一个 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);
    }
}

这段代码有四个问题,严重程度依次上升:

  1. handshakes++ 在多条 EventLoop 线程上并发执行,会丢失更新;这个计数本身也没有业务意义,它统计的是「进程里发生过多少次握手」,不是「当前连接握手了几次」。
  2. state 没有同步,不同线程的读写没有 happens-before 关系,可能一条连接已经写了 READY,另一条连接仍读到 WAIT_HELLO。
  3. 连接 B 在连接 A 握手后直接进入 READY,未认证的请求被转发给业务 handler。这是安全问题,不是性能问题。
  4. 一条连接被拒绝后把 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)。它不改变两件事:

  1. 不改变 handler 的共享语义。 同一个实例加到多条 pipeline,仍然需要 @Sharable,仍然要自己处理字段安全。不同 Channel 的 context 可能被分配到组里的不同 executor,同一个 handler 实例反而更容易被并发调用。
  2. 不把有状态 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 索引与视图 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发