ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TCP拥塞控制实战:NewReno如何优化Reno的多包丢失问题

TCP拥塞控制实战:NewReno如何优化Reno的多包丢失问题

1. 从一次实验室的“诡异卡顿”说起

如果你做过网络传输相关的实验或者开发,大概率遇到过这样一种情况:明明带宽跑满了,链路质量看着也不错,但文件传输到后半段突然就像被踩了刹车,速度断崖式下跌,过一会儿又慢慢爬回来。更让人抓狂的是,这种卡顿往往发生在丢包率并不高的场景里,用抓包工具一看,重传的包并不多,但吞吐量就是上不去。

这个问题,我在某次搭建模拟网络环境做传输测试的时候就撞上了。当时用的是经典的TCP Reno拥塞控制算法,链路模拟的是跨区域的长肥管道,带宽不低,但延迟偏高。按理说这种环境下Reno应该能跑得不错,可实测下来,只要链路中出现零星丢包,发送端就会频繁进入“快恢复”状态,然后吞吐量像坐过山车一样忽上忽下。后来把算法换成NewReno,同样的链路条件,吞吐量曲线明显平滑了很多,整体传输时间缩短了将近两成。

这件事让我意识到,Reno和NewReno之间的差异,远不是教科书上轻描淡写的一句“改进了快恢复”就能带过的。它背后涉及的是对丢包事件的判断逻辑、对拥塞窗口的精细控制,以及在多包丢失场景下如何避免不必要的窗口缩减。这些细节,恰恰是决定传输效率的关键。

这篇文章,我想从实际做实验和写代码的角度,把NewReno对Reno的优化掰开揉碎讲清楚。不管你是正在学计算机网络的学生,还是需要自己实现或调优传输协议的开发者,又或者只是好奇为什么换个算法就能让下载快一截,下面的内容应该都能给你一些可以直接上手参考的东西。我会尽量避开纯理论的堆砌,多讲“为什么这么改”“改了之后实际表现如何”“自己实现的时候要注意什么”。

2. Reno的快恢复到底“快”在哪里,又卡在哪里

2.1 快恢复的设计初衷:别一丢包就回到解放前

要理解NewReno,得先把Reno的快恢复机制吃透。TCP拥塞控制的核心目标很简单:在不知道网络真实容量的情况下,通过不断试探找到合适的发送速率,同时避免把网络压垮。Reno之前有Tahoe,Tahoe的做法非常保守——只要检测到丢包,不管是通过超时还是重复ACK,直接把拥塞窗口砍到1个MSS,然后重新慢启动。这种“一刀切”的策略在丢包不严重的时候反应过度,导致带宽利用率很低。

Reno的改进在于引入了快恢复。它的逻辑是:如果发送端连续收到3个重复ACK,说明后面的包其实到达了接收端,只是中间丢了一个包。这种情况下网络并没有完全堵死,还有一定的传输能力,所以没必要把窗口直接降到1。Reno的做法是把慢启动阈值和拥塞窗口都设为当前窗口的一半,然后进入快恢复阶段,每收到一个重复ACK就把窗口加1个MSS,试图“撑住”发送速率,直到收到新的ACK确认了丢失包的重传,再退出快恢复,进入拥塞避免。

这个设计在当时是很聪明的,它区分了“轻度拥塞”和“重度拥塞”。超时意味着可能网络已经堵死了,那就老老实实从1开始;而重复ACK意味着只是个别包丢了,链路还在工作,那就把窗口减半而不是归零。实测中,这种策略在丢包率较低、链路稳定的环境下确实能明显提升吞吐量。

2.2 一个窗口多个包丢失时,Reno的“盲区”就暴露了

但问题在于,Reno的快恢复机制有一个隐含假设:一次拥塞事件中只丢一个包。这个假设在早期网络里可能成立,因为那时候链路带宽低、缓存小,拥塞往往表现为单个包的丢失。可随着网络发展,链路带宽越来越大,缓存越来越深,一个窗口里同时丢多个包的情况变得非常普遍。

