Netty 4.2.x 源码深度解析 (二十一):Pipeline 事件传播 —— Inbound 与 Outbound 的双向流转

0 阅读26分钟

cover-20.jpg

ChannelPipeline 是一条由 ChannelHandlerContext 节点首尾相接构成的双向链表,两端分别是 HeadContext、TailContext 两个哨兵节点。链条搭好之后,事件如何在链上流动,才是责任链模式的灵魂所在。Netty 把 Channel 上的 IO 操作分成两类:一类是由底层网络触发、从 IO 线程向业务代码层层流入的 Inbound 事件,比如"注册完成""连接激活""读到了数据";另一类是由业务代码发起、向底层 Unsafe 逐层下沉的 Outbound 事件,比如"绑定端口""建立连接""写出数据""关闭连接"。这两类事件在同一条 Pipeline 上沿相反方向传播:Inbound 从 HeadContext 向 TailContext 正向推进,Outbound 从 TailContext 向 HeadContext 反向回溯,最终以 HeadContext 为分水岭,完成了"网络数据输入"与"业务数据输出"的对称闭环。理解这套双向传播机制,是读懂 handler 之间如何做事件分发、为什么 ctx.write() 与 channel.write() 行为会不一样的关键。

围绕 Inbound 正向传播、Outbound 反向传播、写刷新的语义分离与异常处理四条主线,以下几个问题将贯穿本文的源码分析主线:

  • DefaultChannelPipeline 的 fire 方法为何要先判断 head.executor().inEventLoop(),非 EventLoop 线程调用时又是如何把事件切回目标线程的?

  • findContextInbound() 与 findContextOutbound() 如何分别在 next 链和 prev 链上查找下一个匹配的 ctx,skipContext() 又凭什么用 executionMask 跳过未实现该事件的 handler?fireChannelRead() 内那段三分支内联派发,背后隐藏着什么性能考虑?

  • Outbound 事件为何统一从 TailContext 起步,ctx.write() 与 ctx.channel().write() 的传播起点差异会带来怎样不同的 handler 拦截结果?

  • write() 与 flush() 在语义上如何分离,writeAndFlush() 又是如何通过 flush 标志合并 MASK_WRITE | MASK_FLUSH 一次完成"写缓冲 + 刷出"?

  • Inbound 异常与 Outbound 异常为何走两条不同的处理路径,notifyOutboundHandlerException() 使 Promise 失败与 invokeExceptionCaught() 沿 Inbound 链传播各自适用于什么场景?

    本文将沿着 Inbound 正向传播与 Outbound 反向传播两条互逆的路径,先建立双向传播的对称模型,再分别深入两类事件的查找、跳过与派发机制,随后拆解 write/flush 的分层语义与起点差异,最后分析异常传播的两条处理链路。

一、双向传播模型:Inbound 与 Outbound 的对称设计

1.1 两类事件与两条传播方向

所谓责任链传播,是指 Pipeline 本身只负责"存链",事件的流动则依赖 ChannelHandlerContext 上的一组 fire 与 invoke 方法,由每个 ctx 逐步把事件传递给下一个 ctx。这个"存链"与"传链"的分离,正是 Netty 责任链设计的精妙之处:链表结构是静态的,事件传播是动态的,二者通过 ChannelHandlerContext 上的方法调用在运行期粘合起来。

Netty 根据事件的来源方向,把它们划成泾渭分明的两类。Inbound 事件由底层网络触发,从 IO 线程流入业务代码,方向是"从底层到应用";Outbound 事件由业务代码发起,流向底层 Unsafe,方向是"从应用到底层"。这两类事件不仅来源相反,在 Pipeline 上的传播方向也恰好相反,如下图所示:

Pipeline-PipelineBiPropagation.drawio.png

可以看到,HeadContext 与 TailContext 这两个哨兵恰好承担了相反方向的职责:HeadContext 既是 Inbound 的起点,又是 Outbound 的终点;TailContext 既是 Outbound 的起点,又是 Inbound 的终点。Inbound 沿 next 链正向推进,Outbound 沿 prev 链反向回溯。

两类事件的另一组差异体现在触发源与落点上。Inbound 由 IO 线程或底层网络触发,发起点是 HeadContext,终点是 TailContext,消息走到链尾仍无人处理时,由它负责释放资源或告警;Outbound 由业务代码发起,发起点是 TailContext,终点是 HeadContext,最终落到 unsafe.* 上执行真正的底层操作。查找目标时,Inbound 用 findContextInbound(mask),Outbound 用 findContextOutbound(mask),而二者共用同一个 skipContext(executionMask) 判定,用 executionMask 跳过不关心该事件的 handler。

1.2 ChannelInboundInvoker 与 ChannelOutboundInvoker:两类事件的契约

Inbound 事件的"传播契约"定义在 ChannelInboundInvoker 接口里,它只声明了九个 fire 方法,每个方法对应一个 Inbound 事件:

io.netty.channel.ChannelInboundInvoker
​
public interface ChannelInboundInvoker {
​
    ChannelInboundInvoker fireChannelRegistered();
​
    ChannelInboundInvoker fireChannelUnregistered();
​
    ChannelInboundInvoker fireChannelActive();
​
    ChannelInboundInvoker fireChannelInactive();
​
    ChannelInboundInvoker fireExceptionCaught(Throwable cause);
​
    ChannelInboundInvoker fireUserEventTriggered(Object event);
​
    ChannelInboundInvoker fireChannelRead(Object msg);
​
    ChannelInboundInvoker fireChannelReadComplete();
​
    ChannelInboundInvoker fireChannelWritabilityChanged();
}

这九个方法可以粗略分成两组:生命周期的四个(fireChannelRegistered、fireChannelActive、fireChannelInactive、fireChannelUnregistered),以及数据、异常与用户事件的五个(fireChannelRead、fireChannelReadComplete、fireExceptionCaught、fireUserEventTriggered、fireChannelWritabilityChanged)。约定俗成的命名里,fire 前缀表示"向下一个 handler 抛出事件",是 Inbound 传播的动词。

与 Inbound 对应,Outbound 事件的操作契约定义在 ChannelOutboundInvoker 接口里,它声明的是"动作"而非"通知":

io.netty.channel.ChannelOutboundInvoker
​
// 仅列核心动作方法,无参 default 重载与 newPromise 等辅助方法省略
public interface ChannelOutboundInvoker {
​
    ChannelFuture bind(SocketAddress localAddress, ChannelPromise promise);
​
    ChannelFuture connect(SocketAddress remoteAddress, ChannelPromise promise);
​
    ChannelFuture connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise);
​
    ChannelFuture disconnect(ChannelPromise promise);
​
    ChannelFuture close(ChannelPromise promise);
​
    ChannelFuture deregister(ChannelPromise promise);
​
    ChannelOutboundInvoker read();
​
    ChannelFuture write(Object msg, ChannelPromise promise);
​
    ChannelOutboundInvoker flush();
​
    ChannelFuture writeAndFlush(Object msg, ChannelPromise promise);
}

Outbound 的方法命名没有 fire 前缀,因为它们是"我要执行某个操作"的主动请求,而不是"发生了什么"的被动通知。值得留意的是,Outbound 方法大多返回 ChannelFuture,意味着这些操作是异步的,其结果通过 promise 通知;只有 read()、flush() 两个方法返回的是自身类型,因为它们的语义更倾向"触发一次动作"而非"等待一个结果"。这两份接口契约一起,构成了 Pipeline 双向传播的完整事件清单。

1.3 HeadContext 与 TailContext:双向的分水岭

两个哨兵节点之所以能同时扮演起点与终点两种角色,根源在于它们各自实现的方式不同。HeadContext 同时实现了 ChannelInboundHandler 与 ChannelOutboundHandler 两个接口,而 TailContext 只实现了 ChannelInboundHandler:

io.netty.channel.DefaultChannelPipeline.HeadContext / TailContext
​
final class HeadContext extends AbstractChannelHandlerContext
        implements ChannelOutboundHandler, ChannelInboundHandler {
​
    private final Unsafe unsafe;
​
    HeadContext(DefaultChannelPipeline pipeline) {
        super(pipeline, null, HEAD_NAME, HeadContext.class);
        // 直接拿到底层 Unsafe,作为 Outbound 事件的最终执行者
        unsafe = pipeline.channel().unsafe();
        setAddComplete();
    }
​
    // ...
}
​
// 只处理 Inbound 的兜底节点
final class TailContext extends AbstractChannelHandlerContext implements ChannelInboundHandler {
​
    TailContext(DefaultChannelPipeline pipeline) {
        super(pipeline, null, TAIL_NAME, TailContext.class);
        setAddComplete();
    }
​
    // ...
}

HeadContext 的构造器里保存了对底层 Unsafe 的引用,这决定了它是 Outbound 事件的最终落点:所有 Outbound 请求回溯到头之后,都由它把请求交给 unsafe 去真正执行。而 TailContext 只实现 Inbound 接口,意味着当 Inbound 事件一路传到最后却无人处理时,由它来做"兜底",释放消息、告警异常。两个 setAddComplete() 调用则把自己的状态直接置为 ADD_COMPLETE:哨兵节点没有真正的业务 handler,构造时即视为"已就绪",不再等待 handlerAdded 回调,这也为后文 invokeHandler() 的门控逻辑埋下了伏笔。

二、Inbound 事件传播:从 Head 到 Tail 的正向流转

2.1 fire 方法入口:EventLoop 检查与线程切换

Inbound 传播的入口是 DefaultChannelPipeline 上的 fire 方法。以 fireChannelRead 为例,它的实现极其统一:

io.netty.channel.DefaultChannelPipeline#fireChannelRead
​
@Override
public final ChannelPipeline fireChannelRead(Object msg) {
    if (head.executor().inEventLoop()) {
        if (head.invokeHandler()) {
            head.channelRead(head, msg);
        } else {
            head.fireChannelRead(msg);
        }
    } else {
        head.executor().execute(() -> fireChannelRead(msg));
    }
    return this;
}

这段代码看似简单,却藏着两层关键设计。第一层是线程切换:head.executor().inEventLoop() 判断当前线程是否就是 HeadContext 绑定的 EventLoop,如果不是,就把整个 fireChannelRead 通过 execute() 重新提交回 EventLoop 执行。这个判断贯穿了所有 fire 方法与所有 handler 的传播点,是 Netty 保证"handler 永远在它绑定的 EventLoop 线程上串行执行"的基石,也正因为有了它,handler 内部才能放心地不加锁地读写自身状态。

第二层是入口双分支:head.invokeHandler() 为 true 时,直接调用 head.channelRead(head, msg),注意这里调用的是 HeadContext 自己覆写的 channelRead 方法;为 false 时则调用 head.fireChannelRead(msg) 跳过 Head 继续向前传播。对于 HeadContext 而言,由于构造器里已经 setAddComplete(),invokeHandler() 恒为 true,所以入口总是走前者,HeadContext 被当作"第一个 Inbound handler"来调用。它的 channelRead 实现本身不消费消息,只是把事件继续抛给下一个 handler:

