TCP 慢启动的窗口增长:教材、RFC 与 Linux 内核实现的三层对照

本文首发在个人博客,欢迎前往浏览评论 !个人博客


在前几天备考的时候做到了这个题目:

可以跳转到这个链接去看!2025年真题

看到答案的解析说到:

由于当前拥塞窗口小于阈值,处于慢启动阶段,每收到一个ACK,拥塞窗口就加 1 MSS,故由 2000B 增至 3000B。

这一个小细节与我之前一直默认的每一个 RTT 后拥塞窗口翻倍理解不同,怎么回事?(虽然后面证实是我自己看书没看仔细🤪),而且 tcp 采用的是累积确认,这样岂不是就不会形成翻倍的效果了?

抱着这个疑问,我问了下一个很厉害的同学,他的回答是:

发送端按被确认的字节数来增长cwnd,而不是 ACK 的个数(一个ACK确认一个MSS下是也可以看ACK个数来增长cwnd)。具体怎么实现的就要看协议的设计了。计网很多题都是比较理想的情况下的

我不禁好奇,怎么又来一个新的说法,按照字节确认?于是打算自己去探索下,看看 TCP 慢启动阶段,拥塞窗口cwnd到底是怎么增长的?


从“每个 RTT 翻倍”开始

先从计网教材和考试语境里最熟悉的描述切入:

初始拥塞窗口cwnd通常设为 1 MSS(最新的 RFC 允许设置更多 MSS),慢开始算法规定:每收到一个对新报文段的确认,就将cwnd加 1。这样,每经过一个传输轮次(一个往返时延 RTT),cwnd就会加倍,从而随着传输轮次呈指数增长。为了防止cwnd无限制增长,还需要设置一个慢开始门限 ssthresh(阈值)。当cwnd增长至 ssthresh 时,停止慢开始算法,转而采用拥塞避免算法。

可以这样简单概括:

  • 慢启动阶段:每收到一个 ACK,cwnd += 1 MSS
  • 如果一个 RTT 内每个数据段都能各自触发一个 ACK,那么一轮 RTT 之后,cwnd 大约翻倍。
  • 拥塞避免阶段:增长速度降下来,约等于每个 RTT 增加 1 MSS。

假设阈值设置为 16,下面就是一个例子

轮次初始 cwnd一个 RTT 内收到 ACK 数RTT 结束后 cwnd
01 MSS12 MSS
12 MSS24 MSS
24 MSS48 MSS
38 MSS816 MSS <-(达到阈值了)
416 MSS1617 MSS <-(拥塞避免算法)

所以可以看出cwnd随着 RTT 增长,是呈现指数上升的趋势的。就算现在的 tcp 采用的是累积确认方法,其增长趋势也是近似一个指数增长的。

RFC 5681:标准如何定义慢启动

这里可以找到原文

The slow start algorithm is used when cwnd < ssthresh, while the congestion avoidance algorithm is used when cwnd > ssthresh. When cwnd and ssthresh are equal, the sender may use either slow start or congestion avoidance.

During slow start, a TCP increments cwnd by at most SMSS bytes for each ACK received that cumulatively acknowledges new data. Slow start ends when cwnd exceeds ssthresh (or, optionally, when it reaches it, as noted above) or when congestion is observed. While traditionally TCP implementations have increased cwnd by precisely SMSS bytes upon receipt of an ACK covering new data, we RECOMMEND that TCP implementations increase cwnd, per:

cwnd += min (N, SMSS) (2)

where N is the number of previously unacknowledged bytes acknowledged in the incoming ACK. This adjustment is part of Appropriate Byte Counting [RFC3465] and provides robustness against misbehaving receivers that may attempt to induce a sender to artificially inflate cwnd using a mechanism known as “ACK Division” [SCWA99]. ACK Division consists of a receiver sending multiple ACKs for a single TCP data segment, each acknowledging only a portion of its data. A TCP that increments cwnd by SMSS for each such ACK will inappropriately inflate the amount of data injected into the network.

RFC 5681 第 3.1 节的核心意思是:

在慢启动阶段,TCP 对每个累计确认了新数据的 ACK,增加确认的字节数的大小,最多把cwnd增加SMSS(发送方的 MSS)字节。

