qinghuan
← 回到知识探索

后端现场 / 可下载小项目

请求早就发完了,服务器为什么还在等结束?

用真实本机 TCP 连接观察:sendall 返回、读取超时、收到 EOF 是三个事件;关闭发送方向后,仍可以接收对方的回复。

展开本文目录
  1. 下载完整练习包
  2. TCP 的两个方向可以分别结束
  3. 没有新字节、读取超时和 EOF 不是同一事实
  4. 发送方向结束后,接收方向继续完成回复
  5. 是否用 EOF 分隔请求,是应用协议的选择
  6. 不依赖睡眠调度,按固定步骤操作两个端点
  7. 换成 Java:shutdownOutput 后继续读取回复
  8. 排查时把两个方向分别画出来
  9. 记住这三点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 知道客户端和服务端通过连接发送字节。
  • 能读懂 Python bytes、循环与异常处理。
  • 本机允许使用 127.0.0.1 的临时 TCP 端口。
  • 需要补基础时:先区分 TCP 字节流与应用消息
遇到不熟悉的词,先看这 1 个解释
半关闭
只结束 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 等平台可以自行运行同一实验,但本文没有把本机结果写成跨平台实测。

展开收起代码 · 小实验python · 76 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 或远程服务。

展开收起代码 · 小实验java · 78 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
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 的上层协议。

自己试一小步

如果服务端要先回一条“已收到”,最后再回汇总结果,你怎样在客户端半关闭后完整读出两段回复?

  1. 画出两个方向的发送、半关闭和接收顺序。
  2. 另存脚本,在服务端发送两个固定回复片段。
  3. 客户端用循环汇总字节,自己定义怎样解析两段应用消息。
  4. 增加一个回复中途断开的场景,区分完整成功与不完整数据。

做到这些,就算完成

  • 不依赖两次 send 恰好对应两次 recv。
  • 半关闭以后没有再次发送客户端请求。
  • 程序只使用本机临时端口,异常路径关闭所有套接字。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。