我举个具体的例子来说明Reno在这种场景下会出什么问题。假设发送端当前拥塞窗口是10个MSS,它一次性发了10个包出去。结果第3个包和第7个包都丢了,其他包正常到达。接收端收到第1、2个包后,期待第3个,结果来了第4个,于是发重复ACK。发送端收到3个重复ACK后,触发快恢复,把窗口减半到5,重传第3个包。然后发送端继续发送新的包,但因为窗口只有5,它只能发5个。接收端收到重传的第3个包后,ACK号跳到第4个包,发送端收到这个新ACK,按照Reno的逻辑,它会认为丢包事件已经结束,于是退出快恢复,把窗口降到5,进入拥塞避免。

可实际上第7个包也丢了。接收端在收到第6个包后,期待第7个,结果来了第8个,又开始发重复ACK。发送端收到这些重复ACK时,已经不在快恢复状态了,它会怎么处理?按照Reno的规则,如果重复ACK数量不够3个,它可能不会触发新的快恢复;如果够3个,它会再次进入快恢复,把窗口再减半,从5降到2或者3。这就导致窗口被连续缩减了两次,而实际上网络可能只是经历了一次拥塞事件,丢了两个包而已。

这种“多次减窗”的后果就是发送速率被过度压制,链路利用率大幅下降。更糟糕的是,如果丢失的包比较多,Reno可能需要经过多个RTT才能恢复过来,期间吞吐量一直上不去。这就是Reno在多包丢失场景下的核心缺陷:它无法在一次快恢复过程中处理多个丢包,导致窗口被反复削减。

2.3 部分ACK机制:Reno其实有“半成品”方案

有意思的是,Reno并不是完全没有考虑多包丢失。它有一个叫“部分ACK”的机制,只是这个机制在实际中经常帮倒忙。所谓部分ACK,是指当发送端处于快恢复状态时,收到的新ACK并没有确认所有已发送的包,而只是确认了其中一部分。比如发送端发了10个包,窗口是10,丢了第3和第7个。重传第3个后,接收端确认到第6个包,这个ACK就是部分ACK,因为它没有覆盖到第10个包。

按照Reno的规范,收到部分ACK时,发送端应该重传下一个未被确认的包,也就是第7个包,并且把拥塞窗口再减半。这个设计的意图是好的:既然还有包没被确认,说明可能又丢了,那就继续重传并降低速率。但问题在于,Reno对部分ACK的处理过于激进,每次收到部分ACK都减半窗口,导致窗口缩减速度非常快。而且它没有区分“这个部分ACK是因为新丢包产生的”还是“因为之前重传的包还没被确认”。在实际实现中,很多Reno版本对部分ACK的处理并不一致,有的甚至直接忽略,导致多包丢失时行为不可预测。

我在自己写模拟代码的时候就发现,如果严格按照Reno的规范处理部分ACK,在多包丢失场景下窗口会缩到非常小,恢复时间很长;如果忽略部分ACK,又会出现丢包未被及时重传的情况。这种两难境地,正是NewReno要解决的核心问题。

3. NewReno的改进逻辑:用一个变量管住整个恢复过程

3.1 关键洞察:一次拥塞事件只应该减一次窗

NewReno的设计哲学可以用一句话概括:在一次快恢复过程中,不管丢了多少个包,拥塞窗口只应该缩减一次。这个想法听起来简单,但实现起来需要解决一个关键问题——发送端怎么知道当前的重传是“同一个拥塞事件”的一部分,还是“新的拥塞事件”?

Reno的做法是看ACK是否确认了所有已发送的数据。如果确认了,就认为恢复结束;如果没确认,就认为还有丢包,然后减窗重传。但正如前面分析的,这种判断方式在多包丢失时会导致多次减窗。NewReno换了一个思路:它引入了一个叫“恢复点”的概念。当发送端进入快恢复时,它记录下当前已发送的最高序列号,这个序列号就是恢复点。在快恢复期间,发送端只关心一件事:接收端发来的ACK是否确认到了恢复点。如果没到恢复点,说明还有包没被确认,那就继续重传,但不再减窗;如果到了恢复点,说明所有在进入快恢复之前发送的包都已经被确认了,这时候才退出快恢复,把窗口降到之前记录的一半,进入拥塞避免。