可以拆成三个关键词:

  • for each ACK received:触发点是收到 ACK。
  • cumulatively acknowledges new data:这个 ACK 必须累计确认了新的数据。
  • cwnd += min (N, SMSS):N 是 ACK 确认收到的字节数,最大不超过发送端 MSS。

为什么这么设置?

这样设置的原因也在原文中说明了:

这个调整是 Appropriate Byte Counting(ABC,RFC 3465)的一部分,它可以增强 TCP 对异常接收方的鲁棒性。因为有些不正常的接收方可能会使用一种叫做 ACK Division 的机制,诱导发送方人为地增大 cwnd。所谓 ACK Division,就是接收方针对同一个 TCP 数据段发送多个 ACK,每个 ACK 只确认这个数据段的一部分。如果某个 TCP 实现对每一个这样的 ACK 都把 cwnd 增加 SMSS,那么它就会不恰当地增加注入网络中的数据量。

简单来说,RFC 这里想防的是:不要让接收方通过“拆 ACK”的方式骗发送方扩大拥塞窗口。

比如发送方只发了一个 SMSS 大小的数据段,正常情况下,接收方应该用一个 ACK 确认这个数据段。此时发送方在慢启动中把cwnd增加 1 SMSS,这没有问题。

但如果接收方故意把这个确认拆成多个小 ACK:

第 1 个 ACK:确认前 200 字节 第 2 个 ACK:确认前 400 字节 第 3 个 ACK:确认前 600 字节 ...

如果发送方仍然采用“每收到一个 ACK,就增加1 SMSS”的规则,那么同一个数据段就会触发多次窗口增长。明明网络里只成功传输了一个段,发送方却以为可以继续大幅增加发送量,这就会让cwnd被不正常地放大。

所以 RFC 5681 推荐使用:

cwnd += min(N, SMSS)

这样做的含义是:

  • 如果一个 ACK 只确认了很少的新字节,cwnd就只增加这部分实际确认的字节数。
  • 如果一个 ACK 确认了很多字节,单次增长最多也只能到1 SMSS

因此,它既保留了正常慢启动中“一个完整 MSS 确认带来约一个 MSS 增长”的效果,又避免了 ACK Division 把窗口恶意撑大的问题。

不过这只是 RFC 5681 / RFC 3465 从“字节计数”角度给出的规则。等到后面看 Linux 内核实现时会发现,Linux 的处理方式又换了一个视角:它不是直接按 ACK 新确认的字节数来加窗口,而是按“被完整确认的段数”来计算。这个差异也是这篇文章后面最有意思的地方。


Linux 内核实现:为什么看起来又不一样

源码位置:Linux kernel: net/ipv4/tcp_cong.c,可以直接搜索tcp_slow_start()

先看核心函数和它前面的注释:

/* Slow start is used when congestion window is no greater than the slow start * threshold. We base on RFC2581 and also handle stretch ACKs properly. * We do not implement RFC3465 Appropriate Byte Counting (ABC) per se but * something better;) a packet is only considered (s)acked in its entirety to * defend the ACK attacks described in the RFC. Slow start processes a stretch * ACK of degree N as if N acks of degree 1 are received back to back except * ABC caps N to 2. Slow start exits when cwnd grows over ssthresh and * returns the leftover acks to adjust cwnd in congestion avoidance mode. */__bpf_kfunc u32tcp_slow_start(structtcp_sock*tp,u32 acked){u32 cwnd=min(tcp_snd_cwnd(tp)+acked,tp->snd_ssthresh);acked-=cwnd-tcp_snd_cwnd(tp);tcp_snd_cwnd_set(tp,min(cwnd,tp->snd_cwnd_clamp));returnacked;}

这段注释的信息量很大,可以先翻译成更容易理解的话:

当拥塞窗口不大于慢启动阈值时,会使用慢启动。Linux 的实现以 RFC 2581 为基础,同时也正确处理 stretch ACK。

Linux 并没有严格实现 RFC 3465 里说的 Appropriate Byte Counting(ABC),而是采用了另一种处理方式:只有当一个 packet 被完整地 ACK 或 SACK 确认时,才认为它被确认。这样可以防御 RFC 中描述的 ACK 攻击。