io.netty.channel.DefaultChannelPipeline.HeadContext#channelRead

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

HeadContext 扮演的是"起点转发器"的角色:它接到事件后,立刻调用 ctx.fireChannelRead(msg) 把事件交回传播链。这里的 ctx 就是 HeadContext 自身,ctx.fireChannelRead 会进入 AbstractChannelHandlerContext.fireChannelRead,开始真正的逐个 handler 派发。

2.2 fireChannelRead 的完整传播链路

从 HeadContext 转发出来的 fireChannelRead,属于 AbstractChannelHandlerContext 的实例方法,它是 Inbound 传播真正的核心:

io.netty.channel.AbstractChannelHandlerContext#fireChannelRead
​
@Override
public ChannelHandlerContext fireChannelRead(final Object msg) {
    // 沿 next 链查找下一个实现 channelRead 的 handler
    AbstractChannelHandlerContext next = findContextInbound(MASK_CHANNEL_READ);
    if (next.executor().inEventLoop()) {
        // 泄漏追踪:消息进入 handler 前打点
        final Object m = pipeline.touch(msg, next);
        if (next.invokeHandler()) {
            try {
                final ChannelHandler handler = next.handler();
                final DefaultChannelPipeline.HeadContext headContext = pipeline.head;
                if (handler == headContext) {
                    headContext.channelRead(next, m);
                } else if (handler instanceof ChannelDuplexHandler) {
                    ((ChannelDuplexHandler) handler).channelRead(next, m);
                } else {
                    ((ChannelInboundHandler) handler).channelRead(next, m);
                }
            } catch (Throwable t) {
                next.invokeExceptionCaught(t);
            }
        } else {
            next.fireChannelRead(m);
        }
    } else {
        next.executor().execute(() -> fireChannelRead(msg));
    }
    return this;
}

整个方法可以拆成三步来读。第一步是 findContextInbound(MASK_CHANNEL_READ):沿 next 链找到下一个"实现了 channelRead"的 ctx,如果某个 handler 没有覆写 channelRead,就被直接跳过。第二步是 next.executor().inEventLoop() 的线程检查:目标 handler 若绑定在另一个 EventExecutor 上,就把本次传播整体提交到那个执行器,从而把"线程边界"也纳入传播语义。第三步是真正的派发:next.invokeHandler() 为 true 时调用 handler 的方法,否则调用 next.fireChannelRead(m) 继续向前转发。

先看那个 pipeline.touch(msg, next)。它是 Netty 泄漏检测的一部分:在泄漏检测开启时,读到的消息在进入每个 handler 之前都会打一次点,记录下"这条消息现在到了哪个 handler";若未开启,touch 方法会直接原样返回消息,不做任何额外动作。一旦消息后来发生泄漏(没有被释放),ResourceLeakDetector 就能根据这些打点日志,准确地定位出是哪一个 handler 手里"扣着没还"。

再看包裹派发的那段 try/catch:handler 的 channelRead 一旦抛出 Throwable,就调用 next.invokeExceptionCaught(t) 把异常转入异常传播链(本文第五章展开),而不是让异常直接崩掉 IO 线程。这个"异常兜底"几乎出现在每一个 fire 方法与 handler 派发点,是 Netty 保证事件循环健壮性的通用手段。

2.3 findContextInbound 与 skipContext:查找与跳过

上一步反复出现的 findContextInbound 是 Inbound 传播的方向感来源,它与 Outbound 的 findContextOutbound 成对出现,连同 skipContext 一起构成"查找目标 ctx"的完整逻辑:

io.netty.channel.AbstractChannelHandlerContext#findContextInbound / #findContextOutbound
​
private AbstractChannelHandlerContext findContextInbound(int mask) {
    AbstractChannelHandlerContext ctx = this;
    EventExecutor currentExecutor = executor();
    do {
        ctx = ctx.next;
    } while (skipContext(ctx, currentExecutor, mask, MASK_ONLY_INBOUND));
    return ctx;
}
​
private AbstractChannelHandlerContext findContextOutbound(int mask) {
    AbstractChannelHandlerContext ctx = this;
    EventExecutor currentExecutor = executor();
    do {
        ctx = ctx.prev;
    } while (skipContext(ctx, currentExecutor, mask, MASK_ONLY_OUTBOUND));
    return ctx;
}
​
private static boolean skipContext(
        AbstractChannelHandlerContext ctx, EventExecutor currentExecutor, int mask, int onlyMask) {
    return (ctx.executionMask & (onlyMask | mask)) == 0 ||
            (ctx.executor() == currentExecutor && (ctx.executionMask & mask) == 0);
}

两个查找方法的结构完全对称:Inbound 沿 ctx.next 前进,Outbound 沿 ctx.prev 回溯,都用 do-while 循环配合 skipContext 决定是否继续走。skipContext 的返回值为 true 表示"应该跳过这个 ctx",它有两个通过 || 连接的跳过条件。

第一个条件 (ctx.executionMask & (onlyMask | mask)) == 0 判断的是"方向不沾边":onlyMask 是 MASK_ONLY_INBOUND 或 MASK_ONLY_OUTBOUND,是该方向全部事件的位集合。若 ctx 的 executionMask 与它无交集,即 ctx 完全不实现这个方向的事件,就整体跳过;mask 单独并入,是因为 MASK_EXCEPTION_CAUGHT 不在 MASK_ONLY_INBOUND 内,需要单独纳入判断。

