Appearance
深入论述三:MSS/MTU、Nagle 算法、cwnd 拥塞窗口
所属 Day:Day 1 从零编码 · 更新时间:2026-08-20(重构:从原 deep-dive.md 108KB 拆分为 4 篇独立原理文档,本文内容零丢失)
这三个概念在上一节的"网络传输层面"已初步提及,但它们对 TCP 性能的影响远比表面看起来复杂——特别是三者之间的相互作用,是理解 TCP 延迟和吞吐量的关键。
术语速查:
| 缩写 | 全称 | 一句话定义 | 在哪层生效 |
|---|---|---|---|
| MTU | Maximum Transmission Unit | 链路层单帧最大载荷(以太网默认 1500 字节) | L2 链路层 |
| MSS | Maximum Segment Size | TCP 单段最大载荷,MSS = MTU - 40(IP头20 + TCP头20),三次握手协商 | L4 TCP 层 |
| PMTUD | Path MTU Discovery | 通过 ICMP "Frag Needed" 发现路径上最小 MTU | L3 IP 层 |
| Nagle | Nagle 算法 (RFC 896) | 合并小 write():任一时刻最多一个未确认小段 | L4 TCP 层 |
| cwnd | Congestion Window | 发送端维护的拥塞窗口,限制在途数据量 | L4 TCP 发送端 |
| rwnd | Receive Window | 接收端通告的接收窗口,read() 慢 → rwnd 小 | L4 TCP 接收端 |
| ssthresh | Slow Start Threshold | 慢启动阈值,cwnd 超过它后进入拥塞避免 | L4 TCP 发送端 |
| RTT | Round Trip Time | 数据从发送到收到 ACK 的往返时间 | 贯穿 L2-L4 |
| BDP | Bandwidth-Delay Product | 带宽×延迟积,理论上能填满管道的在途数据量 | 端到端 |
核心关系:
MSS决定每段大小 →Nagle决定何时合并小段 →cwnd决定能发多少段 → 实际吞吐量 =min(cwnd, rwnd) / RTT。四个参数像串行的阀门,任何一个关小了,整条连接就被限速。
一、MSS/MTU —— 为什么"不要自己拼大包"
MSS 决定了 TCP 单段能装多少有效载荷,MTU 决定了 IP 层单帧能承载多少字节。两者之间差了一个固定开销(IP 头 20 + TCP 头 20 = 40 字节)。MSS 在三次握手中协商确定,一旦确定,整个连接期间不再改变——这意味着如果路径中间存在 MTU 更小的链路,就需要 PMTUD 来自动发现,否则会产生 IP 分片。
下面这张时序图展示了 MSS 如何在 SYN/SYN+ACK 中完成协商:

MSS 的六个决定因素:
| 序号 | 因素 | 说明 |
|---|---|---|
| 1 | 对端通告的 MSS | 三次握手中 SYN 包的 MSS 选项 |
| 2 | 本端网卡 MTU | MTU - 40(IP 头 20 + TCP 头 20) |
| 3 | 路径 MTU (PMTU) | PMTUD 发现的最小中间链路 MTU |
| 4 | net.ipv4.tcp_base_mss | 内核的默认值,当没有 MSS 选项时使用(通常 512) |
| 5 | advmss(路由表) | ip route 可设置每条路由的 MSS 上限 |
| 6 | TCP 选项占用 | 时间戳选项(12 字节)、SACK 选项等会挤占有效载荷 |
为什么 MSS 很重要?——分片的代价
| 分片情况 | 发生位置 | 对端收到 | 代价 |
|---|---|---|---|
| IP 分片 | IP 层 | 两个 IP fragment(需重组) | 丢任意一片 = 整个包重传 |
| TCP 分段 | TCP 层 | 两个独立 TCP 段 | 丢了只重传丢的那个 |
核心原则:永远让 TCP 自己去分段(基于 MSS),不要让 IP 层做分片。IP 分片没有重传机制——丢一片整个 IP 包作废。
二、Nagle 算法 —— 减少小包但引入延迟
算法定义(RFC 896,John Nagle 1984):
bash
在一条 TCP 连接上,任何时刻最多只能有一个未确认的小段(< MSS)。
如果这个小段还没有被确认,就不能发下一个。下面这张序列图对比了启用 Nagle(默认)和禁用 Nagle(TCP_NODELAY)两种情况下,连续三次 1 字节 write() 的发送效果——注意合并行为如何影响接收端 read() 拿到的内容:

Nagle 的适用性判断:
| 场景 | Nagle 是否合适 | 原因 |
|---|---|---|
| 大块数据传输(文件下载) | ✅ 保持开启 | 数据块接近 MSS,Nagle 几乎不触发 |
| 交互式 SSH/Telnet | ❌ 通常关掉 | 每次按键 1 字节,Nagle 引入 40ms 延迟(等 ACK) |
| HTTP 请求-响应 | ✅ 通常 OK | 写完整个请求后等响应,不连续写 |
| WebSocket / 游戏协议 | ❌ 关掉 | 频繁小消息,Nagle 延迟不可接受 |
| 当前 Day 1 Echo | ⚠️ 影响不大 | 一次 read + 一次 write,不等 ACK 就 close() |
Nagle 与 Delayed ACK 的灾难性交互:Delayed ACK(延迟 40ms 等数据合并 ACK) + Nagle(等 ACK 才能发下一个小段)= 死锁等待 40ms。这是为什么很多低延迟应用同时设置
TCP_NODELAY+TCP_QUICKACK。
三、cwnd(拥塞窗口)与 rwnd(接收窗口)—— TCP 吞吐的双窗口控制模型
TCP 发送端实际上受两种窗口的共同约束——cwnd(拥塞窗口)和 rwnd(接收窗口)。min(cwnd, rwnd) 决定了真正允许的"在途数据量"(bytes in flight),进而通过 RTT 决定了吞吐量上限。
3.1 双窗口递进序列图:cwnd 与 rwnd 在多轮 RTT 中的协同变化
下面这个序列图是本节最重要的一张图。它从冷启动(TCP 连接刚建立)开始,跟踪六轮 RTT 中发送端的 cwnd 增长、接收端 rwnd 的通告变化,以及两者如何共同决定"每轮实际能发多少数据"。
假设条件:RTT=10ms,MSS=1460 字节,初始 cwnd=10 MSS(RFC 6928),接收缓冲区=128KB,窗口缩放因子 wscale=7(即窗口值需左移 7 位),应用层每 RTT 调用一次 read(fd, buf, 8192)。

