《Netty 高性能网络编程》8.3 关闭、半关闭与连接回收

关闭一条连接有 close、disconnect、shutdownOutput 三种语义,半关闭处理错会留下永远不释放的连接。本节说明 ALLOW_HALF_CLOSURE 与 ChannelInputShutdownEvent 的配合,用 ChannelGroup 批量关闭,给出 linkd-v2 GracefulShutdown 的步骤与期限,以及 SO_LINGER 触发 RST 的边界。

本节目标:区分「关整条连接」「只关写方向」「只关读方向」三种语义,并让 linkd-v2 在收到 SIGINT 后按期限收干净连接。
适用版本:JDK 25 + Netty 4.2.19.Final。

承接的问题

8.2 的 BackendPoolHandler.channelReleased 只做了一件事:连接不 active 就关掉。这个判断在真实网关里不够。libraryd 返回 Connection: close 时,TCP 仍然 active,但这条连接已经不能再借;对端发了 FIN 时,如果开了半关闭,连接同样 active,却只能写不能读。把这两种连接放回池里,下一个借用者会拿到一条「写得出、读不回」的通道。

进程退出是另一个场景。2.1 的 LinkdServer.shutdown() 先关监听 channel,再 shutdownGracefully() 两个 group;它能停掉事件循环,但不能让在途响应一定写完。8.2 的后端池也需要在 group 停止前关闭,否则池里那些连接不会被逐个关闭,EventLoop 停了也没有回调机会。

这一节把关闭拆成三层:单条连接的 close、disconnect、shutdownOutput,入站方向的半关闭事件,以及进程级的 ChannelGroup 批量关闭与期限。SO_LINGER 只讲边界,不把它当优雅关闭的开关。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。下面的日志是示意输出,实际文本以运行为准。

核心机制一:close、disconnect 与 shutdownOutput

三个方法经常被当成同义词,实际作用方向不同:

方法关闭方向通道状态典型事件
Channel.close()读 + 写关闭,isActive() 为 falsechannelInactive、closeFuture 完成
Channel.disconnect()断开连接对 NIO TCP 最终仍是关闭同 close()
SocketChannel.shutdownOutput()只关写仍然 active,可继续读对端读到 EOF,本地不触发 channelInactive

disconnect() 在语义上是「断开对端」,但 NIO 的 TCP socket 没有独立的 disconnect 系统调用,Netty 的 NIO 实现最终走的是关闭通道。不要用它表达「只关写方向」;那个语义属于 shutdownOutput()。

SocketChannel channel = (SocketChannel) ctx.channel();
channel.shutdownOutput();

shutdownOutput() 发送 FIN,对端的读循环收到 EOF;本地通道仍然可以读,直到对端也关闭。它适合「请求发完就告诉对端没有更多数据」的一次性协议:客户端发完请求后 shutdownOutput(),服务端读完请求、写完响应再关闭。调用之后本地再 writeAndFlush 会得到 ClosedChannelException,所以它只能作为发送结束的标志,不能当成「稍后还要写」的暂停。

半关闭不是默认行为。默认配置下,对端发 FIN,Netty 直接把整条 channel 关掉,触发 channelInactive;要让读方向单独关闭、写方向继续可用,必须显式打开 ALLOW_HALF_CLOSURE。

核心机制二:ALLOW_HALF_CLOSURE 与 ChannelInputShutdownEvent

ChannelOption.ALLOW_HALF_CLOSURE 是连接级选项,必须在 connect() 或 bind() 之前设置:

Bootstrap bootstrap = new Bootstrap()
        .group(eventLoop)
        .channel(NioSocketChannel.class)
        .option(ChannelOption.ALLOW_HALF_CLOSURE, true)
        .handler(initializer);

打开之后,对端 FIN 不再直接关闭通道,而是触发一个用户事件 ChannelInputShutdownEvent;读循环处理完之后,再触发 ChannelInputShutdownReadComplete。两者都不是入站消息,不会走 channelRead,必须在 userEventTriggered 里处理:

package com.plume.linkd.v2.server;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.socket.ChannelInputShutdownEvent;
import io.netty.channel.socket.ChannelInputShutdownReadComplete;

public final class HalfCloseHandler extends ChannelInboundHandlerAdapter {

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

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt == ChannelInputShutdownEvent.INSTANCE) {
            LOG.log(System.Logger.Level.INFO, "peer half-closed input, flush remaining writes");
        } else if (evt == ChannelInputShutdownReadComplete.INSTANCE) {
            ctx.close();
        } else {
            ctx.fireUserEventTriggered(evt);
        }
    }
}

两个事件的顺序和处理原则:

  • ChannelInputShutdownEvent 表示读方向已经关了。 此时还能写,适合把剩余响应刷出去;不要在这里立刻 close(),否则还在写队列里的数据会被截断。
  • ChannelInputShutdownReadComplete 表示这一轮读已经处理完。 到这里再关闭通道,对端发来的请求已经被业务 handler 看完,不会在读一半时切断。
  • 未知事件要 fireUserEventTriggered。 IdleStateHandler 的超时事件也走这条路径,吞掉它会让 7.2 的心跳失效。

上面的 handler 假设响应在 channelRead0 里同步写完,所以读循环结束时可以安全关闭。如果响应要等下游 RPC,ChannelInputShutdownReadComplete 到达时响应还没写,不能在 ctx.close();应该把关闭动作挂到响应 future 的监听器上,ReadComplete 只设置一个 inputClosed 标记。

打开半关闭后必须配一个兜底期限。对端发了 FIN 却不再关闭,或者业务 handler 因为异常没有走到 close(),这条 channel 会一直 active,占用 fd、缓冲区和 ChannelGroup 里的一个位置。linkd-v2 的做法是在收到 ChannelInputShutdownEvent 时挂一个 30 秒的 eventLoop.schedule(ctx::close, 30, TimeUnit.SECONDS),正常关闭时取消它。

配置对端 FIN 时本地还能写触发事件
默认(false)整条通道关闭否channelInactive
ALLOW_HALF_CLOSURE=true只关读方向是ChannelInputShutdownEvent + ChannelInputShutdownReadComplete

一个容易忽略的边界:半关闭只描述「对端不再发数据」,不描述「对端不再收数据」。如果对端先 shutdownOutput() 再等待响应,本地写仍然成功;如果对端直接 close(),本地下一次写会失败并触发 channelInactive。半关闭状态要通过事件和写失败共同判断,不能只看 isActive()。

核心机制三:ChannelGroup 批量关闭

进程退出时逐条遍历连接容易漏掉并发新建的连接。ChannelGroup 是一个线程安全的通道集合,channel 关闭时自动从组里移除,close() 返回的 future 在所有成员关闭后完成:

ChannelGroup connections = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
connections.add(channel);
connections.close().awaitUninterruptibly(2, TimeUnit.SECONDS);

GlobalEventExecutor.INSTANCE 是一个全局单线程执行器,只负责通知 group 的 future,不执行 channel 的读写;不要因为看到它就以为连接被挪到了别的线程。connections.add(channel) 可以在任意线程调用,channel 的 IO 仍然在它自己的 EventLoop 上。

ChannelGroup 适合「广播 + 批量关闭」,不适合做精确的请求计数:size() 是近似值,channel 关闭和移除之间有短暂窗口。在途请求数要单独用计数器维护,8.3 后面的 GracefulShutdown 就依赖一个独立的 LongSupplier。

核心机制四:SIGINT 优雅关闭的步骤与期限

优雅关闭的目标是「不再接新请求、把手上的请求做完、在期限内退出」。步骤和期限要写死,不能只依赖 shutdownGracefully() 的默认值:

步骤动作期限
1就绪探针置为 false,关闭监听 channel立即
2拒绝新请求,回 503 或 ERROR 帧并说明关闭立即
3等在途请求计数归零5 秒
4关闭 ChannelGroup 里的连接500 毫秒
5关闭后端连接池立即
6shutdownGracefully(0, 2, SECONDS) 停 group2 秒

SIGINT 和 SIGTERM 都会触发 JVM 的 shutdown hook;kill -9 不会,任何 hook 都不会执行。hook 里可以阻塞等待,但整个进程的退出时间由 hook 决定,所以每一步都要有上限。

pgrep -f 'com.plume.linkd.v2.app.LinkdV2Main'
kill -INT 48213

示意输出:

INFO  shutdown: stop accepting
INFO  shutdown: closing 3 connections
INFO  shutdown: 0 in-flight
INFO  shutdown: done

在 linkd-v2 里落地:GracefulShutdown

GracefulShutdown 只做编排,不持有业务状态。在途计数由 ConnectionRegistry 维护:请求进入时 increment(),响应写完时 decrement();关闭流程只读这个计数。

package com.plume.linkd.v2.app;

import io.netty.channel.Channel;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.group.ChannelGroup;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
import java.util.function.LongSupplier;

public final class GracefulShutdown {

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

    private final Channel serverChannel;
    private final ChannelGroup connections;
    private final LongSupplier inFlight;
    private final Runnable closeBackends;
    private final EventLoopGroup boss;
    private final EventLoopGroup worker;
    private final long graceMillis;

    public GracefulShutdown(Channel serverChannel, ChannelGroup connections,
                            LongSupplier inFlight, Runnable closeBackends,
                            EventLoopGroup boss, EventLoopGroup worker,
                            long graceMillis) {
        this.serverChannel = serverChannel;
        this.connections = connections;
        this.inFlight = inFlight;
        this.closeBackends = closeBackends;
        this.boss = boss;
        this.worker = worker;
        this.graceMillis = graceMillis;
    }

    public void run() {
        LOG.log(System.Logger.Level.INFO, "shutdown: stop accepting");
        serverChannel.close().syncUninterruptibly();

        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(graceMillis);
        while (inFlight.getAsLong() > 0 && System.nanoTime() < deadline) {
            LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20));
        }
        long left = inFlight.getAsLong();
        if (left > 0) {
            LOG.log(System.Logger.Level.WARNING,
                    "shutdown: {0} requests still in flight at deadline", left);
        }

        LOG.log(System.Logger.Level.INFO, "shutdown: closing {0} connections", connections.size());
        connections.close().awaitUninterruptibly(500, TimeUnit.MILLISECONDS);
        closeBackends.run();

        boss.shutdownGracefully(0, 2, TimeUnit.SECONDS).syncUninterruptibly();
        worker.shutdownGracefully(0, 2, TimeUnit.SECONDS).syncUninterruptibly();
        LOG.log(System.Logger.Level.INFO, "shutdown: done");
    }
}