第二个条件 (ctx.executor() == currentExecutor && (ctx.executionMask & mask) == 0) 判断的是"同执行器下没实现":当目标 ctx 与当前 handler 用的是同一个 EventExecutor 时,只要它没实现这个具体事件(mask 位不命中),就可以跳过;反之,如果目标 ctx 用的是不同的执行器,即便它没实现这个事件也不能跳过,因为必须把线程切过去才能保证跨执行器场景下的事件顺序(跨执行器不跳过的语义正源于此)。这两个条件的精妙之处在于,它把"类型过滤"与"顺序保证"两件事,用一个 executionMask 位图就统一表达了。

2.4 invokeHandler 门控与三分支内联派发

findContextInbound 找到目标 ctx 后,真正决定"要不要调用这个 handler"的是 invokeHandler():

io.netty.channel.AbstractChannelHandlerContext#invokeHandler
​
boolean invokeHandler() {
    int handlerState = this.handlerState;
    return handlerState == ADD_COMPLETE || (!ordered && handlerState == ADD_PENDING);
}

它读的是 handlerState 这个状态位,逻辑很直白:只有当 handler 已经处于 ADD_COMPLETE(handlerAdded 已回调)时才调用它;在 !ordered(非顺序执行器)的场景下,ADD_PENDING 状态也放行。这个门控的价值在于,DefaultChannelPipeline 有时会先把 handler 挂进链表、却还没调用它的 handlerAdded(延迟添加机制),此时事件若直接触达 handler,handler 可能处于"半初始化"状态。于是 Netty 用 invokeHandler() 把这种 handler 挡在门外,只把事件继续转发。

派发本身采用的是那段三分支内联写法。它之所以要先判 handler == headContext,再判 handler instanceof ChannelDuplexHandler,最后才强转 ChannelInboundHandler,是为了规避一个 JVM 方法调用层面的性能退化:当一个 handler 同时实现了 Inbound 与 Outbound 两个接口时(即 duplex handler),对它做接口类型校验(instanceof)时,会竞争每个类仅有一个槽位的 secondary_super_cache,多线程下频繁刷新该槽位会引发 CPU cache line 在处理器间 ping-pong,导致扩展性下降。Netty 用这段显式多分支先按具体类型分流,规避这一退化。

2.5 TailContext 终点:消息的兜底释放

如果事件一路传下来没有任何 handler 消费,最终会抵达 TailContext。拿 channelRead 来说,TailContext 的实现是:

io.netty.channel.DefaultChannelPipeline.TailContext#channelRead
(onUnhandledInboundMessage 为外部类 DefaultChannelPipeline 的 protected 方法)
​
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
    onUnhandledInboundMessage(ctx, msg);
}
​
protected void onUnhandledInboundMessage(ChannelHandlerContext ctx, Object msg) {
    onUnhandledInboundMessage(msg);
    if (logger.isDebugEnabled()) {
        logger.debug("Discarded message pipeline : {}. Channel : {}.",
                     ctx.pipeline().names(), ctx.channel());
    }
}
​
protected void onUnhandledInboundMessage(Object msg) {
    try {
        logger.debug(
                "Discarded inbound message {} that reached at the tail of the pipeline. " +
                        "Please check your pipeline configuration.", msg);
    } finally {
        ReferenceCountUtil.release(msg);
    }
}

这是 Inbound 消息链的"终点兜底"。Netty 的消息大多是引用计数的(ReferenceCounted),读到一条消息却没人处理的后果,是内存泄漏。所以 TailContext 的职责很纯粹:打一条 debug 日志提示"消息被丢弃了,请检查你的 pipeline 配置",然后无论如何都通过 ReferenceCountUtil.release(msg) 释放引用计数。finally 块保证了即使打日志时抛异常,释放动作也一定执行。

把上面这些环节串起来,一条 channelRead 消息的正向传播时序如下:

Pipeline-channelRead.drawio.png

上面的时序图清晰地展示了三个层次:DefaultChannelPipeline 作为入口做线程兜底,HeadContext 作为起点做转发,中间的 Inbound handler 与 TailContext 则分别扮演"消费者"与"兜底者"。数据每经过一个 handler,都是"fireChannelRead 先找到下一个 ctx,再把消息交给它的 channelRead"的节奏,责任链的正向传播由此一环扣一环地展开。

三、Outbound 事件传播:从 Tail 到 Head 的反向流转

3.1 Pipeline 的 tail 委托入口

Outbound 事件的入口与 Inbound 恰好镜像。DefaultChannelPipeline 把所有的 Outbound 操作统一委托给 tail,即 TailContext:

io.netty.channel.DefaultChannelPipeline#write / #writeAndFlush / #flush / #read
​
@Override
public final ChannelFuture write(Object msg) {
    return tail.write(msg);
}
​
@Override
public final ChannelFuture writeAndFlush(Object msg) {
    return tail.writeAndFlush(msg);
}
​
@Override
public final ChannelPipeline flush() {
    tail.flush();
    return this;
}
​
@Override
public final ChannelPipeline read() {
    tail.read();
    return this;
}

bind、connect、disconnect、close、deregister 亦是如此,全部把请求转给 tail。这样做的原因在于:Outbound 事件要"从后往前"回溯,让更靠近 HeadContext 的 handler(通常是更底层、更接近 IO 的 handler)最后才处理请求,所以必须从链条最末端的 TailContext 出发,一路 prev 回溯到 HeadContext。TailContext 本身不实现 ChannelOutboundHandler,它只是"起点",真正的查找与派发由 AbstractChannelHandlerContext 上的 Outbound 方法完成。