图一解读——cwnd 主导阶段(RTT 0-2):
这是连接建立后的前两轮往返周期。阅读时关注三个关键变化:
- 发送端的 cwnd 指数增长:每收到一个 ACK,cwnd+1 MSS。RTT 1 结束后 cwnd 从 10→20 MSS,RTT 2 结束后 20→40 MSS。这是慢启动的经典行为——窗口每个 RTT 翻倍。
- 接收端 rwnd 缓缓下降:每轮到达的数据(14.6KB → 29.2KB)开始蚕食 128KB 的接收缓冲区,虽然
read()每轮取走 8KB,但到达速度更快→缓冲区净减少。 - cwnd 是瓶颈:此时
min(cwnd, rwnd)的结果是 cwnd。接收端说"我可以收 121KB",但发送端因为慢启动的限制只敢发 14.6KB→29.2KB。发送端的保守发送恰恰让接收端很从容。
关键认知:RTT 1-2 里 cwnd 远小于 rwnd 是正常状态——TCP 设计上就是让连接的初始阶段由发送端的"自律"来控制节奏。这个阶段吞吐量增长飞快(翻倍),但没有超过接收端的处理能力。
一个经典误解——"慢启动"到底慢在哪里?
指数增长,每轮 RTT 翻倍——这看起来一点也不慢,为什么叫"慢启动"?这个问题困惑了很多开发者,答案需要从两个层次理解:
层次一:慢的不是增长率,是起点。
bash
对比:如果 TCP 一上来就"敞开发"
├─ 假设:BDP = 10MB(带宽 1Gbps × RTT 80ms)
├─ 如果 TCP 没有慢启动,连接一建立就发 10MB
│ └─ 路由器缓冲区瞬间爆满 → 大量丢包 → 全局 TCP 同步震荡
│
├─ 有了慢启动:
│ RTT 0: cwnd = 10 MSS = 14.6KB ← 起点极小
│ RTT 1: cwnd = 20 MSS = 29.2KB
│ RTT 2: cwnd = 40 MSS = 58.4KB
│ RTT 3: cwnd = 80 MSS = 116.8KB
│ ...
│ 要经过 log₂(BDP/init_cwnd) = log₂(10MB/14.6KB) ≈ 9.5 个 RTT 才能填满管道
│ └─ 从"一根头发丝"开始,逐轮翻倍,这叫"慢""慢"是相对于管道容量而言的:TCP 花了好几轮 RTT 才把 cwnd 从一根头发丝涨到能填满管道。对于小文件传输(HTTP 请求只需要 1-2 个 RTT 就传完了),慢启动可能整个传输过程中 cwnd 都没涨到 BDP——这就是为什么短连接的吞吐量利用率远低于长连接。
层次二:不慢的话,互联网早就崩了。
1986 年,Van Jacobson 观察到互联网第一次"拥塞崩溃"(congestion collapse):吞吐量从 32Kbps 跌到 40bps,下降了 1000 倍。原因是 TCP 没有拥塞控制——连接一建立就全力发,路由器丢包后重传、再丢包再重传,网络被重传流量填满,有效数据几乎为零。
慢启动(Slow Start, 1988)就是为了解决这个问题:连接刚建立时,发送端不知道网络容量是多少,所以从最小值开始探测,指数增长快速逼近真实容量。注意 Jacobon 的命名逻辑——"slow"是相对于之前的"一股脑全发"而言的,而不是说算法增长慢。实际上指数增长是 TCP 能找到的最快探测方式了。
一句话:慢启动的"慢"是相对于 BDP 管道的绝对容量而言的——从 14.6KB 起步,每个 RTT 翻倍,要翻 10 轮才能填满 10MB 的管道。但对于刚建立的连接而言,这是在不炸掉网络的前提下最快的容量探测方式。没有慢启动,1986 年的互联网崩溃就是代价。

图二解读——临界点与瓶颈转移(RTT 3-4):
这两轮是整个故事的转折点,注意两个质变:
- RTT 3:cwnd 历史上第一次超过了 rwnd。cwnd 翻到 80 MSS(116.8KB),但接收端通告的 rwnd 只有 50KB。
min(116.8, 50)= 50KB——发送端第一次不是因为自己"不敢发",而是因为接收端说"只能收这么多"。这个瞬间标志着控制权从发送端向接收端转移。 - RTT 4:rwnd 崩溃式缩小。RTT 3 只发了 50KB,RTT 4 中接收端缓冲区从 50KB 跌到 8KB。原因很简单——
read()每轮只取 8KB,但每轮到达 50KB+。净流失 42KB/轮。接收端已经撑不住了,通告 rwnd=8KB。 - cwnd 的尴尬:RTT 4 结束后 cwnd=114 MSS(166.4KB)——很漂亮,但毫无意义。实际能发的只有
min(166.4, 8)= 8KB。cwnd 的数字正在变成摆设。
关键认知:从 RTT 3 开始,问题已经不是"网络能不能承载更多数据",而是"接收端能不能消化它已有的数据"。这是 Day 1 单进程 Echo 的核心瓶颈——一个
read()卡住,所有数据堵在接收缓冲区里,rwnd 归零,发送端停摆。

