本节目标:区分「关整条连接」「只关写方向」「只关读方向」三种语义,并让
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() 为 false | channelInactive、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 | 关闭后端连接池 | 立即 |
| 6 | shutdownGracefully(0, 2, SECONDS) 停 group | 2 秒 |
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 与高低水位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。