资讯详情

资讯详情

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

1. 从一次实验室的“诡异卡顿”说起如果你做过网络传输相关的实验或者开发大概率遇到过这样一种情况明明带宽跑满了链路质量看着也不错但文件传输到后半段突然就像被踩了刹车速度断崖式下跌过一会儿又慢慢爬回来。更让人抓狂的是这种卡顿往往发生在丢包率并不高的场景里用抓包工具一看重传的包并不多但吞吐量就是上不去。这个问题我在某次搭建模拟网络环境做传输测试的时候就撞上了。当时用的是经典的TCP Reno拥塞控制算法链路模拟的是跨区域的长肥管道带宽不低但延迟偏高。按理说这种环境下Reno应该能跑得不错可实测下来只要链路中出现零星丢包发送端就会频繁进入“快恢复”状态然后吞吐量像坐过山车一样忽上忽下。后来把算法换成NewReno同样的链路条件吞吐量曲线明显平滑了很多整体传输时间缩短了将近两成。这件事让我意识到Reno和NewReno之间的差异远不是教科书上轻描淡写的一句“改进了快恢复”就能带过的。它背后涉及的是对丢包事件的判断逻辑、对拥塞窗口的精细控制以及在多包丢失场景下如何避免不必要的窗口缩减。这些细节恰恰是决定传输效率的关键。这篇文章我想从实际做实验和写代码的角度把NewReno对Reno的优化掰开揉碎讲清楚。不管你是正在学计算机网络的学生还是需要自己实现或调优传输协议的开发者又或者只是好奇为什么换个算法就能让下载快一截下面的内容应该都能给你一些可以直接上手参考的东西。我会尽量避开纯理论的堆砌多讲“为什么这么改”“改了之后实际表现如何”“自己实现的时候要注意什么”。2. Reno的快恢复到底“快”在哪里又卡在哪里2.1 快恢复的设计初衷别一丢包就回到解放前要理解NewReno得先把Reno的快恢复机制吃透。TCP拥塞控制的核心目标很简单在不知道网络真实容量的情况下通过不断试探找到合适的发送速率同时避免把网络压垮。Reno之前有TahoeTahoe的做法非常保守——只要检测到丢包不管是通过超时还是重复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机制。接收端在收到乱序包时会发送重复ACKACK号是最后一个按序到达的字节。发送端根据这个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.5MbpsReno流只有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并看到吞吐量曲线明显变平滑的时候那种“原来如此”的感觉比看十遍教材都管用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →