本文首发在个人博客欢迎前往浏览评论 个人博客在前几天备考的时候做到了这个题目可以跳转到这个链接去看2025年真题看到答案的解析说到由于当前拥塞窗口小于阈值处于慢启动阶段每收到一个ACK拥塞窗口就加 1 MSS故由 2000B 增至 3000B。这一个小细节与我之前一直默认的每一个 RTT 后拥塞窗口翻倍理解不同怎么回事虽然后面证实是我自己看书没看仔细而且 tcp 采用的是累积确认这样岂不是就不会形成翻倍的效果了抱着这个疑问我问了下一个很厉害的同学他的回答是发送端按被确认的字节数来增长cwnd而不是 ACK 的个数一个ACK确认一个MSS下是也可以看ACK个数来增长cwnd。具体怎么实现的就要看协议的设计了。计网很多题都是比较理想的情况下的我不禁好奇怎么又来一个新的说法按照字节确认于是打算自己去探索下看看 TCP 慢启动阶段拥塞窗口cwnd到底是怎么增长的从“每个 RTT 翻倍”开始先从计网教材和考试语境里最熟悉的描述切入初始拥塞窗口cwnd通常设为 1 MSS最新的 RFC 允许设置更多 MSS慢开始算法规定每收到一个对新报文段的确认就将cwnd加 1。这样每经过一个传输轮次一个往返时延 RTTcwnd就会加倍从而随着传输轮次呈指数增长。为了防止cwnd无限制增长还需要设置一个慢开始门限 ssthresh阈值。当cwnd增长至 ssthresh 时停止慢开始算法转而采用拥塞避免算法。可以这样简单概括慢启动阶段每收到一个 ACKcwnd 1 MSS。如果一个 RTT 内每个数据段都能各自触发一个 ACK那么一轮 RTT 之后cwnd 大约翻倍。拥塞避免阶段增长速度降下来约等于每个 RTT 增加 1 MSS。假设阈值设置为 16下面就是一个例子轮次初始 cwnd一个 RTT 内收到 ACK 数RTT 结束后 cwnd01 MSS12 MSS12 MSS24 MSS24 MSS48 MSS38 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 CountingABCRFC 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 cwndmin(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 CountingABC而是采用了另一种处理方式只有当一个 packet 被完整地 ACK 或 SACK 确认时才认为它被确认。这样可以防御 RFC 中描述的 ACK 攻击。对于一个 degree 为 N 的 stretch ACKLinux 的慢启动会把它当作连续收到了 N 个普通 ACK 来处理而 ABC 会把 N 限制到 2。当这次增长本来会让 cwnd 越过 ssthresh 时慢启动会先在阈值处停下并把还没用完的 ACK 数量返回给拥塞避免阶段让拥塞避免继续调整窗口。同时这里有几个词需要先解释一下SACKSelective 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的含义是窗口按完整段数量增加。这个增长方式在理想情况下就和教材里的“每收到一个 ACKcwnd 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 cwndmin(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 cwndmin(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 SMSSRFC 3465 ABC 可用L调节但不超过2*SMSS近似3个段ACK Division 防御不涉及用min(N, SMSS)限制增长只把完整段确认计入acked理想每包一 ACKRTT 内翻倍RTT 内近似翻倍RTT 内近似翻倍可以看出针对慢启动的公式教材给出的与 linux 源码的方式是一样的。但是这样设计的背后还有很多细节和考量如果不是同学的一句话我也了解不到这么多细节如果你也有相关的了解欢迎评论告诉我如果你想试试做一个实验验证下可以参考Linux cwnd 实验参考资料RFC 5681: TCP Congestion ControlRFC 3465: TCP Congestion Control with Appropriate Byte CountingLinux kernel: net/ipv4/tcp_cong.cNJU Linux 镜像Linux cwnd 实验