几个设计取舍:

  • 先关监听 channel,再等在途计数。 监听 channel 关闭后不会再有新连接,但在途请求还能写完;顺序反过来会让新连接在等待期间继续进来,计数永远归不了零。
  • 在途计数归零后仍要关连接。 HTTP/1.1 的 keep-alive 连接在响应写完后仍然 active,不关就会让 group 一直有成员;connections.close() 负责这一步。
  • closeBackends 放在连接关闭之后。 后端池里的连接也承载在途请求,先等计数归零、再关连接和池,避免把还没收到响应的请求截断。
  • 关闭线程不在 EventLoop 上。 run() 由 shutdown hook 线程执行,parkNanos 阻塞它是安全的;等待循环每 20 ms 检查一次计数和期限,不会被单次等待拖过 deadline。

装配到 LinkdV2Main:

GracefulShutdown shutdown = new GracefulShutdown(
        httpChannel, connections, registry::inFlight, backends::close,
        boss, worker, 5000);
Runtime.getRuntime().addShutdownHook(new Thread(shutdown::run, "linkd-shutdown"));

registry::inFlight 返回 LongSupplier 需要的 long;backends::close 对应 8.2 的 HttpBackendPools.close(),关闭所有按 host:port 建的池。hook 只注册一次,重复注册会让关闭流程执行两遍;GracefulShutdown.run() 自身不是幂等的,重复调用会在已关闭的 group 上再次 sync。

常见错误与边界

错误一:打开 ALLOW_HALF_CLOSURE 却不设关闭期限。

// 反例:半关闭后没有任何兜底
.option(ChannelOption.ALLOW_HALF_CLOSURE, true)

对端发 FIN 后连接保持 active,业务代码如果只在 channelInactive 里清理,这条连接永远不会触发清理。fd、读缓冲、ChannelGroup 里的引用都会累积。半关闭必须和超时、IdleStateHandler 或定时关闭一起用。

错误二:用 SO_LINGER=0 做「快速优雅关闭」。

// 反例:close 走 RST,未发送的数据被丢弃
.option(ChannelOption.SO_LINGER, 0)

SO_LINGER=0 让关闭走 RST 而不是正常的 FIN 序列,发送缓冲里没发出去的数据会被丢掉,对端可能读到连接重置而不是完整的响应。它适合「确认要中止、不关心对端收到什么」的场景,不适合优雅关闭。设置正值则可能让关闭线程等待发送完成,不要在 EventLoop 上做这种关闭。

错误三:shutdownOutput() 之后继续写。

// 反例:半关闭写方向后还发响应
channel.shutdownOutput();
channel.writeAndFlush(response);

第二次写会抛 ClosedChannelException。shutdownOutput() 是「我不会再发了」的承诺,不是「暂停发送」;需要继续双向通信就不要调用它。

错误四:在 EventLoop 线程里等待 ChannelGroup.close()。

connections.close().awaitUninterruptibly(...) 会阻塞调用线程,直到所有成员关闭或超时。在 handler 或 channelInactive 里调用它会卡住当前 EventLoop,其他连接的关闭通知也发不出去。批量关闭应该由 main、shutdown hook 或独立的管理线程发起。

边界:closeFuture 完成不等于对端收到数据。 本地 close() 只表示本端通道关闭,内核发送缓冲里的数据是否送达取决于 TCP;需要确认对端处理完,应该在应用层等响应或确认帧,而不是等 closeFuture。

小结

  • close() 关闭读写两个方向,shutdownOutput() 只关写方向并保持可读;disconnect() 在 NIO TCP 上最终仍是关闭,不要拿它做半关闭。
  • ALLOW_HALF_CLOSURE 必须在连接建立前设置;打开后对端 FIN 触发 ChannelInputShutdownEvent,读循环结束再触发 ChannelInputShutdownReadComplete。
  • 半关闭事件是用户事件,不是入站消息;处理时要传播未知事件,否则 IdleStateHandler 的心跳会失效。
  • 半关闭必须配兜底期限,否则连接会一直 active,fd 和组内引用都不释放。
  • ChannelGroup 适合广播和批量关闭,channel 关闭会自动移除;在途请求计数要单独维护。
  • 优雅关闭按「停 accept、等在途归零、关连接、关池、停 group」的顺序执行,每一步都有期限;SO_LINGER=0 是中止手段,不是优雅关闭。

连接能建立、复用、关闭之后,下一章进入背压:当写速度超过对端消费速度时,Channel.isWritable() 和水位线如何让发送方慢下来。

阅读导航:上一节:8.2 连接池与多路复用 · 下一节:9.1 Channel.isWritable 与高低水位 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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