序列图核心要点:
| RTT | cwnd (KB) | rwnd 通告 (KB) | 有效窗口 (KB) | 瓶颈在谁? | 关键事件 |
|---|---|---|---|---|---|
| 1 | 14.6 → 29.2 | 121 | 29.2 | cwnd | 慢启动,窗口翻倍 |
| 2 | 29.2 → 58.4 | 100 | 58.4 | cwnd | 慢启动继续 |
| 3 | 58.4 → 116.8 | 50 | 50 | rwnd 开始成为瓶颈 | 临界点! cwnd > rwnd |
| 4 | 116.8 → 166.4 | 8 | 8 | rwnd | rwnd 严重不足 |
| 5 | 166.4+ | 8 | 8 | rwnd(锁定) | 吞吐被应用层读速度锁死 |
| 6+ | 持续增长 | ~8 | ~8 | rwnd | 稳态:rwnd / RTT |
核心洞察:前两轮 RTT 中,cwnd 决定了吞吐量(慢启动阶段,窗口尚小)。但从 RTT 3 开始,rwnd 反超成为瓶颈——不是因为网络拥塞,而是因为应用层
read()速度跟不上数据到达的速度。TCP 的双窗口模型保证了一条腿断了另一条腿还能撑住——但撑住的上限由那条更短的腿决定。
3.2 两种窗口的本质区别
cwnd 和 rwnd 虽然都叫"窗口",但创建者、控制逻辑、和目的完全不同:
| 维度 | cwnd(拥塞窗口) | rwnd(接收窗口) |
|---|---|---|
| 谁维护 | 发送端(内部变量,不对端不可见) | 接收端(通过 ACK 的 Window 字段告知对端) |
| 目的 | 防止发送端注入过多数据 → 网络拥塞 | 防止接收端缓冲区溢出 → 数据丢失 |
| 初始值 | 10 MSS (RFC 6928) | 接收缓冲区大小(受 sk_rcvbuf 控制) |
| 变化触发 | 收到 ACK(增长) / 丢包(缩减) | 缓冲区空间变化(数据到达减少,read() 释放增加) |
| 增长方式 | 慢启动:指数;拥塞避免:线性 | 随 read() 释放空间即时恢复 |
| 缩减方式 | 丢包 → cwnd /= 2(超时 → cwnd = 1) | 数据到达 → rwnd 减少(等 read() 释放再恢复) |
| 最坏情况 | cwnd = 1 MSS(超时重传后重新开始) | rwnd = 0(零窗口,发送端完全停止) |
| 对端可见? | 否(发送端私有) | 是(每个 ACK 都携带) |
| 内核参数 | tcp_init_cwnd, tcp_congestion_control | net.core.rmem_default/max, net.ipv4.tcp_rmem |
关键区别:cwnd 是发送端单方面做的"自律"——它在没有外部指令的情况下,自己决定"我不发那么快,怕网络受不了"。rwnd 是接收端主动通告的"指令"——"我这里只有这么多空位了,你别发了"。两者合在一起:发送端自己能发多少自己说了算(cwnd),但对面说停就得停(rwnd)。
3.3 rwnd 的生成机制:从内核缓冲区到 ACK 窗口字段
rwnd 不是凭空出现的数字,它来源于接收端内核中 tcp_sock 维护的接收缓冲区:

rwnd 的三个决定因素:
| 因素 | 内核参数 | 说明 |
|---|---|---|
| 总缓冲区大小 | net.ipv4.tcp_rmem[2](default 默认值,max 最大值),SO_RCVBUF(per-socket) | 决定了 rwnd 的上限 |
| 已占用空间 | sk_receive_queue 中所有 skb 的 truesize 总和 | 已到达但未被 read() 取走的数据 |
| 窗口缩放因子 | 三次握手协商的 wscale(0~14) | rwnd_actual = ACK.window × 2^wscale |
rwnd 的动态变化节奏:
bash
数据到达 → recv_buf 占用增加 → rwnd 减小
↑ 通告给发送端(下一个 ACK)
应用 read() → recv_buf 释放 → rwnd 增大
↑ 通告给发送端(窗口更新)零窗口(rwnd=0)的处理:当接收缓冲区满时,接收端通告 rwnd=0。发送端进入"零窗口探针"模式——每隔一段时间发一个 1 字节的探测段(window probe),检查接收端是否恢复了空间。这个机制防止了"接收端 read() 后通告了窗口更新但该 ACK 丢包 → 死锁"的情况。
3.4 cwnd 的四个阶段(慢启动 → 拥塞避免 → 快速恢复 → 超时)
上面三张序列图展示了 cwnd 在正常情况下的增长轨迹(慢启动阶段)。但在实际网络中,cwnd 不是只涨不跌的——它随时可能因为丢包或超时而大幅回撤。TCP 将 cwnd 的生命周期划分为四个阶段,通过阶段之间的切换来适应网络状态的变化。
下图展示了这四个阶段以及它们之间的切换边界。理解这个状态机是理解 TCP 吞吐量波动的关键——任何一次丢包都可能触发阶段跳变,cwnd 可能从几百 KB 瞬间缩到 1 MSS,吞吐量也随之跳水。

