TCP 流量控制
TCP 流量控制
一个问题:发得太快对方接不住
假设你给朋友寄礼物,每秒寄一个。朋友家里只能放 10 个礼物,他每秒最多拆 1 个。你寄到第 11 秒,他家满了,他只能把第 11 个礼物扔到门外——礼物丢了。怎么办?
他应该告诉你"我满了,别寄了"。等他家有空位了,他再告诉你"可以寄了"。这种"接收方按自己节奏告诉发送方"就叫流量控制。
TCP 的流量控制就是这个机制。接收方通过 TCP 头部的"窗口"字段告诉发送方"我现在能收多少"。发送方按这个窗口大小发数据,绝不超额。
滑动窗口:TCP 的核心机制
接收窗口(rwnd, Receive Window) 是接收方告诉发送方"我现在还能收多少字节"。
发送方有四个数据范围:
- 已发送已确认:发送方发了,接收方 ACK 了,可以从缓存删了
- 已发送未确认:发送方发了,接收方还没 ACK,发送方保留副本准备重传
- 未发送但允许发送:在窗口内,发送方可以发
- 未发送不允许发送:超出窗口,发送方不能发,要等窗口滑动
发送窗口
|<--------->|
已发送已确认 | 已发送未确认 | 未发送但可发 | 未发送不可发发送方每收到一个 ACK(确认部分数据已收),窗口就向右滑动——左侧"已确认"的数据从缓存删,右侧"未发"的数据进入"可发"范围。窗口滑动过程是连续的,叫滑动窗口协议。
窗口字段在 TCP 头里
TCP 头 16 位"窗口大小"字段告诉对方自己的接收窗口,单位是字节。早期最大 65535 字节(16 位最大值)。
这个数字太小了——现代网络动辄 100Mbps+ 带宽,RTT 100ms,意味着飞行中(in-flight)的数据有 1.25MB。65535 字节根本不够用,限制了传输速度。
TCP 选项里的窗口缩放(Window Scale) 解决了这个问题。三次握手时双方协商缩放因子(最大 14),把 16 位窗口值左移 N 位变成实际窗口大小。例如缩放因子 7,窗口字段 65535 表示 65535 × 128 = 8MB。Linux 默认开启窗口缩放。
零窗口:接收方满了怎么办
如果接收方应用层读数据慢,TCP 接收缓存填满,接收窗口 rwnd 减到 0。发送方收到 rwnd=0 的 ACK,停止发数据。
但有个新问题:发送方停止发数据后,接收方应用层开始消费数据,rwnd 变大了,怎么告诉发送方?
TCP 用"零窗口探测"(Zero Window Probe)解决。发送方定期发一个 1 字节的"探测包",接收方回 ACK 告知当前 rwnd。如果 rwnd 还是 0,发送方等 1-2 分钟再探测;如果非 0,发送方恢复数据发送。
Wireshark 过滤 tcp.window_size == 0 && tcp.flags.ack == 1 看所有零窗口 ACK。
接收缓存和发送缓存
接收缓存是接收方 TCP 维护的环形缓冲区:
- 应用层调用
read()读数据,TCP 缓存释放空间,窗口扩大 - 应用层不读,TCP 缓存填满,窗口缩小
发送缓存是发送方 TCP 维护的环形缓冲区:
- 应用层调用
write()写数据,TCP 把数据放进发送缓存 - TCP 把发送缓存里的数据按窗口大小分批发出去
- 已确认的数据从发送缓存删除
- 没确认的数据留在发送缓存等待重传
write() 成功不代表数据已发送到对方——只代表数据进了 TCP 发送缓存。write() 返回比实际网络传输快得多,应用层必须自己设计协议(长度字段、分隔符)防止粘连。
一个常见 bug:应用层 write() 100KB 数据,TCP 发送缓存只有 50KB,write 返回 EAGAIN 错误。开发者必须正确处理部分写入、重试。
糊涂窗口综合征:Silly Window Syndrome
设想一个场景:发送方每次只能发 1 字节数据(接收方应用层慢,每读 1 字节就 ACK)。TCP 头 20 字节 + 数据 1 字节 = 21 字节包,每个包利用率 1/21 = 4.7%。网络效率极低。
这是糊涂窗口综合征——接收方/发送方在窗口很小时还触发数据发送。
Nagle 算法解决发送方问题:
- 发送方有未确认数据时,新数据先缓存不发送
- 等到 ACK 来了或者缓存数据超过 MSS 才发送
- 减少大量小包
接收方延迟 ACK:
- 收到数据后等 200ms 或等到数据填满 MSS 再 ACK
- 让 ACK 能确认更多数据,发送方才能一次发更多
两者结合避免糊涂窗口。Linux 默认开启 Nagle,但可以 TCP_NODELAY 关闭(实时游戏、低延迟应用场景)。
流量控制 vs 拥塞控制
流量控制:端到端,接收方告诉发送方"我能收多少"。解决"接收方处理不过来"。
拥塞控制:网络层面,网络告诉发送方"网络能承载多少"。解决"路由器队列溢出"。
两者都控制发送方发数据速率,但触发原因不同:
- 流量控制:
rwnd(接收窗口)变化触发 - 拥塞控制:
cwnd(拥塞窗口)变化触发,依赖丢包/延迟信号
发送方实际发送窗口 = min(rwnd, cwnd)。两者都开窗口才能发数据。
真实场景:用 Wireshark 看窗口变化
Wireshark 看 Window size value 列,理解窗口随时间变化:
Time Source Window Size
0.000 Client → Server 65535 (initial, scaled)
0.045 Server → Client 32768 (server's receive window)
1.000 Client → Server 65535
2.000 Server → Client 16384 (server's app read slow)
2.500 Server → Client 0 (zero window, app paused)
4.000 Server → Client 65535 (window opens)突然看到 Server 窗口 0 时,发送方会发零窗口探测。捕获这些 1 字节探测包能定位接收方应用层停顿。
思考题
- 接收方 rwnd 是 0,发送方停发数据。但 5 秒后接收方应用层读了 100KB 数据,rwnd 变 100KB。发送方怎么知道?两种方案:定时探测 vs 接收方主动通知,哪种更优?
- Nagle 算法默认开启,但实时游戏(FPS、MOBA)必须关闭(
TCP_NODELAY)。为什么实时游戏不能等 Nagle 凑包? - 一台服务器同时给 10000 个客户端发数据,接收缓存大小固定(比如 1MB)。如果某个客户端带宽很差,TCP 接收缓存会被这个慢客户端的"未确认数据"占满吗?怎么避免?
- 发送方同时看 rwnd 和 cwnd,发包速度 = min(rwnd, cwnd) / RTT。如果 cwnd=100KB、rwnd=50KB、RTT=100ms,实际发送速率是多少?瓶颈在哪?
延伸阅读
- 《深入浅出计算机网络》(高军)第 5 章
- RFC 793: TCP 协议
- RFC 1323: TCP Extensions for High Performance (窗口缩放、时间戳、Piggyback ACK)
- 动手实验:在 Wireshark 看一个 HTTP 下载过程,观察窗口大小变化
- 动手实验:
ss -t -i看 Linux 当前 TCP 连接的窗口和 cwnd