《Java 网络编程基础》3.3 半关闭、RST 与优雅关闭

客户端发完请求只是半关闭,服务端要读到 EOF 才能回响应。本节用 linkd 的 Echo 连接实测 shutdownOutput、SO_LINGER(0) 触发的 RST、Connection reset 与 Broken pipe 的差异,并给出停止 accept、排空写队列、半关闭再 close 的优雅关闭顺序。

本节目标:分清 shutdownOutput、close 和 RST 三种结束方式,能按正确顺序停掉一个正在服务中的连接。
适用版本:JDK 25(Temurin 25.0.5+8);Socket.shutdownOutput、setSoLinger、close。

承接的问题

3.2 解决了「等多久」,但没有解决「怎么结束」。linkd 的 Echo 客户端如果发完请求就直接 close(),服务端可能还在读;服务端如果响应没写完就 close(),客户端可能只收到半截。更糟的是,关闭方式不对会触发 RST,把内核里还没发出去的数据直接丢掉。

先看一个具体场景。客户端要实现「发完请求就告诉服务端没有更多数据了,但仍然等响应」。这不是 close() 能表达的:close() 同时关掉读和写,关掉读之后响应也收不到了。TCP 本身支持只关一个方向,Java 把它暴露成 shutdownOutput() 和 shutdownInput()。

另一个场景是停机。linkd 收到 SIGINT 后不能立刻 System.exit,否则正在写的响应会丢,客户端看到的是连接重置。正确顺序是:停止 accept、让在途请求写完、半关闭、再关闭连接。这个顺序在 14.3 会放到 NIO 事件循环里重写,本节先用阻塞模型把语义跑通。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

核心机制一:shutdownOutput 只发 FIN,不关读方向

shutdownOutput() 在 TCP 层发送一个 FIN,表示「本端不会再发送数据」。对端读到流的末尾,read 返回 -1;本端仍然可以继续读对端发来的数据。shutdownInput() 相反,它让本端的 read 立即返回 -1,后续到达的数据被丢弃。

一个完整的半关闭往返:

try (Socket socket = new Socket("127.0.0.1", port)) {
    socket.getOutputStream().write("hello".getBytes(StandardCharsets.UTF_8));
    socket.getOutputStream().flush();
    socket.shutdownOutput();
    System.out.println("outputShutdown=" + socket.isOutputShutdown()
            + " inputShutdown=" + socket.isInputShutdown());

    byte[] buffer = new byte[64];
    int n = socket.getInputStream().read(buffer);
    System.out.println("client read=" + new String(buffer, 0, n, StandardCharsets.UTF_8).trim());
}

服务端循环读到 -1,确认请求结束,再写响应:

ByteArrayOutputStream request = new ByteArrayOutputStream();
byte[] buffer = new byte[64];
int n;
while ((n = socket.getInputStream().read(buffer)) != -1) {
    request.write(buffer, 0, n);
}
System.out.println("server read " + request.size() + " bytes, EOF");
socket.getOutputStream().write("world\n".getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().flush();

本机输出:

server read 5 bytes, EOF, request=hello
client outputShutdown=true inputShutdown=false
client read=world
write after shutdownOutput: java.net.SocketException: Socket output is shutdown

四行输出对应四个事实:

  • 服务端在收到 FIN 后读到 -1,此时它已经拿到了完整的 5 个字节。
  • isOutputShutdown() 为 true,isInputShutdown() 仍为 false,两个方向独立。
  • 客户端在关闭写方向之后仍然读到了 world,半关闭没有破坏读方向。
  • 再往输出流写会抛 SocketException: Socket output is shutdown,这是本地状态检查,不是网络错误。

FIN 排在已排队数据之后。调用 shutdownOutput() 时,内核会先把发送缓冲区里的数据发完,再发 FIN;不会把前面的数据截断。真正会丢数据的是 RST,下面单独讲。

半关闭适合「用 EOF 表示请求结束」的协议,比如没有 Content-Length 的旧式请求、nc 手工调试、某些自定义的请求-响应协议。HTTP/1.1 有 Content-Length 或 chunked,服务端不需要等 EOF 就知道请求多长;这时半关闭不是必需,反而可能让 keep-alive 连接提前结束。

shutdownInput() 要慎用。它不会告诉对端「别发了」,只是本端丢弃后续数据并让 read 返回 -1。如果对端还在写,数据会被内核确认后丢掉,双方对「发了多少」的认知不一致。想排空输入,正确做法是循环读到 EOF,而不是 shutdownInput。

核心机制二:RST 是怎么被触发的

RST 是 TCP 的复位报文,语义是「这个连接立即作废」。它不保证任何未发送的数据被送达,收到 RST 的一方会把缓冲区里的数据丢掉。常见的触发条件有四类:

  1. 连接一个没有监听的端口。 内核直接回 RST,客户端得到 ConnectException: Connection refused。
  2. 向一个已经彻底关闭的连接写数据。 对端已经 close(),本端再写,对端回 RST;本端的下一次写或读得到 Broken pipe 或 Connection reset。
  3. 用 SO_LINGER(true, 0) 关闭。 close() 不发 FIN,直接发 RST,发送缓冲区里的数据被丢弃。
  4. 关闭时接收缓冲区里还有未读数据。 部分实现会用 RST 代替 FIN,避免对端误以为数据已被处理。这个行为依赖操作系统,不能当成跨平台保证。

本机把前三种跑一遍(节选,省略了服务端 accept 与异常捕获的样板代码):

// 情形一:客户端带 RST 关闭,服务端先读到已缓冲的数据,再收到复位
Socket client = new Socket("127.0.0.1", port);
client.getOutputStream().write("hello".getBytes());
client.getOutputStream().flush();
client.setSoLinger(true, 0);
client.close();

// 情形二:服务端带 RST 关闭,客户端再写
accepted.setSoLinger(true, 0);
accepted.close();

// 情形三:连接一个刚关闭的端口
try (Socket probe = new Socket()) {
    probe.connect(new InetSocketAddress("127.0.0.1", closedPort), 1000);
}

本机输出:

client closed with SO_LINGER(0)
server first read n=5
server read: java.net.SocketException: Connection reset
server closes with SO_LINGER(0)
client write: java.net.SocketException: Broken pipe
connect closed port: java.net.ConnectException: Connection refused (connect failed)

三个报错对应三个位置,不要混:

  • Connection reset 出现在读。 服务端第一次 read 仍然拿到了 RST 之前已经排队的 5 个字节,第二次 read 才抛复位。RST 不会抹掉已经交付给应用的数据,但会终结后续读取。
  • Broken pipe 出现在写。 对端已经 RST,本端再写,内核用 SIGPIPE/EPIPE 告诉进程「管道断了」。JDK 把它翻译成 SocketException: Broken pipe。
  • Connection refused 出现在 connect。 这是握手阶段被拒绝,连接根本没建立,和已建立连接后的 RST 是两回事。

JDK 的异常消息在不同平台可能略有差异,但类型和阶段是稳定的:ConnectException 对应建连失败,读方向的 SocketException: Connection reset 对应收到 RST,写方向的 SocketException: Broken pipe 对应 EPIPE。日志里只记 IOException 会把这三类混成一类,排障时无法区分。

核心机制三:close 与 SO_LINGER

默认的 close() 是优雅关闭:把发送缓冲区里的数据交给内核,发 FIN,然后释放文件描述符。close() 通常立即返回,内核在后台完成剩余工作。getSoLinger() 返回 -1 表示 linger 关闭,这是默认值。

setSoLinger(true, 0) 改变这个行为:close() 立即发 RST,丢弃发送缓冲区里未发送的数据,不等待对端确认。它经常被误用成「避免 TIME_WAIT 的技巧」,代价是:

  • 客户端可能还没读到响应,连接就被复位,表现为 Connection reset。
  • 代理和负载均衡器会把这种断开记为异常,触发重试或告警。
  • 如果请求不是幂等的,重试可能产生重复副作用。

setSoLinger(true, n) 是另一种语义:close() 最多阻塞 n 秒,等数据发完;超时后仍未发完的部分被丢弃,并发 RST。它会阻塞调用线程,不适合放在事件循环或请求处理线程里。

TIME_WAIT 本身不是需要消灭的东西。它让旧连接的迟到报文不会串到新连接,也让对端有机会完成关闭。服务端重启时端口暂时不能 bind,用 SO_REUSEADDR 解决(3.1 已实测),不需要用 linger 0 把连接打成 RST。

一张表总结三种结束方式:

方式发出的报文未发送数据还能读吗典型用途
shutdownOutput()FIN先发完再发 FIN能请求结束、等响应
close()FIN交给内核发送不能正常结束
setSoLinger(true,0) + close()RST丢弃不能明确的异常中止

在 linkd 里落地:停止 accept、排空写队列、再关闭

优雅关闭的顺序可以拆成四步,顺序不能反:

  1. 停止 accept。 把 running 置 false,并 close() 监听套接字,让阻塞在 accept 的线程立刻返回。accept 会抛 SocketException: Socket closed,这是正常停止信号。
  2. 等待在途请求写完。 阻塞模型里,「写队列」就是正在执行的 handler 线程。用一个计数器记录在途连接,等待它归零,最多等一个宽限期。
  3. 半关闭。 如果第 2 步在宽限期内完成,对剩余的空闲连接调用 shutdownOutput(),发出 FIN,告诉客户端「不会再有数据了」。如果宽限期到了还有 handler 在写,跳过这一步,避免把正在写的响应打断。
  4. 关闭连接。 调用 close() 释放描述符;如果宽限期已过仍有连接没结束,这一步就是强制切断。

用一个注册表记录连接和在途计数:

package com.plume.linkd.app;

import java.io.IOException;
import java.net.Socket;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public final class ConnectionRegistry {

    private final Set<Socket> open = ConcurrentHashMap.newKeySet();
    private final AtomicInteger inFlight = new AtomicInteger();

    public void register(Socket socket) {
        open.add(socket);
        inFlight.incrementAndGet();
    }

    public void release(Socket socket) {
        if (open.remove(socket)) {
            inFlight.decrementAndGet();
        }
    }

    public boolean awaitIdle(Duration grace) throws InterruptedException {
        long deadline = System.nanoTime() + grace.toNanos();
        while (inFlight.get() > 0 && System.nanoTime() < deadline) {
            Thread.sleep(20);
        }
        return inFlight.get() == 0;
    }

    public void halfCloseAll() {
        for (Socket socket : open) {
            try {
                socket.shutdownOutput();
            } catch (IOException ignored) {
                // 已经关闭或已复位,继续处理其他连接
            }
        }
    }

    public void closeAll() {
        for (Socket socket : open) {
            try {
                socket.close();
            } catch (IOException ignored) {
                // 关闭失败不影响后续清理
            }
        }
        open.clear();
        inFlight.set(0);
    }
}

awaitIdle 是宽限期的核心:它不强制中断 handler,只等到计数归零或时间用完。返回 false 表示还有连接没结束,调用方要决定是继续等还是强制关闭。生产环境通常给 5–10 秒,和编排系统的 terminationGracePeriodSeconds 对齐。

3.1 里的 BlockingEchoServer 用 Set<Socket> connections 记录连接,这里把它替换成 ConnectionRegistry:字段改成 private final ConnectionRegistry registry = new ConnectionRegistry();,accept 之后调用 registry.register(socket),handler 的 finally 里调用 registry.release(socket)。stop() 也从无参版本扩展成带宽限期的版本:

private final ConnectionRegistry registry = new ConnectionRegistry();

BlockingEchoServer.stop 按四步执行:

public boolean stop(Duration grace) {
    running = false;
    try {
        if (server != null) {
            server.close();
        }
    } catch (IOException ignored) {
        // 监听套接字可能已经关闭
    }
    try {
        boolean idle = registry.awaitIdle(grace);
        if (idle) {
            registry.halfCloseAll();
        }
        registry.closeAll();
        return idle;
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        registry.closeAll();
        return false;
    }
}

halfCloseAll 只在 awaitIdle 返回 true 时执行,这个条件是有意的:如果先半关闭,或者宽限期到了还对在途连接发 FIN,正在写响应的 handler 会立刻拿到 Socket output is shutdown,响应就丢了。确认没有在途请求之后,才对剩下的空闲连接发 FIN;超时则直接 closeAll 强制切断。

到第 6 章,ConnectionRegistry 的角色会由 com.plume.linkd.core.Connection 承担。本卷约定 Connection.close() 按「停止接收新数据、等待写队列排空、关闭通道」的语义执行。阻塞版先用注册表把顺序固定下来,换成 NIO 时只替换等待机制,不改变关闭语义。

LinkdMain 把关闭挂到 JVM 的 shutdown hook 上:

Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    boolean idle = server.stop(Duration.ofSeconds(10));
    if (!idle) {
        System.getLogger("linkd").log(System.Logger.Level.WARNING,
                "grace period expired, remaining connections closed");
    }
}, "linkd-shutdown"));

