TCP 流量控制:接收窗口与发送节奏

前两篇我们反复提到一句话:实际发送窗口取 min(rwnd, cwnd)。拥塞控制管的是 cwnd——网络扛不扛得住;这一篇就来补上 rwnd 这一侧——接收端吃不吃得下

流量控制和拥塞控制天天被一起讲,初学者很容易煮成一锅粥。但它俩的关注点截然相反:拥塞控制防的是"把中间的路挤爆",信号靠发送端自己猜;流量控制防的是"把对端这个人撑死",信号是接收端明明白白通告给你的。一个是猜路况,一个是听对方喊话。把这点先钉死,后面就不会乱。

老规矩,先机制后设计:先看清接收窗口 rwnd 这个数字是怎么算出来、怎么随 ACK 通告给发送端、零窗口时双方怎么僵持又怎么解开,再回头说为什么要这么设计。

一、问题的起点:接收端也会是瓶颈

设想发送端是一台高性能服务器,疯狂往外推数据;接收端却是一台处理能力一般的设备,应用层 read() 得很慢。

数据到了接收端,会先进接收缓冲区(内核空间),等应用调用 read() 把它取走。如果发送端不管不顾地猛发,而应用读得慢,接收缓冲区很快就满了。缓冲区一满,再来的数据无处可放,只能丢弃——而丢弃又会触发发送端重传,重传来了还是没地方放,陷入恶性循环,效率极差。

所以必须有一个机制,让接收端能对发送端说:“我现在最多还能再接 N 字节,你悠着点。” 这个 N,就是接收窗口 rwnd

流量控制的本质,是把"接收缓冲区的剩余空间"实时反馈给发送端,让发送速度自动匹配接收端的消化速度。它是一种端到端的、由接收端主导的背压(backpressure)。

二、rwnd 是怎么算出来又怎么通告的

机制其实很直接。接收端缓冲区总大小固定,里面已经堆了一些"收到了但应用还没取走"的数据。rwnd = 接收缓冲区总大小 − 已占用(已收到未被应用读取)的字节数,也就是缓冲区当前的剩余空间。

通告的载体,就是每一个发回去的 ACK。TCP 报文头里有个 16 位的窗口字段,接收端每次回 ACK 时,都把当前算出的 rwnd 填进去带给发送端。

于是发送端这边形成一条动态约束:

  • 发送端记下对方最近通告的 rwnd
  • 它的"已发送未确认数据量"不能超过这个 rwnd
  • 换句话说,发送窗口右边界 = SND.UNA + min(rwnd, cwnd)

随着接收端应用不断 read() 取走数据,缓冲区腾空,下一个 ACK 通告的 rwnd 变大,发送端就获准多发;如果应用读得慢、缓冲区越堆越满,通告的 rwnd 就越来越小,发送端被迫减速。发送节奏就这样被接收端的消化速度牵着走。

三、零窗口:双方怎么僵住,又靠什么解开

把上面的逻辑推到极端:接收端应用完全卡住不读,缓冲区被填满,rwnd 算出来是 0。接收端就会通告一个 零窗口(zero window)

发送端收到 rwnd=0,必须立刻停止发送新数据——再发也没地方放。于是连接进入一个微妙的状态:没有断开,但数据完全不动了。

这里有个关键的机制陷阱。接下来要靠接收端缓冲区腾出空间后,再通告一个非零窗口(叫窗口更新报文)来解冻。但是——这个窗口更新报文本身是一个 ACK,而 ACK 是不可靠的、不会被重传的! 万一这个"窗口已经打开了"的通知在网络里丢了,会发生什么?

  • 接收端以为:“我已经告诉对方窗口开了,等它发数据。”
  • 发送端以为:“对方窗口还是 0,我不能发,继续等。”

双方互相干等,连接永久死锁

为了破解这个死锁,TCP 设计了零窗口探测(Zero Window Probe):发送端收到零窗口后,会启动一个持续计时器(persist timer),定期(间隔逐渐拉长)向接收端发一个只携带 1 字节的探测报文。接收端收到探测,无论如何都会回一个 ACK,把当前真实的窗口大小捎回来。这样即便之前的窗口更新丢了,探测也能问出最新窗口,死锁被打破。

零窗口探测的精髓在于:解冻的主动权交给发送端,而不是被动等接收端那个可能丢失的通知。用发送端的主动轮询,兜住了 ACK 不可靠的底。 这是 TCP 里"不信任单次通知、用周期性动作兜底"思路的又一个典型,和上一篇 TIME_WAIT 兜底重传 ACK 是同一种智慧。

四、把发送窗口的两道闸门拼起来看

到这里,发送端面前其实有两道独立的闸门,它能发多少由更紧的那道决定:

  • rwnd(流量控制闸门):来自接收端通告,防止压垮对端。接收端读得慢,这道闸就收紧。
  • cwnd(拥塞控制闸门):来自发送端自估,防止挤爆网络。网络丢包,这道闸就收紧。

实际发送窗口 = min(rwnd, cwnd)。这意味着排查"连接为什么慢"时,要分清是哪道闸门在限制:

  • 如果 cwnd 很大但吞吐上不去,且抓包看到对端通告的 rwnd 很小——瓶颈在接收端消化能力(流量控制)。
  • 如果 rwnd 很大但吞吐上不去,且看到重传、cwnd 偏小——瓶颈在网络(拥塞控制)。

