程序调用 close() 后,为什么连接条目还可能留在系统里?服务端读到 b'',为什么仍然能够返回应答?这两种现象并不矛盾:应用对象、文件描述符、TCP 端点状态与两个传输方向,不是同一个生命周期。

第 15 篇核对了字节序号、累计确认和 FIN 的序号占用。本篇把观察点扩展到 socket 调用、报文以及内核状态,分别复现正常关闭、半关闭和复位。握手成功只证明相应连接阶段完成,不能替代业务请求验收。

连接状态保存在端点

TCP 连接不是链路上一个持续存在的报文。通信两端需要保存本地与远端地址和端口、序号、窗口、计时器以及连接状态;网络中的报文推动这些状态变化。两端可能处于不同状态,某一时刻的单端列表不能代表远端已经同步到同一阶段。

RFC 9293 §3.3.1–3.3.2定义了相关状态变量与状态机。一个监听 socket 可以接受多条连接,监听状态不等于每条已接受连接的状态。排障时至少要区分监听端口、某个已连接四元组和应用持有的对象。

Python socket 对象是应用访问接口。应用关闭对象后,内核仍可能为协议收尾保存状态;反过来,对象还存在也不证明对端可达或下一次收发会成功。两者之间需要实际系统调用和状态记录来对应。

握手同步的是两方向的序号

在普通主动连接中,发起侧发送 SYN 后等待确认;监听侧收到请求后返回 SYN 与 ACK,发起侧再确认对端的 SYN。RFC 9293 §3.5给出建立连接的处理过程,包括旧请求造成混淆时的应对。

假设 A 初始序号为 1000,B 为 5000,且这些握手段不携带数据:

报文方向 主要字段 确认的内容
A→B SYN,SEQ=1000 发起 A 方向的序号同步
B→A SYN+ACK,SEQ=5000,ACK=1001 确认 A 的 SYN,同时发起 B 方向同步
A→B ACK,SEQ=1001,ACK=5001 确认 B 的 SYN

SYN 各占一个序号,最后一个纯 ACK 不再消耗序号。两个方向有独立起点;不是把同一计数器来回递增三次。重传、选项与携带数据等情形会改变可见报文形态,表格仅表示这个明确条件下的基本例子。

发起端返回连接成功,与服务端应用执行 accept()、读取请求并处理完成仍然不同。对端进程可能尚未取走已经排队的连接,也可能建立连接后才遇到业务故障。不能把 TCP 建连成功写成认证、数据库提交或接口健康已经通过。

FIN 关闭一个方向

FIN 表示发送方不再发送该方向的新数据。接收方需要先交付它之前的连续字节,应用才能读到接收方向结束。已经收到 FIN 的端点仍可能在相反方向发送内容,所以“读到 EOF”不等于“双方立即都不能写”。

RFC 9293 §3.6–3.6.1区分正常关闭和半关闭。在常见的非同时关闭路径中,主动关闭侧经历 FIN-WAIT-1,自己的 FIN 被确认后可到 FIN-WAIT-2;接收 FIN 但应用尚未关闭发送方向的一侧处于 CLOSE-WAIT。后者再发 FIN 后等待最后确认,对应 LAST-ACK。

这些名字表示协议状态,不能简单当作“哪个应用卡死”的结论。CLOSE-WAIT 表示对端已结束发送、当地应用尚未完成相应关闭;是否泄漏需要结合对象持有者和正常业务等待。FIN-WAIT-2 也需要结合另一端是否仍合法发送,而不是只见状态名就判断异常。

Python shutdown()提供方向控制。shutdown(SHUT_WR) 禁止后续发送,但保留接收方向,适合“请求发送完,以 EOF 标记请求结束,再读取响应”的实验。close() 关闭这个 socket 接口,不能当作还能继续在同一对象上读取的替代写法。

正常 EOF 与复位是不同结果

对于本文使用的 TCP socket,正数长度的 recv() 返回 b'' 表示读到了流结束;它不是“暂时没有数据”。阻塞、超时和复位应分别处理,不能都转换成空字节串,否则调用者失去判断完整性的依据。

