TCP/UDP 网络协议教程 / 06-TCP 流量控制
06 - TCP 流量控制
6.1 流量控制概述
流量控制的目的是防止发送方发送速度超过接收方的处理能力。
问题场景:
发送方处理速度 100MB/s
接收方处理速度 10MB/s
如果没有流量控制:
发送方不断发送 → 接收方缓冲区溢出 → 数据丢失
流量控制解决方案:
接收方告诉发送方:"我的接收窗口还有多大"
发送方根据窗口大小控制发送速率
6.2 接收窗口 (Receive Window, rwnd)
接收方缓冲区:
┌──────────────────────────────────────────────────┐
│ 已接收待应用读取 │ 可用空间 (rwnd) │
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │░░░░░░░░░░░░░░░░░░░░░░░░░░│
└──────────────────────────────────────────────────┘
↑
rwnd = 可用空间大小
class ReceiveBuffer:
"""接收缓冲区模拟"""
def __init__(self, total_size):
self.total_size = total_size
self.data = bytearray()
def receive(self, data):
"""接收数据"""
available = self.total_size - len(self.data)
if len(data) > available:
raise BufferError("接收缓冲区溢出")
self.data.extend(data)
def read(self, size):
"""应用层读取数据"""
read_data = bytes(self.data[:size])
self.data = self.data[size:]
return read_data
@property
def rwnd(self):
"""当前接收窗口大小"""
return self.total_size - len(self.data)
# 示例
buf = ReceiveBuffer(10000)
buf.receive(b'x' * 8000)
print(f"接收后 rwnd: {buf.rwnd}") # 2000
buf.read(5000)
print(f"读取后 rwnd: {buf.rwnd}") # 7000
6.3 发送窗口
发送窗口 = min(cwnd, rwnd)
cwnd = 拥塞窗口(网络拥塞控制)
rwnd = 接收窗口(流量控制)
发送方维护的窗口:
SND.NXT SND.UNA+W
↓ ↓
┌─────────┬──────────┬────────────┐
│ 已发送 │ 可发送 │ 不可发送 │
│ 未确认 │ │ │
└─────────┴──────────┴────────────┘
↑
SND.UNA
SND.UNA: 最早未确认的序列号
SND.NXT: 下一个要发送的序列号
实际发送窗口 = min(cwnd, rwnd) - (SND.NXT - SND.UNA)
6.4 窗口更新 (Window Update)
场景:接收方缓冲区从满到有空间
发送方 接收方
│ │
│── 发送数据 ──────────────────→ │
│ │ 缓冲区满了
│←── ACK, Window=0 ──────────── │ rwnd = 0
│ │
│ (发送方停止发送,等待窗口更新) │
│ │ 应用读取了数据
│ │ 缓冲区有空间了
│←── ACK, Window=5000 ────────── │ 窗口更新
│ │
│── 继续发送数据 ──────────────→ │
def window_update_simulation():
"""窗口更新模拟"""
buf = ReceiveBuffer(10000)
sender_window = 10000
for i in range(20):
# 发送方根据窗口发送数据
send_size = min(1000, sender_window)
if send_size == 0:
print(f"第{i+1}轮: 窗口为0,等待窗口更新...")
# 应用层读取数据
if len(buf.data) > 0:
buf.read(3000)
print(f" 应用层读取 3000 字节,新窗口: {buf.rwnd}")
continue
buf.receive(b'x' * send_size)
sender_window = buf.rwnd
print(f"第{i+1}轮: 发送 {send_size}, 剩余窗口: {sender_window}")
window_update_simulation()
6.5 零窗口 (Zero Window)
零窗口状态:
接收方通告窗口 = 0
发送方必须停止发送数据
问题:如果窗口更新包丢失怎么办?
发送方永远等待 → 连接"死锁"
解决方案:零窗口探测 (Zero Window Probe)
零窗口探测
零窗口探测机制:
1. 发送方等待一段时间后,发送 1 字节的探测包
2. 接收方回复当前窗口大小
3. 如果窗口仍为 0,继续等待并探测
4. 探测间隔指数退避(1s, 2s, 4s, ... 最大 60s)
发送方 接收方
│ │
│←── ACK, Win=0 ──────────────── │
│ │
│ (等待 RTO) │
│ │
│── Probe (1 byte) ───────────→ │
│ │
│←── ACK, Win=0 ──────────────── │
│ │
│ (等待 2*RTO) │
│ │
│── Probe (1 byte) ───────────→ │
│ │
│←── ACK, Win=5000 ──────────── │ 窗口恢复
│ │
│── 继续发送数据 ──────────────→ │
# 零窗口探测间隔(默认 60 秒)
$ sysctl net.ipv4.tcp_probe_threshold
net.ipv4.tcp_probe_threshold = 8
# 探测次数上限
$ sysctl net.ipv4.tcp_probe_interval
net.ipv4.tcp_probe_interval = 60
6.6 糊涂窗口综合征 (Silly Window Syndrome)
问题描述
糊涂窗口综合征:
接收方每次只通告很小的窗口
发送方发送很小的段
→ 大量小包,效率极低
示例:
接收方缓冲区 1000 字节
应用每次读取 1 字节
→ 接收方每次通告 rwnd = 1
→ 发送方每次只发送 1 字节
→ 效率极低(头部开销远大于数据)
Clark 解决方案
接收方策略:
• 不通告小于 MSS 的窗口
• 或者等到窗口至少为缓冲区空间的一半时才通告
示例:
缓冲区大小 = 1000 字节
应用读取 1 字节后,可用空间 = 1 字节
→ 不通告(因为 1 < MSS)
继续等待,直到可用空间 >= 缓冲区一半
→ 通告窗口更新
Nagle 算法
发送方策略:
1. 如果有未确认的数据,等待 ACK 到达
2. 等待期间积累数据
3. 收到 ACK 后,发送积累的数据(最多一个 MSS)
效果:
• 小包被合并成大包
• 减少网络中小包的数量
• 可能增加延迟
禁用方法:
setsockopt(TCP_NODELAY)
import socket
# 启用 Nagle 算法(默认)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 禁用 Nagle 算法(低延迟场景)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
6.7 延迟确认与 Nagle 的冲突
问题场景:
应用发送小请求,等待响应后再发下一个
发送方(Nagle) 接收方(延迟确认)
│ │
│── 发送请求 ─────────→ │
│ │ ← 收到请求,延迟发送 ACK
│ 等待 ACK... │ 等待合并 ACK...
│ │
│ (双方互相等待) │
│ │
│←── 延迟 ACK(40ms 后)── │
解决方案:
1. 禁用 Nagle:setsockopt(TCP_NODELAY)
2. 使用 writev/sendmsg 一次发送
3. 应用层缓冲后批量发送
6.8 窗口缩放 (Window Scale)
问题:TCP 头部窗口字段只有 16 位,最大 65535 字节
现代网络需要更大的窗口
窗口缩放选项(在握手时协商):
• 缩放因子 S = 0-14
• 实际窗口 = 窗口字段值 × 2^S
• 最大窗口 = 65535 × 2^14 = 1,073,725,440 字节 ≈ 1GB
示例:
窗口字段 = 65535
缩放因子 = 7
实际窗口 = 65535 × 128 = 8,388,480 字节 ≈ 8MB
def window_scale_example():
"""窗口缩放计算"""
header_window = 65535
scale_factor = 7
actual_window = header_window * (2 ** scale_factor)
print(f"头部窗口: {header_window}")
print(f"缩放因子: {scale_factor}")
print(f"实际窗口: {actual_window:,} 字节 = {actual_window/1024/1024:.2f} MB")
window_scale_example()
6.9 流量控制与拥塞控制的区别
| 特性 | 流量控制 | 拥塞控制 |
|---|---|---|
| 目的 | 防止接收方溢出 | 防止网络拥塞 |
| 范围 | 端到端 | 全局 |
| 控制者 | 接收方 | 发送方 |
| 变量 | rwnd (接收窗口) | cwnd (拥塞窗口) |
| 信息来源 | ACK 中的窗口字段 | 丢包、延迟等信号 |
发送窗口 = min(cwnd, rwnd)
如果接收方处理快但网络拥塞:
→ cwnd < rwnd → 发送窗口由 cwnd 决定
如果网络畅通但接收方处理慢:
→ rwnd < cwnd → 发送窗口由 rwnd 决定
6.10 监控与调优
# 查看连接的窗口信息
$ ss -tni | grep -A 1 "192.168.1.100:80"
cubic wscale:7,7 rto:200 rtt:1.5/0.75 ato:40 mss:1448
rcv_space:29200 rcv_ssthresh:29200
# 关键字段:
# wscale:7,7 - 发送和接收的窗口缩放因子
# rcv_space:29200 - 接收空间估计
# 调整接收缓冲区大小
# 最小值 默认值 最大值
$ sysctl -w net.ipv4.tcp_rmem="4096 131072 16777216"
$ sysctl -w net.ipv4.tcp_wmem="4096 131072 16777216"
# 启用自动调整
$ sysctl -w net.ipv4.tcp_moderate_rcvbuf=1
6.11 注意事项
⚠️ 零窗口处理:应用层必须及时读取数据,否则会导致零窗口死锁
⚠️ Nagle 延迟:交互式应用(SSH、游戏)应该禁用 Nagle 算法
⚠️ 窗口缩放协商:只在 SYN 包中协商,中途不可更改
⚠️ 缓冲区大小:太大浪费内存,太小限制吞吐量
6.12 扩展阅读
- RFC 7323 - TCP Extensions for High Performance
- RFC 896 - Nagle’s Algorithm
- RFC 813 - Window and Acknowledgement Strategy
下一章:07 - TCP 拥塞控制 - 慢启动、拥塞避免、BBR