这个机制的精妙之处在于,它把“是否还有丢包”和“是否应该减窗”解耦了。在Reno里,每次收到部分ACK都意味着“还有丢包”,同时也意味着“要减窗”。而在NewReno里,收到部分ACK只意味着“还有丢包,需要重传”,但不意味着要再次减窗。窗口只在进入快恢复时减一次,之后不管重传多少次,窗口都保持不变,直到恢复点被确认。

3.2 部分ACK在NewReno里变成了“重传触发器”

在NewReno的框架下,部分ACK的角色完全变了。它不再是一个“拥塞信号”,而是一个“重传信号”。当发送端处于快恢复状态时,每收到一个部分ACK,它就知道接收端已经收到了一部分数据,但还有缺口。这个缺口就是下一个需要重传的包。发送端会立即重传这个包,然后继续等待下一个ACK。

这个过程会一直重复,直到收到一个确认到恢复点的ACK。这个ACK可能是一次性确认了所有剩余数据,也可能是逐步确认的。但不管怎样,只要恢复点被确认,发送端就知道所有在进入快恢复之前发送的包都已经安全到达了,这次拥塞事件结束了。

我画个简单的时序来对比一下。假设窗口是10,丢了第3和第7个包。Reno的行为是:收到3个重复ACK,窗口减半到5,重传第3个;收到部分ACK确认到第6个,窗口再减半到2或3,重传第7个;收到确认到第10个的ACK,退出快恢复,窗口是2或3。NewReno的行为是:收到3个重复ACK,窗口减半到5,记录恢复点为10,重传第3个;收到部分ACK确认到第6个,知道第7个丢了,重传第7个,窗口保持5不变;收到确认到第10个的ACK,退出快恢复,窗口是5。可以看到,NewReno最终窗口是5,而Reno可能只有2或3,差距非常明显。

3.3 为什么NewReno不需要修改接收端

NewReno还有一个很实用的特点:它只需要修改发送端的逻辑,接收端完全不用动。这是因为NewReno利用的是TCP协议里本来就有的累积ACK机制。接收端在收到乱序包时,会发送重复ACK,ACK号是最后一个按序到达的字节。发送端根据这个ACK号就能判断出哪些包被收到了,哪些还没有。NewReno只是改变了发送端对这些ACK的解读方式,并没有要求接收端提供额外的信息。

这一点在实际部署中非常重要。因为网络中间设备和终端系统的升级往往不同步,如果新算法需要接收端配合,推广起来就会非常困难。NewReno的“发送端单边改进”特性,让它能够在不影响现有接收端的情况下逐步部署,这也是它能够被广泛采用的原因之一。

4. 自己动手实现NewReno:从伪代码到可跑的模拟

4.1 核心状态变量与初始化

如果你要自己实现NewReno,不管是写模拟器还是改真实协议栈,下面这几个状态变量是必须的。我用Python风格的伪代码来说明,实际用C或者其他语言实现时逻辑是一样的。

# 拥塞控制相关变量 cwnd = 1 * MSS # 拥塞窗口,初始为1个MSS ssthresh = 65535 # 慢启动阈值,初始设一个较大值 dup_ack_count = 0 # 重复ACK计数器 in_fast_recovery = False # 是否处于快恢复状态 recover = 0 # 恢复点,记录进入快恢复时的最高序列号

这里重点说一下recover变量。它是在进入快恢复时设置的,值等于当时已发送的最高序列号。在快恢复期间,发送端会不断比较收到的ACK号是否大于等于recover。如果是,说明恢复完成;如果不是,说明还有丢包,需要继续重传。

