本节目标:把进程退出拆成停止 accept、等待在途请求、发 FIN、排空写队列和超时强关五步,并用一个受期限约束的关闭钩子兜住 SIGINT/SIGTERM。
适用版本:JDK 25(Temurin 25.0.5+8);ServerSocketChannel、Runtime.addShutdownHook、SocketChannel.shutdownOutput。
承接的问题
7.1 的 stop() 只关闭监听套接字,已有连接交给 JVM 退出时一起消失;7.3 给出了单连接的关闭顺序;14.1 让写队列的字节数可观测;14.2 用期限约束一次调用。把这三件事放到进程级别,就是本节的问题:收到退出信号后,怎样让已经接受的连接把手里的事情做完,又不无限期拖着不退。
滚动发布时最常见的现象是:新版本起来了,旧进程收到 SIGTERM,几毫秒后退出;正在返回的大响应只发了一半,客户端报 Connection reset by peer 或 unexpected end of stream。另一种现象相反:进程一直不退,负载均衡器还在往它转发,最后编排系统等到宽限期结束发 SIGKILL,连接同样被硬切。
三个时间尺度要分清:7.3 的空闲检测按连接算(分钟级),14.2 的请求期限按调用算(秒级),本节的关闭期限按进程算(秒到几十秒)。关闭期限必须比编排系统给的宽限期短,否则优雅关闭还没结束就被 SIGKILL。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。
核心机制一:SIGINT、SIGTERM 与关闭钩子
终端里 Ctrl-C 发 SIGINT,kill <pid> 默认发 SIGTERM,关闭终端窗口发 SIGHUP。这三者都会触发 JVM 的 shutdown hook;kill -9 发 SIGKILL,内核直接终止进程,钩子没有机会运行。以 -Xrs 启动时,JVM 会减少对操作系统信号的使用,SIGTERM 不再走正常关闭流程,这一点在容器里排查「钩子没执行」时经常被忽略。
钩子的执行模型有几个约束:
- 钩子在新线程里运行,和应用线程并发。 钩子开始时 accept 线程、事件循环线程都还活着,不能假设它们已经停下。
- JVM 等所有钩子结束后才 halt。 非守护线程不会延长进程寿命;钩子返回后,即使业务线程还在跑,进程也会退出。
- 钩子里不能调用
System.exit。 JVM 已经处于关闭序列,System.exit会等待关闭序列完成,而关闭序列正在等这个钩子,结果是永久阻塞。 - 多个钩子并发执行,顺序未定义。 不要依赖「另一个钩子已经关掉了线程池」。
注册只需要一行:
Runtime.getRuntime().addShutdownHook(
new Thread(this::shutdown, "linkd-shutdown"));
但钩子只负责「进程要退了」这件事。负载均衡器什么时候停止转发,钩子控制不了。容器里的正确顺序是:先把 readiness 探针置为失败(或执行 preStop),等端点从转发列表里摘除,再发 SIGTERM。少了前面这一步,钩子刚开始排空,新请求还在进来,队列永远排不空。
核心机制二:停止 accept 与连接快照
停止接收新连接靠关闭监听套接字。ServerSocketChannel.close() 是线程安全的,并且会让阻塞在 select() 的 accept 线程返回;随后 accept() 抛 AsynchronousCloseException。这不是故障,是关闭流程的正常分支。
accept 循环要把「关闭导致的异常」和「真正的错误」分开:
try (Selector selector = Selector.open()) {
listener.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) {
SocketChannel ch;
while ((ch = listener.accept()) != null) {
ch.configureBlocking(false);
loop.register(ch, readerFactory.create(ch));
}
}
}
selector.selectedKeys().clear();
}
} catch (AsynchronousCloseException e) {
if (listener.isOpen()) {
throw e; // 不是关闭流程触发的,按故障处理
}
} catch (ClosedChannelException e) {
if (listener.isOpen()) {
throw e;
}
}
listener.isOpen() 是判据:关闭流程已经把监听套接字关掉,此时异常是预期的;如果套接字还开着却收到 AsynchronousCloseException,说明有别的地方在并发关闭,应该记录并暴露。
连接快照必须在连接所属的事件循环线程里取。多 Reactor 下,每个 EventLoop 维护自己的连接集合,关闭线程直接遍历会撞上 ConcurrentModificationException,或者看到一半迁移状态的连接。GracefulShutdown 对每个循环投递一个任务,任务内部再取快照,这样读写仍然只发生在该循环线程。
核心机制三:排空、半关闭与关闭期限
「排空」容易被理解成「对端确认收到」。进程退出时做不到,也不需要:只要用户态写队列为空、没有在途请求,剩下的字节已经在内核发送缓冲区里,close() 会把它们按序发出,FIN 排在最后。本节的排空判据是没有在途请求,且写队列没有待写字节。
只用 pending == 0 不够。HTTP 请求解析完、业务线程还在查库时,写队列也是 0;这时半关闭会把响应通道关掉,业务算完再 enqueue 已经无处可写。所以关闭流程需要一个 idle 判据:连接当前没有正在处理的请求。这个状态由 HTTP 层在派发请求时置忙、在响应入队后置闲,GracefulShutdown 只读它。
对已经空闲的连接,动作是半关闭:
try {
c.channel().shutdownOutput();
} catch (IOException ignored) {
// 对端已复位或通道已关闭,读侧会在 EOF 后走 close()
}
shutdownOutput() 发送 FIN,表示「本端不再写」,但读方向仍然打开。对端收到 FIN 后关闭自己,本端 read 返回 -1,7.2 的 AccumulatingReader 走 c.close(),连接才真正释放。先半关闭再等对端关闭,而不是直接 close(),原因是:如果本端还有未读的入站数据,直接关闭可能让内核发 RST,RST 会让对端丢弃已经收到但还没读走的数据,包括我们刚发出去的响应。半关闭给了对端一个正常收尾的机会。
半关闭不能无限等。关闭期限从停止 accept 开始算,覆盖「等在途请求完成 + 排空写队列 + 等对端 FIN」三段时间。超过期限还没结束的连接,强制 close():写队列里的缓冲区由 Connection.close() 逐个释放,未写出的字节丢弃,日志记录连接对端地址和剩余字节数。
期限怎么定:编排系统给的宽限期是硬上限。Kubernetes 的 terminationGracePeriodSeconds 默认 30 秒,如果 preStop 用 5 秒,关闭钩子最多只剩 25 秒;把 drainTimeout 设成 8 秒,留出足够余量。这个数字是配置约束,不是实测值,部署前要按自己的镜像启动时间和请求尾部延迟调整。
SO_LINGER 要特别小心。setSoLinger(true, 0) 会让 close() 立即发 RST,而不是正常四次挥手;它适合「明确要中止」的场景,不适合优雅关闭。用了它,前面排空的字节可能被对端直接丢掉。
| 阶段 | 动作 | 失败时的表现 |
|---|---|---|
| 停止 accept | 关闭监听套接字 | 新连接被拒绝,已有连接不受影响 |
| 等在途请求 | 查 idle 判据,忙的连接跳过 | 响应还没入队就被关闭 |
| 半关闭 | shutdownOutput() 发 FIN | 对端一直不回 FIN,占着连接 |
| 排空写队列 | 等 pending 归零 | 对端不读,队列增长到硬上限 |
| 强制关闭 | close() 释放队列 | 未写字节丢失,客户端看到截断 |
在 linkd 里落地:GracefulShutdown
GracefulShutdown 把上面五步收进一个类:构造时注入监听套接字、事件循环列表、每循环的连接快照、待写字节数和空闲判据,install(afterDrain) 注册钩子,shutdown() 执行排空并返回是否在期限内完成。
package com.plume.linkd.app;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.EventLoop;
import java.io.IOException;
import java.nio.channels.ServerSocketChannel;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToLongFunction;
public final class GracefulShutdown {
public enum Phase { RUNNING, STOP_ACCEPTING, DRAINING, DONE }
private final ServerSocketChannel listener;
private final List<EventLoop> loops;
private final Function<EventLoop, Collection<Connection>> connectionsOf;
private final ToLongFunction<Connection> pendingBytes;
private final Predicate<Connection> idle;
private final long drainTimeoutMillis;
private final long pollMillis;
private final Set<Connection> halfClosed = ConcurrentHashMap.newKeySet();
private final AtomicBoolean started = new AtomicBoolean();
private volatile Phase phase = Phase.RUNNING;
private volatile boolean drained;
private volatile int forcedClosed;
public GracefulShutdown(ServerSocketChannel listener,
List<EventLoop> loops,
Function<EventLoop, Collection<Connection>> connectionsOf,
ToLongFunction<Connection> pendingBytes,
Predicate<Connection> idle,
long drainTimeoutMillis,
long pollMillis) {
if (drainTimeoutMillis <= 0 || pollMillis <= 0 || pollMillis >= drainTimeoutMillis) {
throw new IllegalArgumentException("expect 0 < pollMillis < drainTimeoutMillis");
}
this.listener = listener;
this.loops = List.copyOf(loops);
this.connectionsOf = connectionsOf;
this.pendingBytes = pendingBytes;
this.idle = idle;
this.drainTimeoutMillis = drainTimeoutMillis;
this.pollMillis = pollMillis;
}
public void install(Runnable afterDrain) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
shutdown();
} finally {
afterDrain.run();
}
}, "linkd-shutdown"));
}
public boolean shutdown() {
if (!started.compareAndSet(false, true)) {
return drained;
}
phase = Phase.STOP_ACCEPTING;
closeListener();
phase = Phase.DRAINING;
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(drainTimeoutMillis);
drained = drain(deadline);
if (!drained) {
forceClose();
}
phase = Phase.DONE;
return drained;
}
private void closeListener() {
try {
listener.close();
} catch (IOException ignored) {
// 已经关闭;accept 线程随后会收到 AsynchronousCloseException
}
}
private boolean drain(long deadline) {
while (System.nanoTime() < deadline) {
AtomicInteger remaining = new AtomicInteger();
CountDownLatch swept = new CountDownLatch(loops.size());
for (EventLoop loop : loops) {
loop.execute(() -> {
try {
sweep(loop, remaining);
} finally {
swept.countDown();
}
});
}
try {
swept.await(pollMillis, TimeUnit.MILLISECONDS);
if (remaining.get() == 0) {
return true;
}
Thread.sleep(pollMillis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
private void sweep(EventLoop loop, AtomicInteger remaining) {
for (Connection c : connectionsOf.apply(loop)) {
if (!idle.test(c) || pendingBytes.applyAsLong(c) > 0) {
remaining.incrementAndGet();
continue;
}
if (halfClosed.add(c)) {
halfClose(c);
}
}
}
public void halfClose(Connection c) {
try {
c.channel().shutdownOutput();
} catch (IOException ignored) {
// 对端已复位或通道已关闭,读侧会在 EOF 后走 close()
}
}
private void forceClose() {
AtomicInteger count = new AtomicInteger();
CountDownLatch closed = new CountDownLatch(loops.size());
for (EventLoop loop : loops) {
loop.execute(() -> {
try {
for (Connection c : connectionsOf.apply(loop)) {
c.close();
count.incrementAndGet();
}
} finally {
closed.countDown();
}
});
}
try {
closed.await(pollMillis, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
forcedClosed = count.get();
}
public Phase phase() {
return phase;
}
public int forcedClosed() {
return forcedClosed;
}
}
装配时,idle 接到 HTTP 层的「当前是否有在途请求」,afterDrain 用来按顺序停业务线程池:
GracefulShutdown shutdown = new GracefulShutdown(
listener, loops, registry::connectionsOf, registry::pendingBytes,
registry::isIdle, 8_000, 50);
shutdown.install(() -> {
businessPool.shutdown();
try {
businessPool.awaitTermination(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
顺序不能反:先让网络层排空,业务线程池才有机会把最后一批响应入队;先 shutdownNow() 业务池,正在执行的请求被中断,响应永远不会入队,网络层只能等到期限再强关。shutdown() 用 AtomicBoolean 实现幂等,正常退出路径和钩子同时调用也只执行一次。
本机用真实 loopback 连接跑两条路径,drainTimeout 分别为 300 ms 和 200 ms,poll 为 50 ms。输出如下:
graceful: drained=true phase=DONE forcedClosed=0 stillRegistered=2 anyClosed=false
second call = true
forced: drained=false phase=DONE forcedClosed=1 stillRegistered=0
- 第一条:两个空闲连接在第一次扫描时被半关闭,
drained=true;anyClosed=false说明只发了 FIN,真正的close()留给读侧收到 EOF 后执行。 - 第二条:重复调用
shutdown()返回同一个结果,没有二次关闭。 - 第三条:连接一直有在途请求,超过 200 ms 期限后被强制关闭,
forcedClosed=1,注册表清空。
输出里的 stillRegistered=2 是演示用注册表没有在读侧移除连接,真实 linkd 由 AccumulatingReader 在 read 返回 -1 时调用 close(),注册表随之清空。
常见错误与边界
错误一:在关闭钩子里调用 System.exit。
// 反例:钩子等关闭序列,关闭序列等钩子
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
shutdown();
System.exit(0);
}));
JVM 已经在关闭序列中,System.exit 会阻塞等待序列完成,而这个钩子正是序列的一部分,进程永远退不出去。钩子正常返回即可,退出码由触发关闭的路径决定。
错误二:排空没有期限。
// 反例:对端不读就一直等
while (pendingBytes.getAsLong() > 0) {
Thread.sleep(50);
}
对端不读、网络中断、业务死锁,任何一种都会让进程永远停在这里;编排系统等到宽限期结束发 SIGKILL,客户端看到的还是硬切,而且比直接退出更晚。排空必须有 deadline,超时走强制关闭。
错误三:关闭监听套接字后,accept 循环把异常记为 ERROR 并重启监听。
// 反例:关闭流程被当成故障,监听又被打开
catch (AsynchronousCloseException e) {
logger.error("accept failed", e);
listener = ServerSocketChannel.open();
listener.bind(address);
}
关闭期间监听套接字被关掉是预期行为,重启监听会让「停止接收新连接」失效,负载均衡器又能连进来。用 listener.isOpen() 区分预期关闭和真故障。
错误四:排空时还在接受 keep-alive 上的新请求。 HTTP/1.1 连接复用意味着「停止 accept」只挡住新连接,已有连接还能继续发请求。进入关闭流程后,响应要带 Connection: close,让客户端不再复用;WebSocket 要先发 Close 帧(1001 going away),再等对端回 Close。否则业务不断产生新响应,pending 永远不归零。
边界:钩子不能延长编排系统的宽限期。 钩子里睡多久,编排系统都可能提前 SIGKILL;真正的上限是 terminationGracePeriodSeconds,drainTimeout 必须比它小,并给 preStop 和进程启动留余量。
小结
- Ctrl-C、
kill、关闭终端都会触发 shutdown hook;kill -9和-Xrs不会,钩子里不能调System.exit。 - 优雅关闭的顺序是:停止 accept、等在途请求结束、半关闭发 FIN、排空写队列、超时强制关闭。
- 排空判据是「没有在途请求且写队列为空」,不是「对端已确认」;只看
pending会把业务还没入队的响应关掉。 - 半关闭用
shutdownOutput(),读方向继续,等对端 FIN 后由读侧close();直接关闭可能因未读数据触发 RST。 - 关闭期限从停止 accept 开始算,必须小于编排系统宽限期;超时后强关并记录剩余字节。
GracefulShutdown用AtomicBoolean实现幂等,afterDrain按「先网络、后业务池」的顺序收尾。
到这里,背压、超时重试和优雅关闭都有了可运行的实现。下一章换到验证手段:用tcpdump、netstat和lsof观察连接状态、重传和关闭过程,把这一章的结论落到可复现的命令上。
阅读导航:上一节:14.2 超时、重试与幂等边界 · 下一节:15.1 tcpdump、netstat 与连接诊断 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。