开始前,会这些就够了
- 知道客户端和服务端通过连接发送字节。
- 能读懂 Python bytes、循环与异常处理。
- 本机允许使用 127.0.0.1 的临时 TCP 端口。
- 需要补基础时:先区分 TCP 字节流与应用消息
一个小服务约定读到请求方向结束后再汇总回复。客户端发送完 order=7,随即开始等回复;服务端却还在等后续内容。客户端代码已经没有更多 send,这件事不会自动变成对端能观察到的结束信号。
本实验按固定顺序操作同一条真实 TCP 连接的两端。先看到七字节都到达,但继续读取只会超时;然后只关闭客户端发送方向,服务端才读到 EOF,并仍能把回复传回客户端。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。另附可选 Java 标准库版本,需 JDK 17 或更新版本;实测 JDK 17 与 21。下方命令运行基础 Python 标准库示例;可选对照的环境和命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 14 个文件包里有什么
- demo.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
- java/:Java 源码、中文 README、JDK 17/21 输出和验证记录
- supplement-verification.json:附加实现的独立复跑记录
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B demo.py解压到独立练习文件夹,在含 demo.py 的目录打开终端运行。Windows本机127.0.0.1临时TCP连接,发送方向EOF、反向回复与5项检查;未测试HTTP、TLS或公网 压缩包的 java/ 子目录另附 Java 标准库版本、中文 README、两份实测输出和验证记录;进入 java/ 后按 README 编译运行。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
TCP 的两个方向可以分别结束
把一条 TCP 连接想成两条有方向的字节通道:客户端发往服务端,以及服务端发往客户端。某一方向以后不再发送,不要求另一方向也同时停止。正常关闭可以分步骤完成,这就是半关闭的基本含义。
Python 的 shutdown(SHUT_WR) 表示此端不再发送;它没有关闭本地接收方向。协议正常推进后,对端读完此前字节,会从接收操作观察到这一方向的结束。脚本随后仍在原客户端套接字上接收回复。
close 则是释放本地套接字资源的操作,不能拿它代替“我不再发,但还要继续读”的意图。实际关闭方式也会受剩余数据和平台状态影响,所以应明确需要关闭哪个方向,再完成资源释放。
没有新字节、读取超时和 EOF 不是同一事实
客户端先 sendall(b'order=7')。服务端用循环收齐这份实验已知的七字节,再尝试多读一个字节。这时客户端尚未半关闭,也没有安排更多写入,所以观察到的是保护等待超时,不是 EOF。
sendall 返回表示这次发送调用完成了它负责的字节发送过程,不替你确认远端应用已经完成业务,更不会自动告诉对端“整个请求到此结束”。如果协议采用长度或分隔符,还需按那个协议来识别请求边界。
本例用已知长度只为稳定地把探针放在七字节都读完之后;随后研究的是结束信号本身。它不是完整的生产请求解析器,也没有把一次 recv 恰好返回多少当作消息边界。
发送方向结束后,接收方向继续完成回复
客户端执行 SHUT_WR 后,服务端下一次 recv 返回空字节串,说明当前这个 TCP 接收方向已正常读到 EOF。服务端仍发送 accepted:7,并关闭自己的发送方向。客户端循环读取回复,最后也观察到对方方向的 EOF。
程序还尝试在客户端半关闭之后再次发送 late,确认这条方向不能重新开启。半关闭不是一次暂停按钮;如果应用打算在同一方向继续发送下一项请求,就不应把 SHUT_WR 当作每条消息的普通分隔符。
收到 EOF 同样不代表业务一定成功。真正的接受结果来自应用定义的回复内容;这次脚本同时检查完整回复字节和读取结束,避免把传输生命周期与业务处理结论混成一项检查。
是否用 EOF 分隔请求,是应用协议的选择
对于一次发送、一份回复的简单协议,发送方向 EOF 可以作为请求体结束信号。但需要在同一连接连续发多条请求时,通常需要另外的消息边界,例如长度字段或明确分隔规则。否则结束第一条时就失去继续发送的能力。
HTTP、TLS 等上层协议还有自己的消息与关闭规则。不要看到本机原始 TCP 示例成功,就对任意 HTTPS 连接直接调用底层 shutdown;应使用相应协议库提供的操作,并核对它处理了哪些层次。
遇到连接重置、超时或异常退出,接收结果也不同于正常 EOF。代码在保护期限内若出现其他错误会失败退出,不把所有异常都改写成“对方正常发完了”。
不依赖睡眠调度,按固定步骤操作两个端点
脚本只监听 127.0.0.1,由系统分配临时端口,然后在同一进程内建立客户端和已接受连接。两端由主线程按步骤驱动,不需要启动后台服务;这些仍是真实系统 TCP 套接字,不是内存列表模拟。
正常读写有三秒保护,故意等待额外字节的探针为 0.1 秒。示例没有用这个数值声称网络延迟;它只是使“还没结束”这一步能够有限返回。结束或异常时,所有套接字都会关闭。
本次 Python 3.14.0、Windows 实跑完成五项检查,客户端在 SHUT_WR 后确实读取 accepted:7。Linux 等平台可以自行运行同一实验,但本文没有把本机结果写成跨平台实测。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""A real localhost TCP connection, with each direction closed separately."""
from contextlib import closing
import socket
def receive_exact(stream, size):
data = bytearray()
while len(data) < size:
part = stream.recv(size - len(data))
if not part:
raise RuntimeError("unexpected EOF before the known request length")
data.extend(part)
return bytes(data)
def receive_until_eof(stream):
chunks = []
while True:
part = stream.recv(1024)
if not part:
return b"".join(chunks)
chunks.append(part)
def main():
checks = 0
def check(condition, message):
nonlocal checks
if not condition:
raise AssertionError(message)
checks += 1
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as listener:
listener.settimeout(3)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
with closing(socket.create_connection(listener.getsockname(), timeout=3)) as client:
peer, _ = listener.accept()
with closing(peer):
peer.settimeout(3)
client.sendall(b"order=7")
request = receive_exact(peer, 7)
check(request == b"order=7", "all request bytes must arrive")
# No more bytes are scheduled, but the client write side is open.
peer.settimeout(0.1)
try:
peer.recv(1)
except TimeoutError:
before = "timeout_not_eof"
else:
raise AssertionError("an open write side must not imply EOF")
check(before == "timeout_not_eof", "waiting is different from EOF")
peer.settimeout(3)
client.shutdown(socket.SHUT_WR)
check(peer.recv(1) == b"", "server must now see request-direction EOF")
peer.sendall(b"accepted:7")
peer.shutdown(socket.SHUT_WR)
response = receive_until_eof(client)
check(response == b"accepted:7", "client may still receive after SHUT_WR")
try:
client.sendall(b"late")
except OSError:
later_write = "rejected"
else:
raise AssertionError("writes after SHUT_WR must not succeed")
check(later_write == "rejected", "closed direction stays closed")
print(f"request={request!r}; before_shutdown={before}")
print("after_client_SHUT_WR=server_received_eof")
print(f"client_still_received={response!r}; later_write={later_write}")
print(f"PASS checks={checks}; sockets_closed=True")
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B demo.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。Windows本机127.0.0.1临时TCP连接,发送方向EOF、反向回复与5项检查;未测试HTTP、TLS或公网
运行后对照结果
request=b'order=7'; before_shutdown=timeout_not_eof
after_client_SHUT_WR=server_received_eof
client_still_received=b'accepted:7'; later_write=rejected
PASS checks=5; sockets_closed=True换成 Java:shutdownOutput 后继续读取回复
Java 对应的发送方向关闭是 Socket.shutdownOutput(),而 InputStream.read() 用 -1 表示读到 EOF。这里保留同一个请求与回复:服务器读完 order=7 后,先观察一次读取超时;客户端关闭发送方向后,服务器才读到 -1,随后仍可把 accepted:7 发回来。
有一个容易写错的地方:关闭 socket.getOutputStream() 返回的流,会连同 socket 一起关闭。如果还要接收回复,就调用 shutdownOutput,等回复读完再让 try-with-resources 关闭整个 Socket。源码另外检查发送方向已关、接收方向仍开,以及后续写入被拒绝。
保存为 TcpHalfCloseDemo.java 后,先用 javac 编译,再用 java -cp . TcpHalfCloseDemo 运行。此文件只连接 127.0.0.1 的临时端口。Windows 上分别使用 JDK 17.0.20.1 与 21.0.12 实际编译运行,两次都通过 6 项检查;这没有验证 HTTP、TLS 或远程服务。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/** Real, finite loopback TCP traffic. No external server is contacted. */
public final class TcpHalfCloseDemo {
private static int checks;
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
checks++;
}
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getByName("127.0.0.1");
byte[] request = "order=7".getBytes(StandardCharsets.US_ASCII);
byte[] response = "accepted:7".getBytes(StandardCharsets.US_ASCII);
try (ServerSocket listener = new ServerSocket()) {
listener.bind(new InetSocketAddress(loopback, 0));
listener.setSoTimeout(3000);
try (Socket client = new Socket()) {
client.connect(listener.getLocalSocketAddress(), 3000);
client.setSoTimeout(3000);
try (Socket server = listener.accept()) {
server.setSoTimeout(3000);
OutputStream clientOutput = client.getOutputStream();
InputStream serverInput = server.getInputStream();
clientOutput.write(request);
clientOutput.flush();
check(Arrays.equals(serverInput.readNBytes(request.length), request),
"server must receive the request bytes");
server.setSoTimeout(100);
boolean timedOut = false;
try {
serverInput.read();
} catch (SocketTimeoutException expected) {
timedOut = true;
}
check(timedOut, "no new byte is different from EOF");
server.setSoTimeout(3000);
// Do not close clientOutput here: that would close client too.
client.shutdownOutput();
check(client.isOutputShutdown() && !client.isInputShutdown(),
"only the sending direction is shut down");
check(serverInput.read() == -1, "server must observe request EOF");
server.getOutputStream().write(response);
server.getOutputStream().flush();
server.shutdownOutput();
byte[] actualResponse = client.getInputStream().readAllBytes();
check(Arrays.equals(actualResponse, response),
"client must still receive the server response");
boolean rejected = false;
try {
clientOutput.write('!');
} catch (IOException expected) {
rejected = true;
}
check(rejected, "a later write after shutdownOutput must fail");
System.out.println("request=order=7; before_shutdown=timeout_not_eof");
System.out.println("after_shutdownOutput=server_read_minus_one");
System.out.println("client_still_received="
+ new String(actualResponse, StandardCharsets.US_ASCII)
+ "; later_write=rejected");
}
}
}
System.out.println("PASS checks=" + checks + "; sockets_closed=true");
}
}
怎样运行:javac -encoding UTF-8 --release 17 TcpHalfCloseDemo.java
java -cp . TcpHalfCloseDemo
运行后对照结果
request=order=7; before_shutdown=timeout_not_eof
after_shutdownOutput=server_read_minus_one
client_still_received=accepted:7; later_write=rejected
PASS checks=6; sockets_closed=true排查时把两个方向分别画出来
当双方都在读时,先问各自等待的是更多数据、某个消息边界,还是对方关闭发送方向。再把最后一次发送、半关闭、读到 EOF 和业务回复按顺序记录;“我已经不再调用 send”不能替代其中任一可观察事件。
设计接口时同时明确正常结束与错误结束的处理。接收方什么时候开始回复、发送方什么时候释放资源、收到半份内容该如何报告,都应成为协议的一部分,而不是只靠延长超时碰运气。
记住这三点
- 程序不再调用 send,不会自动成为对端收到的结束信号。
SHUT_WR关闭发送方向,同时可以保留接收回复的能力。- 超时、正常 EOF 与业务成功是三个需要分别判断的结果。
别把例子用过头
- 把 sendall 返回当作对端业务已经完成。
- 用一次 recv 返回较少字节推断整条请求结束。
- 用
SHUT_WR分隔后还打算在同一方向继续发送请求。 - 把原始 TCP 的关闭步骤直接套到 HTTP 或 TLS 的上层协议。
自己试一小步
如果服务端要先回一条“已收到”,最后再回汇总结果,你怎样在客户端半关闭后完整读出两段回复?
- 画出两个方向的发送、半关闭和接收顺序。
- 另存脚本,在服务端发送两个固定回复片段。
- 客户端用循环汇总字节,自己定义怎样解析两段应用消息。
- 增加一个回复中途断开的场景,区分完整成功与不完整数据。
做到这些,就算完成
- 不依赖两次 send 恰好对应两次 recv。
- 半关闭以后没有再次发送客户端请求。
- 程序只使用本机临时端口,异常路径关闭所有套接字。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- RFC 9293:Half-Closed Connections (新标签页)↗
核对 TCP 两个方向独立关闭,以及开放方向仍可继续传输。
- Python 3.14:socket.shutdown 与超时 (新标签页)↗
核对
SHUT_RD、SHUT_WR、SHUT_RDWR与显式超时行为。 - Microsoft Winsock:shutdown (新标签页)↗
支持 Windows 发送方向关闭、资源仍需关闭和不可重新开启方向的边界。
- Java 21 Socket:shutdownOutput 与输出流关闭 (新标签页)↗
已发送字节之后结束发送方向;后续写入抛 IOException;
getOutputStream返回的流一旦关闭也会关闭 socket。