两道闸门,两个排查方向,决不能混。

五、窗口缩放:16 位字段不够用了

讲个机制上的现实问题。TCP 头里的窗口字段只有 16 位,最大表示 65535 字节(约 64KB)。在今天的高带宽长距离链路上,这远远不够。

回忆带宽时延积:一条 100Mbps、RTT 100ms 的链路,要填满它需要约 100Mbps × 0.1s ÷ 8 ≈ 1.25MB 的在途数据。但 rwnd 最大才 64KB——窗口还没填满管道,发送端就被流量控制卡住了,带宽白白浪费。

解决办法是窗口缩放选项(Window Scale):在三次握手时双方协商一个缩放因子 S,之后通告的窗口值要左移 S 位(乘以 2^S)来解读。这样 rwnd 的有效上限可以扩大到几百 MB,足以喂饱长肥管道。

注意窗口缩放因子只在握手阶段协商一次,连接中途不能改。这也是为什么抓包分析时,如果漏抓了握手包,Wireshark 会算不准后续的真实窗口——它不知道缩放因子。

六、看懂机制后,再问:为什么这样设计

为什么把窗口控制权交给接收端,而不是发送端自己估? 因为接收端缓冲区的剩余空间,只有接收端自己最清楚——它知道应用 read() 取走了多少。让发送端去猜对端的消化速度,既不准也没必要。直接通告是最精确、最低成本的方案。

为什么用"剩余缓冲区"而不是"固定速率"来限流? 速率难以适配千变万化的应用行为——有的应用一阵猛读、一阵不读。而"剩余空间"是一个自适应的量:读得快空间就大、窗口就开;读得慢空间就小、窗口就收。它天然跟随应用的实际消化节奏,不需要任何预设速率。

为什么零窗口要用探测而不是让接收端负责重发更新? 因为 ACK(包括窗口更新)在 TCP 里不被重传——重传机制是为数据段设计的。与其给 ACK 加一套可靠机制,不如让有发送意愿的一方(发送端)主动探测,代价更小、更自然。

七、和工程实践 / 后端开发的联系

  • 抓包看 Window Size 持续缩小到 0:典型的接收端处理不过来。常见于消费者线程阻塞、下游写库慢、GC 停顿等导致应用 read() 跟不上。看到 TCP Zero WindowTCP Window Update 成对出现,基本能锁定是流量控制在起作用。
  • “连接没断但数据不动”:很多人第一反应是应用死锁,其实可能只是接收窗口被打满、进入零窗口僵持。先抓包看窗口,比盲读代码快得多。
  • 代理层缓冲过小拖慢后端:Nginx 等反向代理如果接收缓冲配置过小,回源时通告的 rwnd 上不去,会拖累后端写入速度。这是流量控制在多级链路上的传导。
  • 长肥管道调优:跨地域传输吞吐上不去,且确认是接收端侧限制时,要检查是否启用了窗口缩放、接收缓冲区(net.core.rmem_max 等)是否够大。缓冲区不够,rwnd 就封顶,带宽用不满。

学习这一部分最容易踩的坑

1. 把流量控制和拥塞控制混为一谈

流量控制(rwnd)防压垮接收端、由对端通告;拥塞控制(cwnd)防挤爆网络、由发送端自估。实际窗口取两者较小值,但成因和排查方向完全相反。这是本系列最该分清的一对概念。

2. 以为零窗口意味着连接出故障了

零窗口往往是正常的背压——接收端在喊"等等我"。连接没断,靠零窗口探测会自动恢复。误判成故障去重启连接,反而打断了正常的流控。

3. 忽视零窗口可能死锁的风险

如果实现有 bug、探测没正常工作,零窗口确实可能僵死。理解探测机制,才能在抓包看到"零窗口后迟迟没有探测报文"时判断出实现异常。

4. 忘了 64KB 上限和窗口缩放

不知道窗口缩放,就会困惑"为什么高带宽链路吞吐卡在某个值"。也会在漏抓握手包时被 Wireshark 显示的窗口值误导。

总结

这一篇我们补齐了发送窗口的另一道闸门 rwnd,坚持先机制后设计:

  • 流量控制防的是压垮接收端,与拥塞控制(防挤爆网络)关注点相反,但共同决定发送窗口;
  • rwnd = 接收缓冲区剩余空间,由接收端在每个 ACK 的窗口字段中通告,发送速度因此被接收端消化速度牵引;
  • 实际发送窗口 = min(rwnd, cwnd),排查慢连接要分清是哪道闸门收紧;
  • 零窗口会让数据停摆,靠发送端的零窗口探测周期性问询来解冻,兜住 ACK 不可靠的底;
  • 窗口字段仅 16 位(64KB),长肥管道靠握手时协商的窗口缩放因子突破上限;
  • 设计上把窗口控制权交给最清楚剩余空间的接收端,是最精确低成本的背压方案。

到这里,TCP 可靠性主线(19 可靠性、20 拥塞控制、21 流量控制)就完整了:你能说清一个字节从入窗、在途、被确认、窗口前移的全过程,也能说清这个窗口同时被接收端和网络两侧约束。接下来我们上升到应用层,看 HTTP 是怎么在这条可靠字节流上表达"网页和接口"的。

参考资源