RST 用于中止或拒绝某些连接状态下的通信。RFC 9293 §3.5.2–3.5.3规定生成和处理规则,§3.6 要求向应用区分正常关闭与中止。实际接收端还会验证复位是否符合当前连接,不能把网络上任何带 RST 的报文都当成已生效的关闭。

应用在错误到来前可能已读到部分缓存字节。复位出现在哪一次 recv() 或其他调用上,取决于到达和读取顺序。因此,实验需要记录每次得到的内容、最终 EOF 或异常,而不是只保留一个“连接失败”布尔值。

本篇复位实验采用 Linux 的 SO_LINGER 设置:启用 linger,等待时间设为零后关闭。接口结构与等待语义见 socket(7),具体复位结果由本次 RST 捕获与对端异常共同核对。它不是 Python 对所有平台作出的统一复位承诺,也不是常规应用应该默认使用的关闭方式。

TIME_WAIT 与客户端身份无关

在常见主动正常关闭路径中,最后确认对端 FIN 的主动关闭侧保留 TIME_WAIT。该状态允许处理旧连接的迟到报文及结束阶段重传,不能因为应用对象消失就立即把相同四元组视为完全没有历史。

RFC 9293 §3.6 图 12 展示普通关闭,图 13 展示同时关闭;同时关闭时可能双方进入 TIME_WAIT。因此“TIME_WAIT 总在客户端”不成立,客户端和服务端是应用角色,不决定哪一侧先结束发送。

规范中的等待尺度为 2×MSL。这不是可以不经核查就套用的某个操作系统固定秒数;具体实现、配置和复用条件另有约束。本篇只在关闭后读取状态快照,没有测量整个等待期,也不据此推荐缩短超时或开启复用参数。

抓包看到 FIN 和 ACK,能支持对状态迁移的推导,却不等于已经读取到实际内核状态。实验应在对应命名空间内查询,并关联到同一地址与端口,避免把其他连接的 TIME_WAIT 误作证据。

重启不等于所有旧状态同时消失

应用进程重启、监听 socket 重建和主机重启是不同事件。应用重新启动并不自动清除内核所有 TCP 状态;远端也不会因本地进程退出而瞬间得到一致通知。连接失效往往仍通过报文或超时被发现。

RFC 9293 §3.4.1–3.4.3讨论初始序号和旧报文的风险。如果相同四元组再次使用,而新旧序号范围缺乏足够区分,迟到段可能与新连接发生混淆。不能反过来写成“每次重启都会串包”,也不能声称只换一个进程就已经验证了旧段防护。

处理这类问题需要同时说明连接身份、序号、时间和实现条件。本文未注入跨重启的旧报文,也未修改内核复用策略;这一部分是规范分析,不纳入下面三组真实实验已经完成的范围。

隔离实验:把调用、报文与状态对应起来

本次于 2026-09-20 在专用 Linux 虚拟机运行,内核为 6.18.10-200.fc43.aarch64,Python 为 3.14.3。每组新建两个网络命名空间,以 veth 直接相连:A 为 10.16.0.1,B 为 10.16.0.2:46016。A 执行客户端连接,B 接受连接后关闭监听 socket,随后只检查这条已建立连接。

实验脚本完整运行记录环境及边界记录保存在本篇素材目录。脚本仅使用 Python 标准库和已有 `ip` 命令;需要 Python 3.11 以上,以及自建 Linux 虚拟机内创建命名空间和原始捕获 socket 的权限。在该虚拟机内进入素材目录后执行:
1
2
python3 lab.py --help
sudo python3 lab.py > local-run.jsonl

不要在宿主或生产节点执行。脚本只创建随机名称的自有命名空间,不修改宿主默认路由。finally 清理子进程和已创建的命名空间;本次三组共六个命名空间均已删除,外层虚拟机也已停止。自行复跑的 local-run.jsonl 与附带的当次记录应分别保留。