3.2 bind 的反向传播

以 bind 为例来看 Outbound 传播的完整逻辑:

io.netty.channel.AbstractChannelHandlerContext#bind
​
@Override
public ChannelFuture bind(final SocketAddress localAddress, ChannelPromise promise) {
    ObjectUtil.checkNotNull(localAddress, "localAddress");
    if (isNotValidPromise(promise, false)) {
        return promise;
    }
​
    final AbstractChannelHandlerContext next = findContextOutbound(MASK_BIND);
    EventExecutor executor = next.executor();
    if (executor.inEventLoop()) {
        if (next.invokeHandler()) {
            promise = ensurePromiseUseCorrectExecutor(promise);
            try {
                final ChannelHandler handler = next.handler();
                final DefaultChannelPipeline.HeadContext headContext = pipeline.head;
                if (handler == headContext) {
                    headContext.bind(next, localAddress, promise);
                } else if (handler instanceof ChannelDuplexHandler) {
                    ((ChannelDuplexHandler) handler).bind(next, localAddress, promise);
                } else if (handler instanceof ChannelOutboundHandlerAdapter) {
                    ((ChannelOutboundHandlerAdapter) handler).bind(next, localAddress, promise);
                } else {
                    ((ChannelOutboundHandler) handler).bind(next, localAddress, promise);
                }
            } catch (Throwable t) {
                notifyOutboundHandlerException(t, promise);
            }
        } else {
            next.bind(localAddress, promise);
        }
    } else {
        final ChannelPromise p = promise;
        safeExecute(executor, () -> bind(localAddress, p), promise, null, false);
    }
    return promise;
}

与 Inbound 的 fireChannelRead 相比,两者骨架一致(findContextOutbound → 线程检查 → invokeHandler 门控 → 内联派发 → 异常处理),差异集中在四处。第一处是查找方向:这里用 findContextOutbound(MASK_BIND) 沿 prev 链回溯。第二处是 promise 的处理:Outbound 方法返回 ChannelFuture,所以在进入派发前先 isNotValidPromise(promise, false) 检查 promise 是否已取消,取消就直接返回,避免做无用功;命中后还要 ensurePromiseUseCorrectExecutor(promise) 把 promise 的执行器归一到当前线程,这两步在 Inbound 侧都没有。第三处是派发分支:Outbound 是四分支,比 Inbound 的三分支多出 ChannelOutboundHandlerAdapter 一档。第四处也是最重要的异常处理差异:Inbound 的 catch 是 invokeExceptionCaught(t),而这里是 notifyOutboundHandlerException(t, promise),异常不再是"向后传播",而是直接让 promise 失败。这个差异正是第五章要重点拆解的两种异常路径。

3.3 HeadContext 的 Unsafe 终点

当 Outbound 请求一路回溯到 HeadContext 时,就触及了传播的终点。HeadContext 对每个 Outbound 方法的实现都是同一种范式,委托给 unsafe:

io.netty.channel.DefaultChannelPipeline.HeadContext#write / #flush / #read
​
@Override
public void read(ChannelHandlerContext ctx) {
    unsafe.beginRead();
}
​
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
    unsafe.write(msg, promise);
}
​
@Override
public void flush(ChannelHandlerContext ctx) {
    unsafe.flush();
}

bind、connect、disconnect、close、deregister 也都是各自委托给 unsafe.bind、unsafe.connect 等。到这里,Outbound 请求就从 handler 世界进入了 IO 内核世界。Unsafe 是 Netty 内部负责底层 IO 的操作入口,本文只需要记住:HeadContext 是 Outbound 的"终点收费站",再往后就不再是责任链,而是真正的系统调用。

一条 write 请求的反向传播时序如下:

Pipeline-write.drawio.png

上图展示的层次与 Inbound 完全对称:DefaultChannelPipeline 委托 tail 作为起点,TailContext 通过 findContextOutbound 沿 prev 链反向查找,每个 Outbound handler 依次拦截并继续向前回溯,最终 HeadContext 把请求交给 unsafe。与 Inbound 的"消息被消费"不同,Outbound 的"请求被执行"发生在链的最前端,这就是双向传播在两端的对称落点。

四、write 与 flush:发送语义的分层与起点差异

4.1 write 只入队,flush 才刷出

在 Netty 的 Outbound 世界里,write 与 flush 是两个必须严格区分、却总被初学者混为一谈的操作。write 的语义只是"把消息写进发送缓冲区",即把消息追加到 ChannelOutboundBuffer 的未刷新队列里,此时数据还停留在内存中,没有真正发给对端;flush 的语义才是"把缓冲区的数据真正刷出去",它会触发底层通道把已缓冲的数据写到网络。二者分离的直接好处是:业务可以连续 write 多次,最后只 flush 一次,把多条消息攒成一次系统调用,减少昂贵的 IO 开销。

之所以要在这里强调这个语义,是因为它贯穿了 write/flush/writeAndFlush 三个方法的设计。write 与 writeAndFlush 的入口都指向 write(msg, flush, promise) 这个包级私有方法,区别只在于中间那个 flush 标志;而单独的 flush() 有自己独立的实现,直接以 findContextOutbound(MASK_FLUSH) 起步(派发写法与下面 write 块内的 flush 分支相同,区别在于它自行查找目标,而内联分支复用上一步已选中的 next):