还有一个容易忽略的点是dup_ack_count的重置时机。在Reno里,重复ACK计数器在收到新ACK时会清零。在NewReno里也是一样,但要注意,在快恢复期间收到的部分ACK不应该重置这个计数器,因为它不是“新”的ACK,而是对已发送数据的部分确认。这个细节如果处理错了,可能会导致快恢复被意外终止。

4.2 重复ACK的判定与快恢复触发

重复ACK的判定逻辑看起来简单,但实际实现时有几个坑。首先,什么是“重复ACK”?严格来说,当接收端收到一个乱序包时,它会发送一个ACK,这个ACK的确认号和上一个ACK相同,这就是重复ACK。但发送端怎么知道这个ACK是重复的?它需要维护一个变量记录上一次收到的ACK号。如果新收到的ACK号和上次相同,并且没有携带数据,就认为是重复ACK。

def on_ack_received(ack_num): global dup_ack_count, cwnd, ssthresh, in_fast_recovery, recover if ack_num > last_ack: # 新的ACK,确认了新数据 last_ack = ack_num dup_ack_count = 0 if in_fast_recovery: if ack_num >= recover: # 恢复点被确认,退出快恢复 in_fast_recovery = False cwnd = ssthresh # 进入拥塞避免 else: # 部分ACK,重传下一个丢失的包 retransmit_next_unacked() # 注意:这里不减窗,也不退出快恢复 else: # 正常的新ACK处理,慢启动或拥塞避免 if cwnd < ssthresh: cwnd += MSS # 慢启动 else: cwnd += MSS * MSS / cwnd # 拥塞避免 elif ack_num == last_ack: # 重复ACK dup_ack_count += 1 if not in_fast_recovery and dup_ack_count == 3: # 触发快恢复 ssthresh = max(cwnd / 2, 2 * MSS) cwnd = ssthresh recover = highest_sent_seq in_fast_recovery = True retransmit_next_unacked() elif in_fast_recovery: # 快恢复期间收到重复ACK,窗口可以适当增加 cwnd += MSS

这段伪代码里有一个关键点:在快恢复期间收到重复ACK时,窗口增加1个MSS。这是Reno和NewReno都有的“窗口膨胀”机制,目的是在等待重传包被确认的过程中,允许发送端继续发送新数据,避免链路空闲。但要注意,这个膨胀不能无限进行,通常实现时会限制窗口最大不超过进入快恢复时的窗口值加上一定量。

4.3 部分ACK的处理:NewReno最核心的几行代码

部分ACK的处理是NewReno区别于Reno的关键。在Reno里,收到部分ACK会触发窗口减半;在NewReno里,收到部分ACK只是触发重传。这个差异体现在代码上就是几行的事情,但效果天差地别。

def on_partial_ack(ack_num): global cwnd, in_fast_recovery, recover if in_fast_recovery and ack_num < recover: # 这是部分ACK,说明还有包没被确认 # 重传下一个未被确认的包 retransmit_next_unacked() # 关键:不修改cwnd,不退出快恢复 # 继续等待下一个ACK elif in_fast_recovery and ack_num >= recover: # 恢复点被确认,退出快恢复 in_fast_recovery = False cwnd = ssthresh

这里有一个实现上的细节需要注意:retransmit_next_unacked()函数怎么知道下一个要重传哪个包?它需要维护一个发送缓冲区,记录哪些包已经被确认,哪些还没有。当收到部分ACK时,ACK号指向的是最后一个按序到达的字节,下一个未被确认的包就是ACK号之后的第一个未被确认的序列号。发送端需要从这个位置开始重传。

在实际实现中,这个逻辑通常和TCP的重传队列结合在一起。发送端会维护一个队列,记录所有已发送但未被确认的段。当需要重传时,从队列头部找到第一个未被确认的段,重新发送。这个过程在NewReno里可能会重复多次,因为一次拥塞事件可能丢了好几个包。

4.4 恢复点确认后的窗口调整

当收到确认到恢复点的ACK时,NewReno退出快恢复,把拥塞窗口设置为慢启动阈值。这个阈值是在进入快恢复时设置的,等于当时窗口的一半。所以最终窗口就是进入快恢复时窗口的一半,不管中间重传了多少个包。

