《Netty 高性能网络编程》4.2 Handler 生命周期与状态

本节按时间顺序拆开 handlerAdded、channelRegistered、channelActive、channelRead、channelReadComplete、channelInactive 与 handlerRemoved,说明 ChannelInitializer 如何移除自己,并用 HandshakeHandler 演示每连接状态迁移。

本节目标:把一条连接从加入 pipeline 到移除之间的回调顺序讲成可验证的时间线,并说清每连接状态该在哪个回调里初始化、在哪个回调里清理。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-codec-http 模块)。

承接的问题

4.1 的 HttpPipelineInitializer 一次往 pipeline 里加了三个 handler,但代码没有回答三个问题:handlerAdded 和 channelRegistered 谁先;channelActive 里写响应是否一定成功;ConnectionRegistry.remove 为什么挂在 channelInactive 而不是 handlerRemoved。

9093 的自定义协议还多一层需求。客户端连上后第一条帧必须是 HELLO,服务端回 HELLO_ACK 之后才允许 REQUEST;在收到 HELLO 之前收到别的帧要回 ERROR 并关闭。这个「收到过 HELLO」的状态必须跨多次 channelRead 保存。状态放在哪、什么时候重置,决定了握手是否会串连接。

本节先用一个探针把这条链上的回调顺序打出来,再看 ChannelInitializer 如何移除自己,最后把 HandshakeHandler 的状态机完整写出来。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面标注「本机输出」的内容来自这台机器,单次运行,未做统计。

回调的时间线

先把回调按一条正常 HTTP 连接的生命周期排列:

回调触发时机每条连接次数常见用途
handlerAddedhandler 被加入 pipeline1 次(动态增删除外)初始化 handler 字段、读取配置
channelRegisteredchannel 注册到 EventLoop1建立每连接属性
channelActive连接建立,可读写1(连接失败时 0)ConnectionRegistry.add、发握手帧
channelRead读到一条消息0 到多次解码、业务处理
channelReadComplete一次读循环结束0 到多次批量 flush、决定下一次读
channelWritabilityChanged写缓冲跨过高低水位0 到多次背压、暂停生产
channelInactive连接断开1ConnectionRegistry.remove、清理会话
channelUnregistered从 EventLoop 注销1释放与事件循环相关的资源
handlerRemovedhandler 从 pipeline 移除1释放 handler 自己持有的资源
exceptionCaught入站链路出现异常0 到多次记录原因、决定是否关闭

这张表有两个容易读错的格子。「次数」是单条连接上的期望值:连接失败时 channelActive 不会出现,但 channelRegistered 可能已经发生;channelWritabilityChanged 只有写缓冲越过 WriteBufferWaterMark 才会触发,普通小响应看不到它。

把回调顺序打出来只需要一个双工 handler:

package com.plume.linkd.v2.server;

import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelHandlerContext;

public final class LifecycleProbe extends ChannelDuplexHandler {

    private static final System.Logger LOG =
            System.getLogger(LifecycleProbe.class.getName());

    private void log(String event) {
        LOG.log(System.Logger.Level.INFO, event + " thread=" + Thread.currentThread().getName());
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        log("handlerAdded");
    }

    @Override
    public void channelRegistered(ChannelHandlerContext ctx) {
        log("channelRegistered");
        ctx.fireChannelRegistered();
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        log("channelActive");
        ctx.fireChannelActive();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        log("channelRead");
        ctx.fireChannelRead(msg);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) {
        log("channelReadComplete");
        ctx.fireChannelReadComplete();
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        log("channelInactive");
        ctx.fireChannelInactive();
    }

    @Override
    public void channelUnregistered(ChannelHandlerContext ctx) {
        log("channelUnregistered");
        ctx.fireChannelUnregistered();
    }

    @Override
    public void handlerRemoved(ChannelHandlerContext ctx) {
        log("handlerRemoved");
    }
}

把 LifecycleProbe 加在 HTTP pipeline 的业务 handler 之前,客户端发一个 GET /healthz 后关闭,本机输出(去掉线程名后):

handlerAdded
channelRegistered
channelActive
channelRead
channelReadComplete
channelInactive
channelUnregistered
handlerRemoved