对于一个 degree 为 N 的 stretch ACK,Linux 的慢启动会把它当作连续收到了 N 个普通 ACK 来处理;而 ABC 会把 N 限制到 2。

当这次增长本来会让 cwnd 越过 ssthresh 时,慢启动会先在阈值处停下,并把还没用完的 ACK 数量返回给拥塞避免阶段,让拥塞避免继续调整窗口。

同时,这里有几个词需要先解释一下:

  • SACK:Selective Acknowledgment,选择确认,用来告诉发送方哪些非连续的数据块已经收到了。
  • (s)acked:内核注释里的写法,意思是 ACKed 或 SACKed。
  • stretch ACK:一个 ACK 不是只确认一个段,而是一次性确认多个段。比如接收方延迟确认,可能收到两个或多个 TCP 段之后才回一个 ACK。
  • degree N:这个 stretch ACK 一次性确认了 N 个完整段。

acked 的确认单位不是字节

这里最关键的一点是:Linux 这段代码里的acked不是“确认了多少字节”,而是“确认了多少个完整段”。

也就是说,RFC 5681 / RFC 3465 更偏向用字节来描述:

cwnd += min(N, SMSS)

其中 N 是 ACK 新确认的字节数。

但 Linux 在这里的实现思路更接近:

cwnd += acked

其中acked是新确认的完整段数。Linux 的snd_cwnd本身也是以段为单位维护的,所以这里的+ acked不是把字节数直接加到窗口上,而是把“确认了几个完整段”加到窗口上。

这就解释了为什么内核代码看起来和 RFC 公式不一样:

  • RFC 讨论时常以字节为单位描述cwnd增长。
  • Linux 这里的acked是“被确认的完整数据段数量”。
  • 因此tcp_snd_cwnd(tp) + acked的含义是:窗口按完整段数量增加。

这个增长方式在理想情况下就和教材里的“每收到一个 ACK,cwnd += 1 MSS”对应:如果一个 ACK 正好确认一个完整 MSS,那么acked = 1,窗口就增加 1 个段。

为什么这样也能防 ACK Division?

Linux 的防御方式与 RFC 不同:它不把一个段的一部分当作一次有效的“完整段确认”。只有当一个 packet 被完整确认时,才会计入acked。所以恶意接收方把一个段拆成多个小 ACK,也不能让acked变成多个完整段。

这就是注释里说的:

Linux 并没有严格实现 ABC,而是做了“更好”的东西。

这里的“更好”可以理解为:它没有按字节粒度做 ABC,而是从“完整段确认”这个粒度上防住了 ACK Division。这样可以有更高的吞吐量,实现起来也没有浮点和字节的计算,而且与拥塞避免阶段衔接的更顺利(后面逐行讲解代码会仔细解释)。

stretch ACK 下 Linux 更激进

再看 stretch ACK。

假设一个 ACK 一次性确认了 3 个完整段,Linux 会把它当作连续收到 3 个普通 ACK,而 RFC 3465 的 ABC 会限制慢启动中的单次增长量,实验性建议里L不能超过2*SMSS。所以在 stretch ACK 场景下,Linux 的处理会比 ABC 更激进一些。

这也解释了注释里的这句话:

Linux 把一个确认 N 个段的 stretch ACK,当成连续来了 N 个普通 ACK;但 ABC 会把这个数量限制到 2。

详解代码:逐行看tcp_slow_start()

现在再回头看代码:

__bpf_kfunc u32tcp_slow_start(structtcp_sock*tp,u32 acked){u32 cwnd=min(tcp_snd_cwnd(tp)+acked,tp->snd_ssthresh);acked-=cwnd-tcp_snd_cwnd(tp);tcp_snd_cwnd_set(tp,min(cwnd,tp->snd_cwnd_clamp));returnacked;}

先解释几个变量:

  • tp:当前 TCP 连接的控制块,里面保存了这个连接的拥塞控制状态。
  • tcp_snd_cwnd(tp):当前发送端拥塞窗口,单位是完整段。
  • tp->snd_ssthresh:慢启动阈值。慢启动最多增长到这个阈值附近,之后就要进入拥塞避免。
  • tp->snd_cwnd_clamp:拥塞窗口的硬上限,防止cwnd超过系统允许的最大值。
  • acked:这次 ACK 新确认的完整段数量。