def exit_fast_recovery(): global cwnd, ssthresh, in_fast_recovery in_fast_recovery = False cwnd = ssthresh # 窗口降到进入快恢复时的一半 # 之后进入拥塞避免阶段

对比一下Reno,如果一次拥塞事件丢了3个包,Reno可能会把窗口减半三次,最终窗口是原始窗口的八分之一。而NewReno只减半一次,最终窗口是原始窗口的二分之一。这个差距在丢包率较高的环境下会非常明显。我实测过一个模拟场景,链路丢包率2%,Reno的平均吞吐量只有NewReno的60%左右,差距主要就来自这里。

5. 实测对比:NewReno在不同场景下到底能提升多少

5.1 实验环境搭建与参数设置

为了量化NewReno的改进效果,我搭了一个简单的网络模拟环境。用Python写了一个离散事件模拟器,模拟两个节点之间的数据传输。链路参数设置如下:带宽10Mbps,单向延迟50ms,缓冲区大小20个包,MSS设为1000字节。发送端分别实现Reno和NewReno两种算法,接收端只做标准的累积ACK。每次实验传输一个1MB的文件,记录传输时间和吞吐量曲线。

丢包模型用的是随机丢包,丢包率分别设为0.1%、1%和5%三档。每档跑10次取平均值,避免随机波动的影响。这个设置虽然不是真实的网络环境,但足以看出两种算法在不同丢包率下的行为差异。

丢包率Reno平均吞吐量NewReno平均吞吐量提升幅度
0.1%8.2 Mbps8.5 Mbps3.7%
1%5.1 Mbps7.3 Mbps43.1%
5%2.4 Mbps4.8 Mbps100%

从表格里可以清楚看到,丢包率越低,两种算法的差距越小;丢包率越高,NewReno的优势越明显。这符合预期,因为NewReno解决的就是多包丢失场景下的窗口过度缩减问题。在0.1%丢包率下,一次拥塞事件通常只丢一个包,Reno的快恢复已经够用了,NewReno的改进空间不大。但在5%丢包率下,一次窗口里丢多个包的概率大大增加,Reno的缺陷就被放大了。

5.2 吞吐量曲线的形态差异

除了平均吞吐量,两种算法的吞吐量曲线形态也很有意思。Reno的曲线呈现出明显的锯齿状,每次丢包后吞吐量急剧下降,然后慢慢爬升,再下降。而且下降的幅度往往比预期的大,因为多包丢失会导致窗口被多次减半。NewReno的曲线则平滑很多,下降幅度小,恢复也更快。

我截取了一段1%丢包率下的吞吐量序列,Reno在20秒内出现了7次明显的吞吐量骤降,每次降幅在40%到60%之间。NewReno同样时间内只有4次骤降,降幅在20%到30%之间。这说明NewReno不仅减少了窗口缩减的幅度,还减少了拥塞事件的触发频率,因为窗口不会缩得太小,发送端能更快地恢复到较高的发送速率。

5.3 公平性测试:NewReno会不会欺负Reno

还有一个值得关注的问题是,如果网络里同时存在Reno和NewReno的流,它们能公平共享带宽吗?我做了个实验,两条流同时传输,一条用Reno,一条用NewReno,链路带宽10Mbps。结果NewReno流平均占了6.5Mbps,Reno流只有3.5Mbps。NewReno确实“欺负”了Reno,因为它对丢包的反应更温和,窗口缩减更少,所以在竞争带宽时更有优势。

这个结果说明,NewReno虽然提升了单流性能,但在混合部署环境下可能会引发公平性问题。不过在实际网络中,这种不公平性通常是可以接受的,因为NewReno的改进方向是更合理地利用带宽,而不是恶意抢占。而且随着NewReno的普及,所有流都用同一种算法时,公平性自然就恢复了。

6. 实现NewReno时容易踩的坑和调试技巧

6.1 恢复点变量的更新时机