收到 SIGINT 或 SIGTERM 后,JVM 启动 hook,stop 关闭监听、等待在途请求、半关闭、关闭连接。hook 本身不设超时,宽限期由 stop 的参数控制;如果编排系统只给 10 秒,这里就不要设 30 秒,否则进程会在 hook 执行到一半时被 SIGKILL。

常见错误与边界

错误一:shutdownOutput() 之后继续写。 本机会抛 java.net.SocketException: Socket output is shutdown。半关闭是单向的终态,不能再恢复;需要双向通信就不要提前关输出。

错误二:用 setSoLinger(true, 0) 规避 TIME_WAIT。 它发的是 RST,不是 FIN,未发送的数据会被丢弃。客户端可能只收到半个响应,日志里出现 Connection reset。端口复用应该用 SO_REUSEADDR,不是 linger 0。

错误三:把 Connection reset 当成可重试的普通网络错误。 复位发生时,请求可能已经被对端执行,只是响应丢了。对 POST /loans 盲目重试会生成两笔借阅;重试需要幂等键,14.2 会展开。

错误四:关闭前不排空接收缓冲区。 如果客户端发了额外数据而服务端没读,部分实现会在 close() 时发 RST,把还没发出去的响应一起丢掉。请求-响应协议要么读到 EOF,要么按 Content-Length 精确读完,再关闭。

