Appearance
第17章 连接关闭与半关闭
TCP 提供全双工的字节流通信。在客户端和服务端之间,两个发送方向相互独立,且各自拥有独立的序列号(Sequence Number)空间。当其中一端发送 FIN 报文段时,它仅仅是在宣告:“我这边的代码已经把所有数据都发完了”。此时,另一端依然可以继续发送数据。这种单向关闭、单向开放的状态,我们称之为“半关闭(Half-Close)”。
从一个请求场景开始
假设客户端需要向服务端上传一段长度未知的流数据,服务端必须等所有数据接收完毕后才能进行计算。为了实现这种交互,应用层可以这样约定:
- 客户端持续不断地发送请求数据。
- 数据发送完毕后,客户端调用
shutdown(SHUT_WR)发出 FIN 报文段,利用 TCP 的 EOF(文件结束符)机制告知服务端:“请求已发送完毕”。 - 服务端的
recv调用读取到 EOF,得知客户端数据已传输完毕,开始进行计算并返回响应数据。 - 服务端在发完所有响应后,也关闭自己的发送方向。
- 客户端读取响应数据直到遇见服务端的 EOF,至此整个连接彻底释放。
请注意第 3 步:当服务端收到客户端发来的 FIN 时,它自身的发送方向依然处于可用状态,因此完全有能力继续向客户端返回计算结果。
把连接画成两个方向
我们可以把 TCP 连接想象成两条独立的单向通道。假设 A 是客户端,B 是服务端:
text
A 的 send / B 的 recv:A ==========================> B
B 的 send / A 的 recv:A <========================== B当 A 调用 shutdown(SHUT_WR) 后,它主动切断了自己的发送通道,也就是上面的第一条箭头到达了终点:
text
A 的发送方向:A ========== data ========= FIN =====> B 已结束
B 的发送方向:A <=============== response ========== B 仍开放此时,FIN 报文段会紧跟在之前发送的所有数据字节之后。由于 TCP 保证有序交付,B 会先确保之前接收到的所有连续字节都交付给应用层,然后才会向应用层传递 EOF 信号。另外,为了支持确认(ACK)与重传机制,FIN 标志会占用一个序列号位置。
逐步计算数据、FIN 和确认号
让我们通过一个具体例子来看看关闭阶段的序列号(Seq)和确认号(Ack)是如何计算的。假设 TCP 握手完成后:
- A 的下一个发送序列号为 1001;
- B 的下一个发送序列号为 9001。
此时,A 发送了 120 字节的请求数据,紧接着发送 FIN。如果这 120 字节和 FIN 分别封装在两个独立的报文段(Segment)中,交互过程如下:
| 方向 | Flags | Seq | TCP 数据长度 | Ack 的计算 |
|---|---|---|---|---|
| A → B | ACK, PSH | 1001 | 120 | 数据覆盖了 1001 到 1120 的位置,下一个预期序列号是 1121 |
| A → B | ACK, FIN | 1121 | 0 | FIN 占用一个序列号 1121,下一个预期序列号变成 1122 |
| B → A | ACK | 9001 | 0 | B 确认收到了 FIN,返回 Ack = 1122 |
在实际实现中,TCP 往往会进行优化,将最后一批数据和 FIN 标志打包进同一个报文段发送。此时报文的 Seq=1001,数据长度为 120,并且 FIN 标志位被置为 1。下一个序列号的计算公式为:
因为 SYN 和 FIN 各占用 1 个序列号位置,所以计算结果依然是
随后,B 在收到 A 的 FIN 之后,向 A 返回了 40 字节的响应数据。B 发送的数据 Seq=9001,发完后其发送序列号推进到 9041,A 收到后会返回 Ack=9041。最后,B 发送自己的结束信号:包含 FIN 和 ACK 的报文段,其 Seq=9041。A 收到后返回确认,Ack=9042。
从这个过程中我们可以清晰地看到,TCP 两个方向的 Seq 和 Ack 始终是各自独立计算的。
shutdown 与 close 的应用语义
shutdown(SHUT_WR)
在 Python 中,你可以这样调用半关闭:
python
sock.shutdown(socket.SHUT_WR)这行代码明确告诉内核:本地应用已经发完了所有数据。内核收到指令后,会把一个 FIN 报文段排在所有待发送的缓冲数据之后。一旦调用成功,该 Socket 将无法再发送数据(如果强行调用 send,通常会引发 BrokenPipeError、OSError 或特定平台的系统错误),但它仍然可以调用 recv 正常接收对端发来的数据。
需要注意的是,当 sendall 返回时,只代表 Python 已经把所有数据成功交给了本地 Socket 的发送缓冲区。至于对端应用是否真正读取并处理了这些数据,TCP 协议本身无法保证,必须依赖应用层的业务响应来确认。而通过调用 shutdown(SHUT_WR),我们可以向对端清晰地传递一条“发送边界”的信息。
close
python
sock.close()与 shutdown 不同,close 是彻底释放应用层持有的 Socket 文件描述符资源。调用之后,你的代码将完全失去对该对象的读写能力。
在操作系统的默认配置下,调用 close 后,内核通常会尽量把发送缓冲区里还没发完的数据发出去,并自动追加 FIN 来推进连接的关闭。不过,实际的关闭时机非常复杂,会受到内核缓冲、文件引用计数、网络超时机制以及各种 Socket 选项(比如 SO_LINGER)的共同影响。
经验法则: 如果你的协议要求“先结束自己的发送,再读取对端返回的剩余数据”,那么最佳实践是先显式调用 shutdown(SHUT_WR) 触发半关闭,等业务数据全部交互完毕后,再调用 close 释放资源。
shutdown(SHUT_RD) 与 SHUT_RDWR
另外两个选项是 SHUT_RD 和 SHUT_RDWR。SHUT_RD 用于告诉内核停止接收新数据,但这在各个操作系统底层的缓冲处理行为上存在很大差异。SHUT_RDWR 则相当于同时关闭读写两个方向。
在网络编程教学和排查问题的实战中,我们通常主推使用 SHUT_WR。这是因为 SHUT_WR 能在网络线路上划出一条极其清晰的协议边界,并且它直接映射为一个可以通过 Wireshark 抓包看到的、能被 TCP 独立确认的 FIN 报文段。
recv 返回空字节串意味着什么
在处理阻塞式 TCP Socket 时,我们经常会写出这样的读取代码:
python
chunk = sock.recv(4096)
if chunk == b"":
print("EOF")这里的空字节串 b"" 有着极强的网络语义:它明确表示对端的发送方向已经正常到达了末尾(EOF),且这个方向之前所有的有序字节都已被本地读取完毕。
要让 recv 返回 b"",必须严格满足以下两个条件:
- 位于 FIN 报文段之前的所有连续数据,都已经被成功交付给了本地应用程序;
- FIN 标志本身已经按序到达了当前的接收位置。
假如内核的接收缓冲区里还有未读完的数据,recv 会优先返回这些剩余数据。只有当缓冲区被抽干,并且触碰到了 FIN 边界时,下一次(或后续某次)的 recv 才会返回 b""。这种 EOF 状态是持续的——一旦返回了 b"",之后在这个 Socket 上的任何读取尝试都会继续返回 b""。
新手常见的混淆点: 有的应用层协议允许发送“长度为 0 的消息”(比如一段头部指示载荷长度为 0 的数据帧)。但在网络流中,这种“空消息”仍然是由真实的协议字节构成的。而 Socket 层面的 recv(4096) == b"" 截然不同,它代表的是底层 TCP 字节流的彻底终结。两者完全不在一个抽象层次上。
另外,TCP 连接异常结束时的表现也和正常的 EOF 不同。如果连接被对方强制切断(收到 RST 报文段),应用层通常会捕获到 ConnectionResetError 异常;如果网络出现黑洞导致长时间无响应,recv 要么会一直死等,要么会在触发应用设定的超时阈值后抛出 TimeoutError。因此,正常的 EOF (b"")、连接重置 (RST) 以及网络超时 (Timeout),是排查网络故障时三类截然不同的诊断证据。
FIN 仍然参加可靠传输
因为 FIN 标志消耗了一个序列号,所以它和普通的数据报文段一样,必须接受 TCP 可靠传输机制的管理,经历 ACK 确认,并在丢包时触发重传。
当发送端成功调用 shutdown(SHUT_WR) 时,仅仅意味着本地操作系统已经接受了应用层的“结束发送”请求,并将 FIN 推入了发送队列。对端返回的 ACK 确认可能要等上一会儿才能到达。如果携带 FIN 的报文段或者对应的 ACK 在网络中丢失了,底层的 TCP 协议栈会依靠重传计时器自动进行重传。如果你的应用程序对关闭连接的最大等待时间有严格要求,就不能仅仅依赖底层的 TCP 机制,而需要在应用层专门设置业务生命周期或超时策略。
在接收端,一旦内核按序收到了 FIN,它会立即回复 ACK 确认,并将当前连接的 TCP 状态机推进到下一步(通常是 CLOSE_WAIT)。请注意,此时业务代码可能还在忙别的,并没有立刻去调用 recv。此时,网络层面的 EOF 信号就已经静静地躺在接收队列里等待交付了。这就解释了为什么我们在排查问题时,经常会看到系统网络状态早就变成了 CLOSE_WAIT,但应用程序的日志却过了好一会儿才打印出 recv == b""。这中间的时间差,纯粹是内核网络栈处理和用户态线程调度导致的。
此外,TCP 的乱序重排机制对 FIN 同样有效。如果在到达的 FIN 之前还存在序列号缺口(比如前面的某个数据包丢了),接收端内核会先对目前收到的连续数据前缀发送 ACK,并耐心等待丢失的数据包到达补齐。只有当所有前置字节都成功填补缺口,与 FIN 无缝拼接成一段连续的字节流后,内核才会向应用层的 recv 调用交付 EOF。这一严谨的规则,从根本上保证了我们前面的结论:“在代码里读到 EOF,就等于确信对端在 FIN 之前发送的所有数据,都已经一字不落地交付给我们了”。
可控半关闭实验
保存为 half_close_server.py:
python
import socket
import time
ADDR = ("127.0.0.1", 18080)
with socket.socket() as listener:
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(ADDR)
listener.listen()
print("listening", ADDR, flush=True)
conn, peer = listener.accept()
with conn:
request = bytearray()
while True:
chunk = conn.recv(7)
print("server recv", repr(chunk), flush=True)
if chunk == b"":
break
request.extend(chunk)
print("request EOF; response remains writable", flush=True)
time.sleep(8) # 留出 CLOSE_WAIT 观察窗口
response = f"received={len(request)};".encode() + bytes(request).upper()
conn.sendall(response)
conn.shutdown(socket.SHUT_WR)
print("response sent and write side closed", flush=True)保存为 half_close_client.py:
python
import socket
with socket.socket() as sock:
sock.connect(("127.0.0.1", 18080))
for part in (b"hello ", b"tcp ", b"half-close"):
sock.sendall(part)
sock.shutdown(socket.SHUT_WR)
print("client request EOF sent", flush=True)
response = bytearray()
while True:
chunk = sock.recv(5)
print("client recv", repr(chunk), flush=True)
if chunk == b"":
break
response.extend(chunk)
print("complete response", response.decode(), flush=True)为了直观体验半关闭的效果,请先启动 Wireshark 抓包,然后再依次运行服务端与客户端脚本。
你会发现,尽管客户端分了三次调用 sendall 发送数据,但由于 TCP 的流式特性,这些数据边界可能会在服务端每次读取 7 个字节的 recv(7) 调用中被重新拼合。
服务端读到 b""(即客户端发来的 FIN)后,刻意休眠了 8 秒钟。在这 8 秒的空档期内,服务端的连接状态将进入 CLOSE_WAIT,但它的发送通道依然畅通无阻,能够毫无压力地发回响应数据。反观客户端,虽然它已经主动切断了自己的发送方向,但其接收方向依然在正常运作,最终成功读取到了完整的响应内容。
你可以通过以下命令在系统层面观察连接状态。
Windows 观察命令:
powershell
Get-NetTCPConnection |
Where-Object { $_.LocalPort -eq 18080 -or $_.RemotePort -eq 18080 } |
Format-Table LocalAddress, LocalPort, RemoteAddress, RemotePort, StateLinux 观察命令:
bash
ss -tanpo '( sport = :18080 or dport = :18080 )'Wireshark 预期时间线
在 Wireshark 中设置如下过滤器:
text
tcp.port == 18080在抓到的数据包中,请重点观察以下几个关键节点:
- 客户端发送的最后一批请求数据,可能会与 FIN 标志合并在同一个报文段中,也可能被拆分成两个独立的报文段。
- 当服务端的 ACK 确认了这些请求数据和 FIN 之后,客户端的状态通常会切换至
FIN-WAIT-2,而服务端则进入CLOSE_WAIT。 - 仔细观察服务端,它在确认了客户端的 FIN 之后,依然在向客户端发送响应数据包。
- 同样地,服务端发送的最后一点响应数据,可能会与它自己的 FIN 标志合并发送,也可能是独立发送。
- 客户端最终回复 ACK 确认了服务端的 FIN,此时主动发起关闭的一方(客户端)将正式进入
TIME-WAIT状态。
在一次常规的 TCP 关闭流程中,我们经常会听到“四次挥手”的说法,但实际上抓包可能会看到三段式的控制报文交互:A 发送 FIN,B 经过优化把 ACK 和自己的 FIN 合并在同一个报文中发回(FIN+ACK),最后 A 再回复一个 ACK。如果 B 收到 FIN 后先单独发了 ACK,过了一会儿才发自己的 FIN,这就会呈现出经典的四段式交互。
此外,应用层数据包、延迟确认(Delayed ACK)、报文合并优化以及网络波动引起的重传,都会让抓包看到的实际报文数量增加。在排查问题时,只要耐心地逐个核对每个包的 Flags、Seq、Ack 以及 TCP 数据长度,就能精准还原出底层的真实交互轨迹。
半关闭适合哪些协议
半关闭机制非常契合那些“读取流数据直到 EOF,然后一口气返回处理结果”的单次批处理协议。比如,客户端向服务端上传一段长度未知的代码让其编译,或者上传大文件计算哈希摘要,这就很适合用 shutdown(SHUT_WR) 来划定边界。
然而,由于半关闭会永久性地切断连接的一个发送方向,因此这条连接在交互完成后,很难再被拿来复用于后续的双向请求。如果你的业务场景需要使用长连接、频繁的多包请求或是多路复用(Multiplexing),那么在应用层引入长度字段(Length-Prefixed)或者明确的帧类型(Frame Type)来界定消息边界,显然是更合理的架构选择。
即使在使用半关闭时,应用层协议也必须在设计时明确回答以下问题:
- 究竟该由哪一方先发起半关闭?
- 收到 EOF 后,另一方允许返回什么样的响应数据?
- 业务出错时该如何传递错误状态?
- 各种环节的最大等待超时时间是多少?
- 双方在什么时刻去调用彻底的
close?
只有具备了这样清晰、严谨的协议约定,才能有效避免双端陷入“都在傻等对方先关连接”的死锁泥潭。
理解检查
- A 发送了一个
Seq=5001、携带 99 字节数据的报文段,并且在同一个包中设置了 FIN 标志位。B 收到后,回复的 Ack 应该等于多少? - 服务端调用
recv(4096),第一次返回了 100 字节数据,紧接着第二次调用返回了b""。这两个返回值在底层是如何与 FIN 报文段对应的? - 客户端在代码中执行了
shutdown(SHUT_WR)之后,还能在该 Socket 上执行哪种网络方向的操作? - 如果你在 Wireshark 抓包中发现:服务端在收到客户端发来的 FIN 之后,又硬生生地向客户端发送了 200 字节的数据。这种行为符合 TCP 的设计模型吗?
- 我们常说的 TCP 连接关闭,有时抓包看到 3 个控制报文,有时又是 4 个。这种差异主要是由什么引起的?
答案要点:
Ack=5101。数据占据 5001 到 5099,FIN 占用 5100,所以下一个期待的序列号是 5101。- 内核接收缓冲区中尚未读完的数据会优先交付给应用层(返回 100 字节)。当所有连续数据交付完毕,且 FIN 按序到达后,它向应用层表现为持久的 EOF(返回
b"")。 - 客户端仍然可以通过接收方向(
recv)读取服务端发来的数据。 - 完全符合。这正是“半关闭”状态的体现——服务端的发送方向仍然保持开放,可以继续发送响应。
- 差异取决于服务端的 TCP 协议栈行为。服务端可以将对首个 FIN 的确认(ACK)与自己发送的结束标志(FIN)打包合并发送(三段式),也可以先单独回复 ACK 确认,后续业务处理完再发送自己的 FIN(四段式)。
本章小结
- TCP 的全双工架构允许两个发送方向独立结束,发送 FIN 仅仅关闭当前这一侧的发送方向。
- FIN 标志严格按照顺序排列在之前发送的所有字节之后,并且会消耗掉一个序列号(Sequence Number)位置。
- 使用非零长度调用
recv时返回空字节串b"",意味着在这之前的所有数据均已正常读取,并且对端的发送方向已正常触发了 EOF。 - 在应用层,
shutdown(SHUT_WR)能清晰地向对端划定请求结束的边界,同时保留自身的接收能力。 - 在真实网络中,连接关闭所产生的报文数量并不是固定的“四次挥手”,而是由数据组合、ACK 延迟合并以及重传机制共同决定的。