四个阶段的切换边界:
| 阶段 | 增长率 | 触发条件 | 退出条件 | 典型 cwnd 范围 |
|---|---|---|---|---|
| 慢启动 | 指数:每 ACK +1 MSS | 连接建立 / RTO 超时后 | cwnd ≥ ssthresh 或丢包 | 10 MSS → ssthresh |
| 拥塞避免 | 线性:每 RTT +1 MSS | cwnd ≥ ssthresh | 丢包(3 dup ACK 或 RTO) | ssthresh → BDP |
| 快速恢复 | cwnd = ssthresh + 3 | 3 dup ACK | 该丢包的 ACK 到达 | ssthresh 附近 |
| 超时重传 | cwnd = 1 MSS | RTO 到期 | 慢启动重新开始 | 1 MSS |
拥塞窗口里有"指数退避"吗?——没有,指数退避在 RTO 定时器上。
这个区分很重要,因为"指数退避"和"cwnd 回退"作用在不同层面,容易混淆:
bash
RTO = 指数退避(定时器层面)
├─ 第一次超时:RTO = 1s,重传,cwnd = 1
├─ 又超时: RTO = 2s ← 翻倍!
├─ 又超时: RTO = 4s ← 再翻倍!
├─ 又超时: RTO = 8s
├─ ...
└─ 上限通常为 120s(Linux tcp_retries2 控制)
→ 这之所以叫"退避",是因为连续失败时等待越来越久,给网络更多恢复时间
cwnd = 硬重置(拥塞控制层面)
├─ 无论第几次超时,cwnd 都直接归 1 MSS
├─ 不是说"第一次归 1,第二次归 0.5,第三次归 0.25..."
└─ cwnd 不做指数退避,它只做一刀切的重置两个层面合在一起的效果是:连续丢包时,TCP 发得越来越少(cwnd=1),而且等得越来越久(RTO 翻倍)——双层惩罚叠加,给拥塞网络最大的恢复空间。但"指数退避"这个词严格属于 RTO 定时器行为,不属于 cwnd。
一句话:拥塞窗口没有指数退避——超时后 cwnd 直接归 1,不会逐次递减。指数退避发生在 RTO 定时器上(1s→2s→4s→8s...),两者的共同作用让 TCP 在持续丢包时"发得少 + 等得久"。
3.5 主流拥塞控制算法清单与对比
前面 3.1-3.4 都在讲"cwnd 怎么涨、怎么跌"——但涨跌的具体规则取决于你用的是哪个拥塞控制算法。不同的算法对丢包、RTT 变化的反应完全不同,选错算法的后果是:相同的网络条件下,吞吐量可以差出 10 倍。
算法分类与对比:
| 算法 | 年代 | 类型 | cwnd 增长方式 | 丢包反应 | 适合场景 | 不适合场景 |
|---|---|---|---|---|---|---|
| Reno | 1990 | 基于丢包 | 慢启动+拥塞避免(AIMD) | cwnd /= 2 | 低丢包率网络 | 高带宽、高延迟、有损网络 |
| New Reno | 1996 | 基于丢包 | 同 Reno + 快速恢复改进 | cwnd /= 2(改进了部分窗口多个丢包) | 低丢包率,单个窗口少量丢包 | 同上 |
| CUBIC | 2008 | 基于丢包 | 三次函数增长(非 AIMD 线性) | cwnd × 0.8 | Linux 默认,通用场景,长肥管道比 Reno 快 | 高丢包率(>1%)性能急剧下降 |
| Vegas | 1994 | 基于延迟 | 通过 RTT 变化预测拥塞,提前降速 | 检测到 RTT 增大即降速 | 希望避免丢包(低延迟敏感场景) | 与丢包类算法竞争带宽时不公平 |
| BBR | 2016 | 基于 BDP | 周期性探测 BDP 和 RTT,不盲目涨 cwnd | 不依赖丢包信号 | 高丢包/长肥管道最优(YouTube/Google 内部使用) | 会抢占 Reno/CUBIC 的带宽(不公平) |
| BBRv2 | 2019 | 基于 BDP+丢包 | 同 BBR + 丢包作为辅助信号 | 丢包时也减速(比 BBR v1 更公平) | BBR 场景 + 需要与丢包类算法共存 | 实现较新,旧内核不支持 |
算法的核心分歧——用什么信号判断拥塞?
bash
基于丢包(Loss-based):Reno, New Reno, CUBIC
├─ 逻辑:"丢包 = 拥塞"
├─ 行为:一直加速直到丢包,丢包后再减速
├─ 问题:在有损链路(WiFi/移动网络)上,随机丢包被误判为拥塞
│ → 明明网络不堵,但因为丢包率1%,cwnd被反复砍半
└─ 本质:需要"先撞墙"才知道墙在哪
基于延迟/带宽(Delay/BW-based):Vegas, BBR
├─ 逻辑:"RTT 增大 = 缓冲区开始堆积 = 即将拥塞"
├─ 行为:在丢包发生之前就检测到拥塞信号,主动降速
├─ 优势:不需要用丢包来"试探"网络容量
└─ 问题:与丢包类算法共存时,主动让出带宽 → 被 CUBIC 挤占一句话选型:内网低丢包 → CUBIC 就行;跨公网/丢包率 >0.5% → BBR;WiFi/移动网络 → BBR 优势巨大。
应用层如何控制和选择?
拥塞控制算法有三层控制粒度,从粗到细:
bash
第一层:系统级默认算法(重启后仍生效)
# 查看当前默认算法
$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic
# 查看内核支持哪些算法
$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr
# 修改系统默认
# sysctl -w net.ipv4.tcp_congestion_control=bbr
# 如果没有 bbr,先加载模块
# modprobe tcp_bbr
第二层:per-socket 设置(应用层代码控制,最灵活)
int fd = socket(AF_INET, SOCK_STREAM, 0);
// 在 connect() 之前或之后都可以设置
const char *algo = "bbr";
socklen_t len = strlen(algo);
setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, algo, len);
// 读取当前连接使用的算法
char current[16];
socklen_t optlen = sizeof(current);
getsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, current, &optlen);
printf("current cc: %s\n", current); // "bbr"
第三层:不同的连接用不同的算法
// 举例:一个服务同时维护两种连接
// - 文件下载连接 → BBR(大块传输,需要带宽)
// - 实时控制连接 → CDG 或 Vegas(延迟敏感,避免排队)
// 每个 accept() 得到的 fd 可以独立 setsockopt工程实践要点:
TCP_CONGESTION可以在连接建立后、数据传输过程中动态切换——内核会平滑过渡 cwnd。但不要在每次read()/write()之后都切,切换本身有一定开销。- 用
ss -ti确认当前连接的算法和 cwnd 值:ss -ti | grep -E 'cubic|bbr|cwnd'- BBR 需要内核 ≥ 4.9,BBRv2 需要内核 ≥ 5.x。容器环境中注意宿主机内核版本。
- 混合部署 CUBIC 和 BBR 时,BBR 可能会抢占 CUBIC 的带宽——如果公平性重要,统一算法或都上 BBRv2。
3.6 双窗口协同的四种典型场景
| 场景 | cwnd vs rwnd | 实际限制 | 典型例子 | 优化方向 |
|---|---|---|---|---|
| 网络拥塞限制 | cwnd << rwnd | cwnd | 跨公网传输、丢包率高 | 换拥塞控制算法(BBR)、降低延迟 |
| 接收端限制 | rwnd << cwnd | rwnd | 应用层 read() 太慢、recv_buf 太小 | 调大 tcp_rmem、优化应用读速度 |
| 带宽延迟积限制 | cwnd ≈ BDP | BDP | 长肥管道(跨洋链路) | 增大 tcp_init_cwnd、调大 tcp_wmem |
| 零窗口悬挂 | rwnd ≈ 0 | rwnd(≈0) | 接收端进程挂死、GC 暂停 | 打开 window probe 日志、应用层心跳 |
实际吞吐量的计算公式:
bash
吞吐量 = min(cwnd, rwnd) / RTT
当 cwnd 是瓶颈:
吞吐量由拥塞控制算法决定
需要在"多占带宽"和"不引发丢包"之间平衡
当 rwnd 是瓶颈:
吞吐量 = recv_buf_free / RTT(≈ read()速度)
**网络带宽再高也没用**3.7 为什么 cwnd 导致 read() 拿不到全部数据?
回到序列图 RTT 1-3 的场景——发送端有 500KB 数据待发送:
| RTT # | cwnd | rwnd (约) | 有效窗口 | 本轮可发 | 累计到达 | 一次 read(8192) 拿到 |
|---|---|---|---|---|---|---|
| 1 | 10 MSS | 128 KB | 14.6 KB | 14.6 KB | 14.6 KB | 8192 B(最多) |
| 2 | 20 MSS | 100 KB | 29.2 KB | 29.2 KB | 43.8 KB | 8192 B |
| 3 | 40 MSS | 50 KB | 50 KB | 50 KB | 93.8 KB | 8192 B |
| 4 | 80 MSS | 8 KB | 8 KB | 8 KB | 101.8 KB | 8192 B |
| 5 | 114 MSS | 8 KB | 8 KB | 8 KB | 109.8 KB | 8192 B |
结论:
read()每次只能拿到 8192 字节(应用自己设的读取量上限),但发送端可能已经发了 500KB。这些数据大部分在路上(in flight)或者还没发(被 cwnd/rwnd 卡在发送端)。这就是"read()返回量不确定"的最深层根源——前两个是 MSS 分段和 Nagle 合并,第三个是 cwnd 限制了在途数据量,第四个是 rwnd 因应用层读太慢而缩小。
3.8 工程参数速查
| 参数 | 默认值 | 含义 | 增大后果 | 减小后果 |
|---|---|---|---|---|
tcp_init_cwnd | 10 MSS (~14KB) | 初始拥塞窗口 | 首次 RTT 多发数据,适合短连接(HTTP),但可能加重突发 | 慢启动更慢,小文件传输更慢 |
tcp_slow_start_after_idle | 1(开启) | 空闲超时后重置 cwnd | — | 设为 0 保持 cwnd(长连接闲置再恢复时更快) |
tcp_congestion_control | cubic | 拥塞控制算法 | — | bbr 在高丢包/长肥管道更优 |
tcp_rmem[2] | 默认接收缓冲区 | rwnd 上限 | 更多数据缓存在内核,延迟 ACK 策略更有效 | rwnd 容易成为瓶颈 |
tcp_adv_win_scale | 1(默认) | rwnd = buf / 2^adv_win_scale | 数值越小,rwnd 越接近 buf 总大小 | 预留给开销的空间增大,实际 rwnd 减小 |
调参警告:增大
tcp_init_cwnd或tcp_rmem之前,先理解当前瓶颈在哪。用ss -ti看cwnd/rwnd/rtt的实际值。盲目调参最常见的后果是:cwnd 调大了 → 网络拥塞加剧 → 丢包率上升 → 实际吞吐反而下降。