Appearance
第22章 流量控制
假设发送端速度极快,而接收端应用程序突然暂停读取 10 秒。你会观察到:在最初的几毫秒内,数据依然在不断到达;但很快,发送端就会自动停下来,此时连接仍然保持在 ESTABLISHED 状态。这种现象正是 TCP 流量控制(Flow Control)在起作用:接收端的 TCP 会通过“窗口(Window)”机制告知发送端,自己目前还有多少序列空间可以接收数据。
从接收缓冲区理解 rwnd
TCP 连接的每个方向都维护着独立的发送和接收状态。当 A 向 B 发送数据时,B 的 TCP 协议栈会把按序到达的数据放入接收缓冲区(Receive Buffer),随后 B 的应用程序通过 recv 系统调用将这些数据读走。应用程序读走数据后,缓冲区腾出了新的空间,此时 B 就可以向 A 通告(Advertise)一个更大的接收窗口。
这个接收窗口通常简写为 rwnd(Receive Window)。假设 B 当前期望接收的下一个字节序列号为 RCV.NXT,向 A 通告的窗口大小为 RCV.WND,那么 B 当前能够接纳的序列号范围就是:
TCP 首部中的 Window 字段总是由报文的发送方填写,用于声明自己在相反方向上的数据接收能力。因此,在进行抓包分析时,必须先理清数据流向:例如,客户端发给服务端的 ACK 报文中携带的 Window 字段,限制的是服务端未来能够向客户端发送的数据量。
接收窗口的大小通常与 Socket 接收缓冲区的可用空间直接相关。不过,操作系统往往会预留一部分空间用于管理开销,还会执行动态缓冲调节(Auto-tuning),并且有一套自己的内存记账规则。因此,Socket 选项 SO_RCVBUF、进程实际占用的内存、应用程序的任务队列以及抓包看到的线上 rwnd,它们之间虽然存在联系,但具体数值往往不完全一致,需要分别进行测量。
此外,业务逻辑的处理能力也有独立的边界:应用程序可能会非常快速地将数据从 Socket 读入进程内存,随后却在业务逻辑的内部队列中发生积压。在这种情况下,尽管业务处理已经遇到了瓶颈,但从 TCP 协议层面来看,由于数据已经被应用层读走,接收窗口(rwnd)依然会很大。
滑动窗口怎样移动
为了控制发送节奏,发送端会维护几个关键的序列号状态:
SND.UNA(Send Unacknowledged):最早发出但尚未收到确认的序列号;SND.NXT(Send Next):下一个准备发送的数据序列号;SND.WND(Send Window):对端最新通告的接收窗口大小。
按照基础的滑动窗口模型,发送端首先会计算原始的可用窗口大小(
如果对端意外缩小了接收窗口(窗口收缩),导致 SND.NXT 已经越过了新窗口的右边界,此时计算出的
举个具体的例子:假设接收端最新返回的 Ack = 500001,并且通告 rwnd = 16384 字节;而发送端当前已经把数据发送到了 SND.NXT = 508193。那么:
这意味着,发送端已经有 8192 字节(508193 - 500001)的数据在网络中处于“在途”(In-flight)状态,但由于总窗口是 16384 字节,发送端此时仍然可以继续发送 8192 字节的新数据。随着后续新的 ACK 不断推进 SND.UNA,或者接收端通过 Window Update 报文增大 SND.WND,发送端的窗口右边界就会持续向前滑动。
需要注意的是,接收窗口(rwnd)仅仅代表了接收端的处理能力上限。发送端实际允许的在途数据量,还会受到拥塞窗口(cwnd,Congestion Window,代表网络传输能力上限)的约束:
在决定最终能发送多少数据时,发送端还需要从上述上限中扣除当前已经发出的在途数据量(FlightSize),并且前提是应用程序已经将足够的数据写入了发送缓冲区。综合来看,TCP 的数据发送往往受制于四种独立的瓶颈:接收端的缓冲空间、网络的拥塞控制、发送端应用程序的供数速度,以及本地操作系统的 Socket 资源。
Window Scale 的方向与计算
在 TCP 的固定首部中,Window 字段的长度只有 16 位(Bit),这意味着它能表示的最大窗口原始值仅为 65535 字节。为了突破这个限制,RFC 7323 引入了窗口缩放选项(Window Scale)。在三次握手阶段,通信双方可以为各自的数据接收方向协商一个二进制移位数(Shift Count)。
只有当双方的 SYN 报文中都携带了 Window Scale 选项时,窗口扩展机制才会生效;如果任何一端没有携带该选项,整条连接的两个方向都会默认移位数为 0(即不进行缩放)。若某端在成功协商后发送过 shift.cnt = 7,那么在后续通信中,该端通告的接收窗口原始值(Raw Window)就需要通过下面的公式来还原成真实窗口大小(Calculated Window):
例如,如果抓包看到的 Window 字段原始值为 4096:
缩放因子(移位数)的最大值为 14,这意味着 TCP 理论上最大可以支持接近 1 GiB(
需要特别注意的是,SYN 和 SYN+ACK 报文本身携带的 Window 字段,必须直接按照原始的 16 位数值来解释,窗口缩放机制要从握手完成后的后续报文才开始生效。如果你在 Wireshark 中捕获到了完整的 TCP 握手过程,Wireshark 会自动替你完成计算,并同时展示原始的 Window size value 和真实的 Calculated window size。但如果你的抓包是从连接中途开始的,Wireshark 因为没有看到握手阶段的 Window Scale 选项,就无法得知正确的缩放因子。此时,虽然抓包中看到的原始 Window 字段是准确的,但真实的窗口换算值只能依靠查阅操作系统的端点状态,或者重新抓取完整的握手过程来补齐了。
从窗口缩小到 Zero Window
假设接收端的缓冲区有 64 KiB 的空间可用于存放数据。如果应用程序突然暂停读取,随着新数据的到来,缓冲区中积压的数据从 16 KiB 增加到 48 KiB,那么接收端能够通告给发送端的可用空间,就会从大约 48 KiB 锐减到 16 KiB。如果数据还在源源不断地进入,接收端的缓冲区最终会被填满,此时它就会向发送端回复一个 Window = 0 的报文。这条零窗口 ACK 意味着,接收端当前窗口的右边界已经与它期望接收的下一个字节序列号完全重合了。
当发送端收到零窗口通告后,它必须立刻停止发送常规的新数据,转而进入零窗口探测(Zero-Window Probing)逻辑。为什么需要探测呢?想象一下,如果接收端的应用恢复了读取,腾出了空间,并发送了一个窗口更新(Window Update)的 ACK 报文,但这个 ACK 偏偏在网络中丢失了。如果发送端只是无限期地死等,它将永远错过窗口重新打开的机会,导致连接彻底“死锁”。为了防止这种情况,RFC 9293 强制要求 TCP 支持 Zero-Window Probing:在零窗口期间,发送端会定期发送携带至少一个字节新数据(如果有可用数据的话)或重传数据的探测报文。接收端收到探测报文后,必须回复一个包含当前 Ack 和最新窗口大小的 ACK 报文。规范建议,第一次探测应该在零窗口持续了一个 RTO(重传超时时间)之后触发,随后探测的间隔时间会呈指数级增长。
当接收端的应用程序恢复调用 recv 读取数据后,缓冲区终于腾出了空间。此时,接收端会主动发送一个 Window Update 报文通知发送端窗口已开,发送端收到后即可恢复正常的数据发送。TCP 这种在零窗口期间定期探测的机制,通常被称为坚持定时器(Persist Timer)机制,其核心目的是确保窗口更新信息能够可靠地传递给发送方。值得澄清的是,虽然 RTO 丢包恢复和 Persist 窗口探测在底层都使用了相似的计时概念,但它们触发的场景和根本原因截然不同。
在 Wireshark 分析网络包时,我们经常会看到 TCP Window Full、TCP ZeroWindow、TCP ZeroWindowProbe、TCP ZeroWindowProbeAck 以及 TCP Window Update 等醒目的标签。需要明白的是,网络报文中真实存在的只有 Window 字段以及 Seq/Ack 序列号等“线上事实(On-the-wire Facts)”,而这些标签仅仅是 Wireshark 结合上下文逻辑为你做出的智能诊断和归类。
四种相似的“发送停顿”
在排查网络问题时,如果你观察到发送端突然停止发送新数据,不要急于把锅甩给网络。你可以按照下面的现象分类,逐项排查限制的来源:
| 现象 | 报文特征与端点证据 | 主要瓶颈所在 |
|---|---|---|
rwnd = 0 | 对端返回的 ACK 通告零窗口,随后出现发送端的窗口探测包 | 接收端的 TCP 缓冲区空间 |
rwnd 很小且频繁更新 | 窗口大小随着接收端应用程序的缓慢读取而一点点打开 | 接收端应用程序的消费速度 |
rwnd 充足,但 cwnd 很小 | 接收窗口很大,但发送端通过 ss -ti 查看到拥塞窗口受限 | 网络拥塞,正在进行路径探测或恢复 |
| 窗口都充足,但 FlightSize 很小 | 接收和拥塞窗口都很大,但发送缓冲区监控或应用日志显示供数出现间歇 | 发送端应用程序本身,或其上游处理阶段 |
这张表格不仅梳理了常见的停顿原因,也生动地揭示了 TCP 流量控制与应用程序背压(Backpressure)机制是如何衔接的。如果服务端应用程序由于繁忙而暂停调用 recv,内核接收缓冲区最终会被塞满,此时 rwnd 就会降为 0,从而将处理压力通过网络优雅地传导回对端的 TCP 发送方。但反过来,如果服务端持续调用 recv 并将数据塞入无界的业务队列中,那么从 TCP 协议层的视角来看,接收缓冲区的空间依然充足。这种情况下,TCP 的流量控制机制将无法生效,所有压力都会转移到进程内存的暴涨和业务处理延迟上。在第28章中,我们将深入探讨如何利用有限长度的队列、适时暂停读取以及过载拒绝等手段,在应用层把这条反馈链条重新闭环。
窗口更新信息通常是由纯 ACK 报文携带的,也可以跟随反向传输的数据一起发回。为了防止因为旧的 ACK 报文乱序晚到而引发接收窗口回退,发送端会综合比较报文中的 Ack 号、Seq 号以及当前的窗口更新状态,来判断一份通告是否足够“新鲜”。在进行抓包分析时,一个非常实用的技巧是追踪滑动窗口的“右边界”(Right Edge):
举个例子,假设当前的 Ack = 100000,真实的换算窗口是 32768 字节,那么右边界就是 132768。随后,接收端发来一份新的 ACK,Ack 号推进到了 108192,但通告的窗口大小降为了 24576。此时右边界依然是 132768(108192 + 24576);这说明接收端虽然成功接纳了 8192 字节的新数据,但应用程序暂时没有释放出新的空间。如果在后续的报文中,窗口大小突然变为了 40960,此时右边界随之大幅推进到了 149152,这明确表明接收端新开放了更多序列空间。
当你分析双向连接时,可以分别为两个方向画出窗口右边界的推进曲线。客户端通告的窗口曲线约束着服务端的发送行为;而服务端通告的曲线则约束着客户端。只要标清楚方向,你就能一目了然地找到窗口瓶颈与数据流向之间的对应关系。
受控实验:暂停接收应用
为了直观地观察流量控制机制,我们将复用第20章创建的 tcp-rx 网络命名空间(Namespace)进行受控实验,且无需人为注入丢包。首先,我们需要清理掉之前可能遗留的规则:
bash
sudo tc qdisc del dev veth-tx root 2>/dev/null || true
sudo ip netns exec tcp-rx tc qdisc del dev veth-rx root 2>/dev/null || true接下来,请在终端 A 中进入命名空间并启动一个服务端。这个服务端会请求一个极小的接收缓冲区(8192 字节),并且在连接建立后故意暂停读取数据 12 秒,休眠结束后才会开始持续读取:
bash
sudo ip netns exec tcp-rx python3 - <<'PY'
import socket
import time
srv = socket.socket()
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8192)
srv.bind(("10.200.20.2", 18080))
srv.listen(1)
conn, peer = srv.accept()
print("accepted", peer,
"effective_rcvbuf", conn.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF),
flush=True)
time.sleep(12)
total = 0
while True:
chunk = conn.recv(65536)
if not chunk:
break
total += len(chunk)
print("received", total)
conn.close()
srv.close()
PY由于 Linux 内核在处理 SO_RCVBUF 时通常会自动将请求值翻倍或进行自动调节,因此程序会打印出实际生效的缓冲区大小。同时,在终端 B 中启动抓包:
bash
sudo tcpdump -i veth-tx -s 0 -w chapter22.pcap 'tcp port 18080'最后,在终端 C 中启动客户端脚本,尝试发送 32 MiB 的数据,并精确统计耗时。在对端窗口耗尽后,循环发送过程会出现明显的等待现象:
bash
python3 - <<'PY'
import socket
import time
data = memoryview(b"x" * (32 * 1024 * 1024))
s = socket.create_connection(("10.200.20.2", 18080))
started = time.monotonic()
sent = 0
while data:
n = s.send(data)
sent += n
data = data[n:]
print("send completed", sent, "bytes in", time.monotonic() - started, "s")
s.shutdown(socket.SHUT_WR)
s.close()
PY预期现象
- 窗口收缩:握手完成后,随着数据不断涌入接收缓冲区,Window 值会逐步下降,最终可能降至零。
- 发送阻塞:在服务端长达 12 秒的暂停期内,客户端的发送循环会出现明显的阻塞等待,但底层的 TCP 连接仍然保持在 ESTABLISHED 状态。
- 零窗口探测:由于暂停时间较长,抓包中会出现零窗口探测(Zero-Window Probe)及其 ACK 报文;实际首次探测的触发间隔取决于本机的 RTO 值和实现策略。
- 窗口全开与恢复:12 秒后,服务端开始读取数据,发出 Window Update 报文打开窗口,客户端的发送随之恢复正常。
在 Wireshark 中使用以下过滤条件,可以快速定位关键报文:
text
tcp.port == 18080 &&
(tcp.analysis.zero_window || tcp.analysis.zero_window_probe ||
tcp.analysis.zero_window_probe_ack || tcp.analysis.window_update)建议选中过滤出的 ACK 报文,手工记录其原始 Window、缩放因子、换算窗口、Ack 以及时间戳。即便某些标签未能成功出现,你仍然可以沿着 Window 数值曲线和客户端的耗时完成分析。
客户端发送完成后,两段 Python 程序会自动退出;随后请在抓包终端按 Ctrl+C 保存 pcap 文件。
跨平台提示:如果你处于缺少 netem 的 Windows 或 macOS 环境,同样可以完成本实验。只需将脚本中的地址改为 127.0.0.1,在两个终端中直接运行相同的 Python 程序,并在回环接口(Loopback Interface)抓包即可。尽管回环卸载机制可能会改变可见的网络分段大小,但 Window 的变化规律以及发送端的阻塞现象,依然是两项确凿且独立的证据。
理解检查
- 假设某报文的原始 Window 字段为 3000,且发送该报文的一端曾在三次握手阶段声明了
Window Scale = 5,那么该报文通告的换算窗口是多少字节? - 当发送端的序列号状态推进至
SND.UNA=10000、SND.WND=12000、SND.NXT=17500时,原始可用窗口 与实际用于发送新数据的空间 分别是多少? - 如果接收端的应用程序已经极其迅速地将数据读进了其自身的内部任务队列中,此时在网络层面上看到的
rwnd会呈现怎样的变化? - Zero-Window Probe 机制的核心目的是为了解决哪一条控制信息可能在网络中丢失的问题?
小结
TCP 的流量控制机制有效地保护了接收端的序列空间和缓冲能力。rwnd 始终由接收端主动通告,Window Scale 选项精妙地解决了线上 16 位字段的换算问题,而滑动窗口则跟随着 Ack 的推进和应用的读取不断向前滑动。当遭遇零窗口时,普通数据的发送将被强行暂停,此时 persist 探测机制成为了确保双方能够及时感知窗口重新打开的关键。在日常排障中,应用的业务队列、rwnd 以及 cwnd 必须作为三个截然不同的层次分别进行观测与剖析。