顺序是稳定的:handler 先被加入 pipeline,再随 channel 注册,然后连接激活、读数据、读循环结束,最后断开、注销、移除。channelInactive 和 channelUnregistered 在同一个关闭流程里相邻发生;handlerRemoved 最后发生,因为 pipeline 要在 channel 注销后销毁所有 context。

这次运行里没有出现 channelWritabilityChanged 和 exceptionCaught:前者要等写缓冲越过水位线,后者要等链路抛异常。表中列出它们是为了完整,不代表每次连接都会触发。它们一旦发生,同样在该连接的 EventLoop 上执行。

channelRegistered 早于 channelActive 这一点决定了初始化位置:需要 Channel 已经注册、但还不需要连接可写的状态(例如往 channel.attr() 写初始值)放在 channelRegistered;需要写数据、读取对端地址、注册到连接表的状态放在 channelActive。连接失败时只有前者会发生,后者不会。

这个顺序只适用于在连接注册前就加入 pipeline 的 handler。连接已经注册后再 addLast 的 handler 只收到 handlerAdded,不会补发 channelRegistered 和 channelActive;动态加 handler 时,如果初始化依赖连接已激活,要自己检查 ctx.channel().isActive() 并补做,不能假设回调会重放。

ChannelInitializer 如何移除自己

ChannelInitializer 本身也是一个 handler,它被加入 pipeline 后执行 initChannel,然后把自身移除。这个过程解释了 4.1 的一个现象:连接建立后 pipeline.names() 里没有初始化器。

ChannelInitializer 带 @ChannelHandler.Sharable,同一个实例可以服务多条连接。它内部用一个集合记录已经初始化过的 Channel,使每条连接只调用一次 initChannel。channelRegistered 在它里面是 final 的,子类只能实现 initChannel,不能改写注册逻辑。

时序可以压缩成三步:

1. ChannelInitializer 被 addLast 到 pipeline
2. channel 注册阶段,ChannelInitializer 的 handlerAdded(或 channelRegistered)调用 initChannel
3. initChannel 返回后,ChannelInitializer 调用 ctx.pipeline().remove(this)

因此,在 initChannel 内部新加的 handler 会先收到 handlerAdded,此时初始化器还在链表里;等注册事件继续向下传时,初始化器已经消失。用探针打印 names() 能看到这个切换:

handlerAdded names=[InitProbe#0, HttpServerCodec#0, HttpObjectAggregator#0, LinkdHttpHandler#0, DefaultChannelPipeline$TailContext#0]
channelRegistered names=[HttpServerCodec#0, HttpObjectAggregator#0, LinkdHttpHandler#0, DefaultChannelPipeline$TailContext#0]

示意输出里的 InitProbe#0 在第二行消失。注意 names() 仍不包含 head,原因见 4.1。

ChannelInitializer 的 initChannel 在目标 Channel 的 EventLoop 上执行,所以里面可以直接 ch.pipeline().addLast(...),不需要额外同步。不要在 initChannel 里启动线程或做阻塞查询:它会拖慢这条连接的注册,也会占用该 EventLoop 上其他连接的时间。

HandshakeHandler:把状态放在 handler 字段里

9093 的握手状态属于单条连接,不属于进程。最直接的放法是 handler 的实例字段;因为 handler 默认按连接创建,字段天然隔离。先看完整实现:

package com.plume.linkd.v2.server;

import com.plume.linkd.v2.protocol.FrameType;
import com.plume.linkd.v2.protocol.LinkdFrame;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;

import java.nio.charset.StandardCharsets;

public final class HandshakeHandler extends SimpleChannelInboundHandler<LinkdFrame> {

    private static final System.Logger LOG =
            System.getLogger(HandshakeHandler.class.getName());

    public enum State { WAIT_HELLO, READY, REJECTED }

    private State state = State.WAIT_HELLO;

    public HandshakeHandler() {
        super(false);
    }

    public State state() {
        return state;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, LinkdFrame frame) {
        switch (state) {
            case WAIT_HELLO -> onHello(ctx, frame);
            case READY -> ctx.fireChannelRead(frame);
            case REJECTED -> { }
        }
    }

    private void onHello(ChannelHandlerContext ctx, LinkdFrame frame) {
        if (frame.type() != FrameType.HELLO) {
            state = State.REJECTED;
            ctx.writeAndFlush(new LinkdFrame(FrameType.ERROR, 0, frame.requestId(),
                    ("expected HELLO, got " + frame.type()).getBytes(StandardCharsets.UTF_8)))
                    .addListener(f -> ctx.close());
            return;
        }
        state = State.READY;
        ctx.writeAndFlush(new LinkdFrame(FrameType.HELLO_ACK, 0, frame.requestId(),
                "ready".getBytes(StandardCharsets.UTF_8)));
        LOG.log(System.Logger.Level.INFO, "handshake ready id={0}",
                ctx.channel().id().asShortText());
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        if (state != State.READY) {
            LOG.log(System.Logger.Level.WARNING, "closed before handshake id={0} state={1}",
                    ctx.channel().id().asShortText(), state);
        }
        ctx.fireChannelInactive();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        LOG.log(System.Logger.Level.WARNING, "handshake failed", cause);
        ctx.close();
    }
}

三个细节值得单独说。

第一,构造器里的 super(false) 是 Netty 4.2 的写法。SimpleChannelInboundHandler 在 4.2 用构造器参数控制是否自动释放,没有可覆写的 autoRelease() 方法;一些 4.1 的资料会让你覆写它,在 4.2 编译不过。这里把合法帧通过 ctx.fireChannelRead(frame) 交给下游,所以必须关闭自动释放,明确「谁转发谁负责」。

第二,LinkdFrame 目前是 record,本身不实现 ReferenceCounted,自动释放对它没有实际作用;但把 autoRelease 关掉是一个声明:如果以后 payload 换成 ByteBuf,下游拿到的对象仍然有效,释放责任跟着所有权走。第 5 章会把这条规则展开。

第三,REJECTED 分支不是「设个标志就完事」。它在回 ERROR 的 future 上挂 ctx.close(),让错误帧先写出去再关闭;如果直接 ctx.close(),写操作可能还没 flush,客户端只看到连接被重置。case REJECTED -> { } 是必要的兜底:关闭是异步的,关闭完成前还可能读到后续帧,此时不再处理。

把 HandshakeHandler 放进 EmbeddedChannel 验证状态迁移,本机输出:

state after hello=READY
outbound=2
downstream got type=5
state after request=READY
bad state=REJECTED error type=7 payload=expected HELLO, got 5
closed=true

outbound=2 是 HELLO_ACK,downstream got type=5 说明握手完成后 REQUEST 确实被转发给后面的业务 handler;第二个 channel 直接发 REQUEST,状态变成 REJECTED,回 ERROR(类型 7)并关闭。这个验证不依赖真实 socket,但状态迁移和 pipeline 传播与线上一致。

channelReadComplete 不等于一次消息处理完成

channelReadComplete 的语义是「这一轮读事件处理完了」,不是「一条消息处理完了」。一次读循环里可能读出多条消息,也可能一条都没读出来。它和业务事务边界没有关系。

本机第一次用「客户端发完请求立即断开」的方式跑探针时,输出里同一条请求之后出现了两次 channelReadComplete:

channelRead
channelReadComplete
channelReadComplete

第二次前面没有 channelRead,因为那一轮读循环读到的是连接关闭或读失败。把 channelReadComplete 当成「一次请求处理完成」写清理逻辑,就会在空读时误触发;把它当成「响应已发出」更不对,它只描述读方向。

它的正确用途是批量操作。一次读循环里如果产生了多个响应,可以在这里统一 ctx.flush(),减少系统调用;也可以在 AUTO_READ 关闭时,在这里决定是否继续 ctx.read()(9.3 展开)。业务上的「请求完成」应该由请求对象、future 或业务计数器表达,不能借这个回调。

在 linkd-v2 里落地

HandshakeHandler 插在 9093 的编解码器和业务回显之间:

pipeline.addLast("frameDecoder", new LinkdFrameDecoder());
pipeline.addLast("frameEncoder", new LinkdFrameEncoder());
pipeline.addLast("registry", new RegistryHandler(registry));
pipeline.addLast("handshake", new HandshakeHandler());
pipeline.addLast("echo", new LinkdFrameEchoHandler());

入站帧先经过解码器变成 LinkdFrame,再经过 RegistryHandler(只关心 active/inactive),然后进入 HandshakeHandler。握手完成后,HandshakeHandler 把帧 fire 给 echo;未完成握手时,帧在这里被消费或拒绝,echo 不会看到。

HTTP 侧不需要握手状态,但同样依赖生命周期。LinkdHttpHandler 在 channelRead0 里处理完整请求,在 exceptionCaught 里记录并关闭;RegistryHandler 在 channelActive 注册、在 channelInactive 移除。三者的分工是:

  • RegistryHandler:连接级、无业务状态,只维护注册表。
  • HandshakeHandler:连接级、有协议状态,状态只属于当前连接。
  • LinkdHttpHandler:请求级、无跨请求状态,每个请求独立处理。

把三类状态分开,才能回答 4.3 的问题:哪些 handler 可以跨连接共享,哪些必须每连接新建。HandshakeHandler 的 state 字段就是「不可共享」的典型证据。

常见错误与边界

错误一:在 handlerAdded 里写数据。 此时 channel 可能还没注册或还没 active,写操作要么排队、要么以失败 future 结束,对端收不到。需要连接已建立的动作放 channelActive;handlerAdded 只做字段初始化和配置读取。

错误二:把 channelReadComplete 当成「一条消息处理完」。

// 反例:在读循环结束时清理单条请求的状态
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
    currentRequest = null;
    ctx.fireChannelReadComplete();
}

一次读循环可能包含多条消息,也可能一条都没有。这个回调触发时,前面的请求可能还没处理完,后面的请求可能已经读进来。单条请求的状态应该在处理该请求的方法里清理,或者跟着请求对象走。

错误三:SimpleChannelInboundHandler 自动释放后又把消息传给下游。

// 反例:默认构造器会自动释放,fire 之后下游拿到的是已释放对象
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf buf) {
    ctx.fireChannelRead(buf);
}

