本节目标:分清
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 的一方会把缓冲区里的数据丢掉。常见的触发条件有四类:
- 连接一个没有监听的端口。 内核直接回 RST,客户端得到
ConnectException: Connection refused。 - 向一个已经彻底关闭的连接写数据。 对端已经
close(),本端再写,对端回 RST;本端的下一次写或读得到Broken pipe或Connection reset。 - 用
SO_LINGER(true, 0)关闭。close()不发 FIN,直接发 RST,发送缓冲区里的数据被丢弃。 - 关闭时接收缓冲区里还有未读数据。 部分实现会用 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、排空写队列、再关闭
优雅关闭的顺序可以拆成四步,顺序不能反:
- 停止 accept。 把
running置 false,并close()监听套接字,让阻塞在accept的线程立刻返回。accept会抛SocketException: Socket closed,这是正常停止信号。 - 等待在途请求写完。 阻塞模型里,「写队列」就是正在执行的 handler 线程。用一个计数器记录在途连接,等待它归零,最多等一个宽限期。
- 半关闭。 如果第 2 步在宽限期内完成,对剩余的空闲连接调用
shutdownOutput(),发出 FIN,告诉客户端「不会再有数据了」。如果宽限期到了还有 handler 在写,跳过这一步,避免把正在写的响应打断。 - 关闭连接。 调用
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 字节序、字符集与边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。