《Java 网络编程基础》14.3 优雅关闭与连接排空

停掉 accept 不等于优雅关闭:写队列没排空就退出,客户端会看到响应被截断。本节用 linkd 的 GracefulShutdown 接住 SIGINT,停止接收新连接、按连接半关闭、在期限内排空,超时后强制关闭,并给出可复现的阶段输出。

本节目标:把进程退出拆成停止 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 与连接诊断 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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