io.netty.channel.AbstractChannelHandlerContext#write
​
void write(Object msg, boolean flush, ChannelPromise promise) {
    if (validateWrite(msg, promise)) {
        // flush=true 时,下一个 handler 实现 write 或 flush 任意一个即命中
        final AbstractChannelHandlerContext next = findContextOutbound(flush ?
                MASK_WRITE | MASK_FLUSH : MASK_WRITE);
        final Object m = pipeline.touch(msg, next);
        EventExecutor executor = next.executor();
        if (executor.inEventLoop()) {
            if (next.invokeHandler()) {
                promise = ensurePromiseUseCorrectExecutor(promise);
                try {
                    final ChannelHandler handler = next.handler();
                    final DefaultChannelPipeline.HeadContext headContext = pipeline.head;
                    if (handler == headContext) {
                        headContext.write(next, msg, promise);
                    } else if (handler instanceof ChannelDuplexHandler) {
                        ((ChannelDuplexHandler) handler).write(next, msg, promise);
                    } else if (handler instanceof ChannelOutboundHandlerAdapter) {
                        ((ChannelOutboundHandlerAdapter) handler).write(next, msg, promise);
                    } else {
                        ((ChannelOutboundHandler) handler).write(next, msg, promise);
                    }
                } catch (Throwable t) {
                    notifyOutboundHandlerException(t, promise);
                }
                if (flush) {
                    // 写完之后,若需要刷出,紧接着再沿同一方向执行 flush
                    try {
                        final ChannelHandler handler = next.handler();
                        final DefaultChannelPipeline.HeadContext headContext = pipeline.head;
                        if (handler == headContext) {
                            headContext.flush(next);
                        } else if (handler instanceof ChannelDuplexHandler) {
                            ((ChannelDuplexHandler) handler).flush(next);
                        } else if (handler instanceof ChannelOutboundHandlerAdapter) {
                            ((ChannelOutboundHandlerAdapter) handler).flush(next);
                        } else {
                            ((ChannelOutboundHandler) handler).flush(next);
                        }
                    } catch (Throwable t) {
                        next.invokeExceptionCaught(t);
                    }
                }
            } else {
                next.write(msg, flush, promise);
            }
        } else {
            final WriteTask task = WriteTask.newInstance(this, m, promise, flush);
            if (!safeExecute(executor, task, promise, m, !flush)) {
                task.cancel();
            }
        }
    }
}

这段代码的妙处全在 flush 标志上。findContextOutbound(flush ? MASK_WRITE | MASK_FLUSH : MASK_WRITE) 这行决定了查找的掩码:当只是 write(flush=false)时,只要下一个 handler 实现了 write 就能命中;当是 writeAndFlush(flush=true)时,掩码变为 MASK_WRITE | MASK_FLUSH,采用"或"匹配:下一个 handler 只要实现了 write 与 flush 中的任意一个就会命中。命中后 write 派发与 flush 派发在同一个 ctx 上连续执行,缺省的那个方法走 @Skip 默认实现自行向前转发;更关键的是跨执行器场景,写与刷会被打包进同一个 WriteTask 一起切线程,保证"写先于刷"的事件顺序不被线程边界打乱。

找到目标 handler 后,先执行 write 派发,紧接着在 if (flush) 块里再执行一次 flush 派发。两次派发都复用了同样的四分支内联写法(与 Inbound 的三分支相比,多出 ChannelOutboundHandlerAdapter 一档)。还需留意两次派发的异常处理不同:write 抛异常走 notifyOutboundHandlerException(t, promise)(让 promise 失败),flush 抛异常走 next.invokeExceptionCaught(t)(转入 Inbound 异常链),因为 flush 没有 promise 可失败,只能沿 Inbound 通知。这个细节再次印证了第五章要讲的"返回 Future 的操作与无返回值的操作,异常路径不同"。

4.2 ensurePromiseUseCorrectExecutor:让监听器跑在正确的线程

write 的派发前还有一行容易被忽略的 ensurePromiseUseCorrectExecutor(promise):

io.netty.channel.AbstractChannelHandlerContext#ensurePromiseUseCorrectExecutor
​
private ChannelPromise ensurePromiseUseCorrectExecutor(ChannelPromise promise) {
    if (promise instanceof DefaultChannelPromise && !((DefaultChannelPromise) promise).executor().inEventLoop()) {
        ChannelPromise newPromise = newPromise();
        PromiseNotifier.cascade(newPromise, promise);
        return newPromise;
    }
    return promise;
}

它的目的是统一 promise 的执行器:如果调用方传入的 promise 是 DefaultChannelPromise,且它的执行器不在当前线程,就新建一个位于当前执行器上的 newPromise,用 PromiseNotifier.cascade 把新 promise 的结果级联回原来的 promise。这样做的收益在于,后续所有加在这个 promise 上的 ChannelFutureListener 都会在新 promise 的执行器(也就是当前 handler 的线程)上执行,避免了监听器与 handler 因跑在不同线程而需要额外同步的问题。

4.3 ctx.write 与 channel.write 的起点差异

理解了方向与语义之后,就能解释一个经典问题:为什么 ctx.write() 与 channel.write() 的行为可能不同?答案是二者的起点不同。

channel.write()(即 pipeline.write())的入口是 tail.write(),从 TailContext 出发,意味着整条 Outbound 链上的所有 handler 都会依次拦截这次写。而 ctx.write() 从当前 ctx 出发,沿 prev 链回溯,当前 ctx 自身、以及更靠近 Tail 一侧的 handler(h3、TailContext)都不会参与,只有更靠近 Head 的 handler(h1、HeadContext)才会被依次拦截。用一张图表示:

Pipeline-Different.drawio.png