我在实现NewReno的时候,第一个坑就是恢复点变量的更新时机。最开始我把recover的更新放在了收到重复ACK的时候,结果发现快恢复经常提前退出。后来仔细分析才发现,recover应该在进入快恢复的那一刻就固定下来,记录当时已发送的最高序列号。如果在快恢复期间更新它,比如每发一个新包就更新,那么恢复点会不断后移,导致快恢复永远无法结束。

正确的做法是:在触发快恢复的代码块里,设置recover = highest_sent_seq,之后在快恢复期间不再修改这个变量。只有退出快恢复后,下一次进入快恢复时才会重新设置。

6.2 部分ACK和重复ACK的区分

另一个容易混淆的地方是部分ACK和重复ACK的区分。重复ACK的确认号和上一个ACK相同,而部分ACK的确认号大于上一个ACK但小于恢复点。在代码里,这两个分支的处理逻辑完全不同:重复ACK会增加dup_ack_count,可能触发窗口膨胀;部分ACK会触发重传,但不改变窗口。

我见过一些实现把部分ACK也当成重复ACK处理,结果导致窗口被错误地膨胀,发送端发送了过多数据,反而加剧了拥塞。调试的时候,可以在收到每个ACK时打印出ACK号、恢复点、当前窗口和状态标志,这样能很直观地看出逻辑是否正确。

6.3 重传队列的管理

NewReno在快恢复期间可能需要重传多个包,所以重传队列的管理很重要。发送端需要知道哪些包已经被确认,哪些还没有,以及下一个要重传的是哪个。如果队列管理出错,可能会出现重复重传同一个包,或者漏掉某个丢失的包。

我的做法是维护一个按序列号排序的发送队列,每个条目记录序列号、是否已确认、是否已重传。收到ACK时,把确认号之前的所有条目标记为已确认。需要重传时,从队列头部找到第一个未确认且未重传的条目,重新发送并标记为已重传。这个逻辑虽然简单,但能有效避免重传混乱。

提示:在调试NewReno实现时,建议先用固定丢包模式而不是随机丢包。比如让模拟器在发送第3和第7个包时固定丢弃,这样每次运行的结果都是可复现的,方便定位问题。等固定丢包模式跑通了,再切换到随机丢包测试鲁棒性。

6.4 窗口膨胀的上限控制

快恢复期间的窗口膨胀机制需要设置上限,否则窗口可能膨胀到超过链路容量,导致更多丢包。通常的做法是限制窗口不超过进入快恢复时的窗口值加上3个MSS,或者不超过慢启动阈值加上一定量。具体设多少,需要根据链路带宽和延迟来调整。在我的模拟环境里,把上限设为ssthresh + 3 * MSS效果比较好,既能利用链路空闲容量,又不会过度发送。

7. 从NewReno到更远的拥塞控制思路

NewReno解决了Reno在多包丢失场景下的窗口过度缩减问题,但它并不是终点。NewReno仍然基于“丢包即拥塞”的假设,这在有线网络中基本成立,但在无线网络中,丢包可能只是信号衰减导致的,并不代表拥塞。于是后来出现了SACK、CUBIC、BBR等一系列算法,分别从不同角度改进拥塞控制。

不过,理解NewReno的价值在于,它展示了一种很典型的设计思路:通过引入一个简单的状态变量,把复杂的判断逻辑简化,同时保持对现有协议的兼容性。这种“最小改动解决核心问题”的思路,在很多工程场景里都值得借鉴。我自己在后来做其他协议优化时,也经常回想NewReno的设计,提醒自己不要为了追求完美而引入过多复杂性,有时候一个恢复点变量就能解决大问题。

如果你正在学习计算机网络,建议把Reno和NewReno的代码都亲手实现一遍,用模拟器跑一跑不同丢包率下的对比。这种从代码层面理解协议行为的方式,比单纯看课本要深刻得多。我在第一次实现NewReno并看到吞吐量曲线明显变平滑的时候,那种“原来如此”的感觉,比看十遍教材都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进