强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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 扩展阅读


下一章07 - TCP 拥塞控制 - 慢启动、拥塞避免、BBR