回到图中 ctx = h2 的那个位置(h1、h2、h3 是链表上三个示意 handler):ctx.write(msg) 从 h2 出发,只沿 prev 链经 h1 回到 HeadContext,h3 与 TailContext 完全不参与;而 channel.write(msg) 从 TailContext 起步,沿 prev 链依次经过 h3、h2、h1 抵达 HeadContext,整条链都被卷入。

这个差异在实际开发中有很实用的价值。假设我们在 h3 里实现了一个 Outbound handler,希望在 handler 内部再执行一次不受自己拦截的写操作,就应该调用 ctx.write(),让写从当前 ctx 的前驱开始回溯,从而"跳过自己以及更靠近 Tail 的 handler",避免无限递归或重复处理。反之,如果想走完整的 Outbound 链,就用 channel.write()。这是责任链"灵活跳级"能力的典型体现:同样的方向,起点不同,拦截范围就不同。

五、异常传播:Inbound 异常链与 Outbound Promise 失败

5.1 fireExceptionCaught 与 invokeExceptionCaught

异常在 Pipeline 上的传播,本质上是 Inbound 传播的一种。当某个 handler 抛出异常时,Netty 不会让异常直接冲垮 IO 线程,而是把它包装成一次 fireExceptionCaught 传播,让它像 Inbound 事件一样向后找一个"愿意处理异常"的 handler:

io.netty.channel.AbstractChannelHandlerContext#fireExceptionCaught / #invokeExceptionCaught
​
@Override
public ChannelHandlerContext fireExceptionCaught(final Throwable cause) {
    AbstractChannelHandlerContext next = findContextInbound(MASK_EXCEPTION_CAUGHT);
    ObjectUtil.checkNotNull(cause, "cause");
    if (next.executor().inEventLoop()) {
        next.invokeExceptionCaught(cause);
    } else {
        try {
            next.executor().execute(() -> next.invokeExceptionCaught(cause));
        } catch (Throwable t) {
            if (logger.isWarnEnabled()) {
                logger.warn("Failed to submit an exceptionCaught() event.", t);
                logger.warn("The exceptionCaught() event that was failed to submit was:", cause);
            }
        }
    }
    return this;
}
​
private void invokeExceptionCaught(final Throwable cause) {
    if (invokeHandler()) {
        try {
            handler().exceptionCaught(this, cause);
        } catch (Throwable error) {
            if (logger.isDebugEnabled()) {
                logger.debug(
                    "An exception " +
                    "was thrown by a user handler's exceptionCaught() " +
                    "method while handling the following exception:", cause);
            } else if (logger.isWarnEnabled()) {
                logger.warn(
                    "An exception '{}' [enable DEBUG level for full stacktrace] " +
                    "was thrown by a user handler's exceptionCaught() " +
                    "method while handling the following exception:", error, cause);
            }
        }
    } else {
        fireExceptionCaught(cause);
    }
}

fireExceptionCaught 用 findContextInbound(MASK_EXCEPTION_CAUGHT) 找下一个覆写了 exceptionCaught 的 handler,然后切线程、调 invokeExceptionCaught。invokeExceptionCaught 里是两条分支:invokeHandler() 为 true 就调用 handler().exceptionCaught(this, cause),让这个 handler 来处理异常;为 false 就继续 fireExceptionCaught(cause) 向后转发。

这里还有一个细节要留意:如果 handler 的 exceptionCaught 方法自己又抛出了异常,Netty 不会把这个新异常再次投入传播链(否则可能无限循环),而是只用 logger.warn/debug 记录一条日志了事。这体现了异常传播"只兜一次底"的设计,处理异常的过程中再出错,就不再递归传播,避免栈溢出。

5.2 Outbound 异常的两条路径

回到前面几章反复出现的差异:同样是 catch (Throwable t),Inbound 与"返回 Future 的 Outbound"走的是不同的处理。关键就在于 notifyOutboundHandlerException:

io.netty.channel.AbstractChannelHandlerContext#notifyOutboundHandlerException
​
private static void notifyOutboundHandlerException(Throwable cause, ChannelPromise promise) {
    PromiseNotificationUtil.tryFailure(promise, cause, promise instanceof VoidChannelPromise ? null : logger);
}

它做的事情非常直接:调用 PromiseNotificationUtil.tryFailure,把 cause 塞给 promise,让这个 promise 失败。之所以 Inbound 与 Outbound 要分道扬镳,根本原因在于"异常通知的载体"不同:Inbound handler 的回调方法没有返回值,也没有 promise 可以失败,所以异常只能沿 Inbound 链向后找一个 exceptionCaught 处理器;而 bind、write 这类 Outbound 操作都有 ChannelPromise,调用方本来就是通过向 promise 注册监听器来感知结果的,所以直接把异常写进 promise 失败,调用方就能在自己的 operationComplete 里拿到异常,无需再绕一圈 Inbound 传播。

但要特别留意一个反例:并非所有 Outbound 操作都有 promise。read() 与 flush() 返回的是自身而非 ChannelFuture,它们没有 promise 可失败,所以这两处的 catch 走的是 next.invokeExceptionCaught(t),即转入 Inbound 异常链。于是 Outbound 异常实际上有两条路径:有 promise 的操作让 promise 失败,无 promise 的操作转入 Inbound 异常链。第六章整体串联时会再次汇总这个规律。

5.3 TailContext 的异常兜底

如果异常沿 Inbound 链一路传下去,始终没有 handler 覆写 exceptionCaught,最终就会抵达 TailContext:

io.netty.channel.DefaultChannelPipeline.TailContext#exceptionCaught
(onUnhandledInboundException 为外部类 DefaultChannelPipeline 的 protected 方法)
​
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
    onUnhandledInboundException(cause);
}
​
protected void onUnhandledInboundException(Throwable cause) {
    try {
        logger.warn(
                "An exceptionCaught() event was fired, and it reached at the tail of the pipeline. " +
                        "It usually means the last handler in the pipeline did not handle the exception.",
                cause);
    } finally {
        ReferenceCountUtil.release(cause);
    }
}

TailContext.exceptionCaught 是异常链的最终兜底:打一条 warn 日志提示"异常无人处理,通常意味着链尾的 handler 没覆写 exceptionCaught",然后在 finally 中调用 ReferenceCountUtil.release(cause),若异常对象本身实现了 ReferenceCounted(极少数自定义异常携带引用计数资源的场景)则真正递减计数,对普通 Throwable 而言这是空操作,但这行兜底保证了任何场景都不会因异常路径而漏掉一次释放。到这里,一条异常要么在中途被某个 handler 的 exceptionCaught 接住,要么在 TailContext 处被记录并释放,两条异常路径汇合于此。

下面这张时序图汇总了 Outbound 异常与 Inbound 异常的两条路径:

六、整体链路串联

把前五章的内容串起来,Pipeline 的双向传播就呈现为一个完整的对称闭环。

在 Inbound 一侧,事件沿"底层 → 应用"的方向走 next 链:底层 IO 触发后,DefaultChannelPipeline.fireXxx 先做 inEventLoop 线程兜底,HeadContext 作为起点把事件 fire 出去,findContextInbound 配合 skipContext 沿 next 链寻找下一个目标 handler,invokeHandler 门控保证事件只在 handler 就绪后触达,内联的三分支派发把方法调用绑定到最具体的类,直到 TailContext 释放未处理的消息或记录未捕获的异常。

在 Outbound 一侧,请求沿"应用 → 底层"的方向走 prev 链:DefaultChannelPipeline 把 bind/connect/write/flush 等统一委托给 tail,findContextOutbound 沿 prev 链回溯,HeadContext 作为终点把请求交给 unsafe。其中 write 只入队、flush 才刷出,writeAndFlush 用 flush 标志把 MASK_WRITE | MASK_FLUSH 合并,在一次传播中连续完成写与刷;ctx.write() 与 channel.write() 的起点差异则决定了 Outbound 链的拦截范围。

异常一侧则是一个二分:有 promise 的 Outbound 操作异常走 notifyOutboundHandlerException 让 promise 失败,Inbound 异常与 read/flush 这类无 promise 操作的异常走 invokeExceptionCaught 转入 Inbound 链,最终在 TailContext 处告警并释放。

把这几个维度合到一处,整条链路的骨架就完整了:所有传播点都先判 inEventLoop(),不在当前线程时用 execute() 切回,保证 handler 始终串行执行;查找目标统一交给 findContextInbound/findContextOutbound,再由 skipContext(executionMask) 跳过未实现该事件的 handler;真正触达 handler 之前还要过一道 invokeHandler() 门控,通常只放行 handlerState == ADD_COMPLETE 的节点,!ordered 时 ADD_PENDING 也放行;派发本身是多分支内联写法,用以规避双接口 handler 的方法调用退化;写与刷按 write 入队、flush 刷出、writeAndFlush 合并三层分工;异常则一分为二,promise 操作走 tryFailure,无 promise 操作走 invokeExceptionCaught。

把这套机制浓缩成一句话,本质上就是 "双向对称 + 掩码跳过 + 精准派发" :next 与 prev 两个指针构成方向,executionMask 用一次位与比较完成常数级的 handler 跳过,invoke 派发保证事件精准触达,HeadContext 与 TailContext 两个哨兵封闭两端,最终让一条普通的 handler 链表,成为贯通网络输入与业务输出的双向事件通道。

全文小结

本文聚焦 Pipeline 的 Inbound 与 Outbound 事件双向传播,从传播模型与事件派发两个维度,深入分析了双向流转、查找跳过、写刷新语义分离与异常处理四条主线。

在传播模型层面,Inbound 事件沿 next 链从 HeadContext 向 TailContext 正向推进,Outbound 事件沿 prev 链从 TailContext 向 HeadContext 反向回溯,二者以 HeadContext 为分水岭形成对称闭环。在派发机制层面,findContextInbound/findContextOutbound 配合 skipContext 用 executionMask 跳过未实现该事件的 handler,invokeHandler() 门控事件只在 handlerAdded 之后触达 handler,内置的多分支内联派发规避了双接口 handler 的方法调用退化。在写刷新层面,write 只把消息写入待发送队列、flush 才触发底层刷出,writeAndFlush 用 flush 标志合并 MASK_WRITE | MASK_FLUSH,而 ctx.write() 与 channel.write() 的起点差异决定了 Outbound 链的拦截范围。在异常层面,promise 型 Outbound 异常走 notifyOutboundHandlerException 让 promise 失败,Inbound 异常与 read/flush 这类无 promise 操作走 invokeExceptionCaught 沿 Inbound 链传播,末端由 TailContext 日志告警并释放。所有传播点统一先判 inEventLoop() 再 execute 切回目标执行器,保证了 handler 串行且线程安全。


原创不易,如果本文对您有帮助,带来了些许灵感或启发,烦请动动小手点赞、关注、转发、收藏。这是作者持续更新的动力源泉,衷心感谢您的支持。我会尽量在工作之余,为大家带来更高品质的内容,努力保持周更。