捕获进程先在 B 的 eth0 报告 READY,再启动监听和连接。父进程用管道逐条下发操作,并等待结果,保证“读完请求”“得到 EOF”“发出反向响应”之间有明确顺序。每个 socket 的五秒超时只是实验失败上限,不是 TCP 协议默认计时器。

每组先真实传送七字节 request,确认服务端读取相同字节,并在两个命名空间读取 /proc/net/tcp。记录保留本地和远端地址、端口以及原始状态码。01/05/06/08 分别对应 ESTABLISHED、FIN_WAIT2、TIME_WAIT、CLOSE_WAIT;状态编号可对照 Linux v6.18 的定义。该上游源码是解释参照,不等于声称实测发行版内核与它完全相同。

场景 第一次结束发送之后 后续实际应用结果 最后状态快照
正常关闭,B 先 SHUT_WR A=CLOSE_WAIT,B=FIN_WAIT2 A 读到 EOF,随后 A 结束发送,B 也读到 EOF B=TIME_WAIT,A 无匹配条目
半关闭,A 先 SHUT_WR A=FIN_WAIT2,B=CLOSE_WAIT B 在 EOF 后发送八字节 response,A 在 SHUT_WR 后仍读到这八字节 A=TIME_WAIT,B 无匹配条目
复位,B 零 linger 后 close 关闭前双方 ESTABLISHED A 的读取抛出 ConnectionResetError,errno=104 两端均无匹配条目

正常关闭组捕获到两次 FIN+ACK,没有捕获到 RST;服务端实际进入 TIME_WAIT,足以反驳“TIME_WAIT 必在客户端”。这组没有服务端响应数据,验证对象是关闭过程,不能据此声称一次请求已经完成业务处理。

半关闭组日志中的 server-after-EOF 发送值与 client-after-SHUT_WR 接收值都是 726573706f6e7365,即八字节 response。接收 EOF 的方向是 A→B,继续传送响应的方向是 B→A;这两件事可以同时成立。本组同样没有捕获到 RST。

复位组在 B 读完请求之后才设置 SO_LINGER,避免把“未读请求便关闭”的另一条处理路径混进来。B→A 的最后一个 TCP 帧 flags 为十进制 20,即 RST|ACK;随后 A 得到真实复位异常。可参照 Linux v6.18 tcp.c__tcp_close() 的零 linger 分支及 tcp_disconnect()。本次结果只覆盖这个连接状态与调用顺序,不能泛化成所有复位场景都没有 TIME_WAIT。

本次验证到哪里

三组都有实际应用操作、原始帧和内核状态快照。capture.raw_frames 保存十六进制帧,decoded.frame_index 指向对应帧;附件不是 pcap 文件。最小解码器只处理本实验的无 VLAN IPv4/TCP 流量,报告端点和 flags,不检查完整选项、校验和或捕获丢弃计数。

状态快照是在同步屏障之后取得的,不能证明每一个短暂中间状态都被观察到。没有测量 TIME_WAIT 到期时间,没有验证同时关闭、端口复用、进程重启、旧报文注入或公网链路。删除命名空间会结束实验,不能把清理后的条目消失算作 TIME_WAIT 自然到期。

练习

第一题:服务端先发送响应并结束发送,客户端读到 EOF 后才结束自己的发送方向。能否仅因它叫服务端,就排除服务端出现 TIME_WAIT?

校验:不能。该条件下服务端先发 FIN,符合主动正常关闭侧的典型路径。仍应读取实际状态,不能用应用角色替代证据。

第二题:客户端 shutdown(SHUT_WR) 后,服务端读完请求并得到 EOF。服务端随后返回十字节响应,是否违背 TCP 关闭语义?

校验:不违背。先结束的是客户端到服务端的方向,反方向仍可发送。客户端必须继续读取,直到得到完整响应或明确失败;不能把本地 SHUT_WR 当成对端响应已经结束。

第三题:一次读取先返回四字节,下一次抛出复位异常。能否把异常改成空串,以便让调用方按正常文件结束处理?

校验:不能无条件这样做。四字节可能只是截断内容,EOF 与中止表示不同结果。上层协议需要自行验证消息完整性,同时保留底层失败信息。

参考资料