第一行:

u32 cwnd=min(tcp_snd_cwnd(tp)+acked,tp->snd_ssthresh);

它先计算慢启动阶段能增长到的新窗口:

新 cwnd = min(当前 cwnd + 新确认的完整段数, 慢启动阈值)

也就是说,慢启动阶段确实会按acked增加窗口,但最多只能增加到ssthresh

第二行:

acked-=cwnd-tcp_snd_cwnd(tp);

这一行是在计算“这次 ACK 里,有多少确认量已经被慢启动消耗掉了”。

cwnd - tcp_snd_cwnd(tp)表示这次慢启动实际让窗口增加了多少。然后从acked里减掉这一部分,剩下的就是慢启动没有用完的确认量。

碰到ssthresh时如何处理

举一个例子:

当前 cwnd = 8 ssthresh = 10 acked = 5

如果不考虑ssthresh,窗口本来可以从 8 增长到 13。

但是慢启动最多只能增长到ssthresh = 10

实际新 cwnd = min(8 + 5, 10) = 10

所以慢启动实际只消耗了 2 个acked

实际增长量 = 10 - 8 = 2 剩余 acked = 5 - 2 = 3

第三行:

tcp_snd_cwnd_set(tp,min(cwnd,tp->snd_cwnd_clamp));

这一行真正更新 TCP 控制块里的snd_cwnd。更新时还要再和snd_cwnd_clamp取最小值,保证窗口不会超过系统设置的上限。

最后一行:

returnacked;

函数返回没有被慢启动消耗掉的acked

如果慢启动还没有碰到ssthresh,通常返回 0,表示这次 ACK 的确认量已经全部用来增加慢启动窗口了。

如果刚好在这次 ACK 中跨过了ssthresh,就会返回剩余的acked,让后面的拥塞避免逻辑继续使用它。这样做的好处是:同一个 ACK 中超过慢启动阈值的那部分确认量不会被浪费,而是平滑地交给拥塞避免阶段处理。

所以 Linux 这一段代码可以概括为:

在慢启动阶段: cwnd 按新确认的完整段数 acked 增长; 但最多增长到 ssthresh; 如果这次 ACK 让 cwnd 碰到 ssthresh,就把剩余 acked 交给拥塞避免。

这就是为什么看完 RFC 后再看 Linux,会觉得公式变了:RFC 5681 / RFC 3465 是以“确认字节数”为中心描述规则,而 Linux 这里是以“完整段数”为单位实现窗口增长。


总结:三层对照:同一个慢启动,三种表达

这里通过一个表格来总结或许最合适:

维度教材模型RFC 5681 / RFC 3465Linux 内核
核心视角建立直觉规定协议边界工程实现
增长依据ACK 数量ACK 新确认的字节数NACK 新确认的完整段数acked
慢启动公式每 ACK 确认一个报文段约+1 MSScwnd += min(N, SMSS)cwnd += acked
单位通常按 MSS 抽象字节完整段
stretch ACK 确认 3 段通常不讨论RFC 5681 最多+1 SMSS;RFC 3465 ABC 可用L调节但不超过2*SMSS近似+3个段
ACK Division 防御不涉及min(N, SMSS)限制增长只把完整段确认计入acked
理想每包一 ACKRTT 内翻倍RTT 内近似翻倍RTT 内近似翻倍

可以看出,针对慢启动的公式,教材给出的与 linux 源码的方式是一样的。但是这样设计的背后,还有很多细节和考量,如果不是同学的一句话,我也了解不到这么多细节,如果你也有相关的了解,欢迎评论告诉我!

如果你想试试做一个实验验证下,可以参考Linux cwnd 实验

参考资料

  • RFC 5681: TCP Congestion Control
  • RFC 3465: TCP Congestion Control with Appropriate Byte Counting
  • Linux kernel: net/ipv4/tcp_cong.c
  • NJU Linux 镜像
  • Linux cwnd 实验