默认构造器在 channelRead0 返回后释放 buf;下游如果异步使用它,就会读到已回收的内存。要转发就 super(false) 并明确由下游释放,要么不转发、在当前 handler 里用完。LinkdFrame 这类非引用计数对象不受影响,但规则要按最坏情况记。

边界:channelInactive 之后不要再写。 连接已经断开,writeAndFlush 会得到失败的 future;如果还在 channelInactive 里发「再见」帧,只会增加无意义的失败日志。需要发关闭帧的场景要在收到关闭信号时先写、再关,而不是等 channelInactive。

小结

  • 正常连接的回调顺序是 handlerAdded → channelRegistered → channelActive → channelRead → channelReadComplete → channelInactive → channelUnregistered → handlerRemoved,全部在该连接的 EventLoop 上执行。
  • channelActive 在连接失败时不会触发;依赖连接可写的初始化放 channelActive,只依赖注册的放 channelRegistered。
  • ChannelInitializer 在 initChannel 返回后移除自己;连接建立后的 pipeline.names() 里没有它。
  • 有状态 handler 把状态放在实例字段里,前提是每连接新建;HandshakeHandler 的 WAIT_HELLO → READY / REJECTED 就是这种模式。
  • channelReadComplete 是一次读循环的边界,可能对应零条或多条消息,不能当作单条请求完成的信号。
  • 转发消息时用 super(false) 关闭 SimpleChannelInboundHandler 的自动释放,并明确下游负责释放。

handler 的创建、注册、读写、移除已经有时间线。下一个问题是这些 handler 实例能不能在连接之间共享:哪些字段让 handler 变成有状态,@Sharable 到底检查什么,AttributeKey 和 ChannelGroup 如何保留每连接状态,以及 DefaultEventExecutorGroup 能隔离什么、不能隔离什么。

阅读导航:上一节:4.1 Pipeline 结构与事件传播 · 下一节:4.3 Handler 共享与线程安全 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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