错误五:用 shutdownInput() 排空输入。 它只是让本端 read 返回 -1 并丢弃后续数据,不会通知对端停止发送。双方对已发送字节数的认知会不一致,调试时表现为「客户端说发了,服务端说没收到」。

边界:半关闭不是所有协议的通用收尾。 HTTP/1.1 的 keep-alive 连接依赖双方保持双向打开;服务端在响应后 shutdownOutput() 会让连接无法复用。只有在协议明确用 EOF 分隔消息,或者客户端主动半关闭时,才适合这么做。

边界:close() 不保证对端已经收到数据。 它只保证数据进入内核发送队列。进程崩溃、机器断电、或 linger 0,都会让队列里的数据消失。需要确认送达,必须在应用层做响应确认,不能靠 close() 的返回值。

小结

  • shutdownOutput() 发 FIN,对端读到 -1,本端仍可读;之后再写抛 Socket output is shutdown。
  • shutdownInput() 只让本端读到 -1 并丢弃后续数据,不通知对端,不能用来「排空」。
  • RST 的三种常见触发:连接无监听端口、向已关闭连接写、SO_LINGER(true,0) 关闭;本机分别得到 Connection refused、Broken pipe、Connection reset。
  • Connection reset 出现在读,Broken pipe 出现在写;排障时不要都归成 IOException。
  • close() 默认发 FIN;linger 0 发 RST 并丢数据,不是避免 TIME_WAIT 的正确手段。
  • 优雅关闭顺序是停止 accept、等待在途请求写完、半关闭、关闭连接;宽限期要和编排系统对齐。

到这里,连接的建立、等待和结束都走了一遍。但 Echo 只回写字节,没有回答「一条消息到哪里结束」。下一节从字节序和字符集开始,把字节到字符、再到消息边界的转换补齐。

阅读导航:上一节:3.2 连接、读、写超时 · 下一节:4.1 字节序、字符集与边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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