首页 / 资讯中心 / 文章详情

GBN与SR滑动窗口协议深度解析:机制、窗口大小与工程选型

GBN与SR滑动窗口协议深度解析:机制、窗口大小与工程选型 ★ FEATURED ARTICLE
1. 从一个让人抓狂的传输故障说起做网络协议开发或者分布式系统的人大概率都遇到过这样的场景两台机器之间传数据网络稍微抖一下接收端就开始疯狂丢包发送端要么傻等超时要么把已经发过的数据全部重来一遍。更让人崩溃的是明明只丢了一个包结果后面几百个包全部被重传带宽瞬间被吃满吞吐量断崖式下跌。这个问题的根源就出在差错恢复机制的设计上。传输层协议要解决的核心问题之一就是当数据在链路上丢失、损坏或者乱序到达时收发双方如何协调把丢失的数据补回来同时又不浪费带宽。滑动窗口协议就是解决这个问题的经典方案而它有两个最主流的变体GBNGo-Back-N回退N步和SRSelective Repeat选择重传。这两个名字听起来很学术但它们的区别其实非常直观。GBN的做法是一旦某个包丢了从这个包开始后面所有已发送但未确认的包全部重传。SR的做法是谁丢了就只重传谁其他已经正确到达的包不碰。一个粗暴但简单一个精细但复杂。实际工程中到底该选哪个什么场景下GBN反而比SR更合适窗口大小该怎么设这些问题光看教科书上的定义是找不到答案的。这篇文章面向的是正在学习计算机网络协议、准备面试、或者实际在做可靠传输模块开发的读者。我会把GBN和SR的机制拆开揉碎用具体的数字和场景算给你看再结合我在实际项目中踩过的坑把这两个协议的适用边界讲清楚。不管你是刚接触滑动窗口的新手还是已经写过可靠UDP传输的老手应该都能从中找到有用的东西。2. 滑动窗口到底在滑什么先把底层逻辑理清楚2.1 没有窗口的世界有多可怕要理解滑动窗口先得知道没有它会怎样。最原始的可靠传输方案是停等协议Stop-and-Wait发送方发一个包然后停下来等确认ACK收到ACK之后再发下一个。这个方案逻辑上没问题但效率极低。举个例子假设链路带宽是1Gbps往返时延RTT是30毫秒每个数据包大小是1500字节。发送方发一个包的时间是 1500×8 / 1×10^9 ≈ 12微秒。但发完之后要等30毫秒才能收到ACK。也就是说在这30毫秒里链路完全是空闲的。有效吞吐量只有 1500×8 / (12μs 30ms) ≈ 400Kbps连带宽的千分之一都没用到。这就是停等协议的死穴链路利用率 发送时间 / (发送时间 RTT)。当RTT远大于发送时间时利用率趋近于零。滑动窗口的核心思想就是既然等ACK的时间那么长那我为什么不一次性多发几个包让链路始终有数据在跑2.2 窗口的本质是一份在途数据清单滑动窗口的精髓可以用一句话概括发送方维护一个允许连续发送而无需等待确认的序号范围这个范围就是窗口。打个比方窗口就像你去餐厅点菜。停等协议是你点一道菜等菜上了吃完再点下一道。滑动窗口是你一次性点N道菜厨房按顺序做做好一道端上来一道。你不需要等第一道菜吃完才能点第二道只要厨房还在做你就可以继续点。这个N就是窗口大小。发送方的窗口分为两部分已经发送但还没收到ACK的部分和还没发送但可以发送的部分。随着ACK陆续到达窗口的左边沿向右滑动右边沿也跟着向右扩展让新的序号进入可发送范围。这就是滑动二字的由来。接收方同样维护一个窗口表示它当前愿意接收的序号范围。接收窗口的大小决定了接收方能够缓存多少个乱序到达的包。如果接收窗口是1那就意味着接收方只能按序接收乱序到达的包一律丢弃——这正是GBN的工作方式。2.3 窗口大小受什么约束窗口大小不是随便设的它受到两个硬约束。第一个约束是序号空间。如果序号用n个比特表示序号范围就是0到2^n - 1。对于GBN发送窗口大小W必须满足 W ≤ 2^n - 1。为什么因为如果W 2^n当所有ACK丢失时发送方会重传整个窗口而接收方无法区分这些包是新的还是旧的。留一个序号的余量就是为了打破这种歧义。对于SR约束更严格W ≤ 2^(n-1)。因为SR的接收方会缓存乱序包如果发送窗口和接收窗口加起来超过序号空间就会出现新旧包序号重叠的问题。举个具体例子n3序号空间是0到7。如果W5发送方发了0到4接收方收到了0到4但ACK全丢了。发送方超时后重传0但接收方此时窗口已经滑到了5到1回绕它会把重传的0当成新包接收数据就乱了。所以SR的窗口最大只能到序号空间的一半。第二个约束是接收方缓存能力。窗口越大接收方需要缓存的乱序包越多内存开销越大。在实际系统中窗口大小往往还要考虑接收缓冲区的实际大小。3. GBN的工作机制简单粗暴但有效的回退逻辑3.1 发送方视角一条道走到黑的累积确认GBN发送方的逻辑非常简洁。它维护一个发送窗口窗口内的序号可以连续发送不需要等ACK。每收到一个ACK窗口左边沿就滑到该ACK序号的下一个位置。这里的关键是累积确认Cumulative ACKACK n表示接收方已经正确收到了序号n之前的所有包。这种累积确认机制带来一个很重要的特性ACK是可以丢失的。假设发送方发了0、1、2、3四个包接收方回了ACK 0、ACK 1、ACK 2、ACK 3。如果ACK 1和ACK 2都在路上丢了只要ACK 3到达了发送方发送方就知道0到3全部被正确接收了因为ACK 3是累积的。这大大减少了确认开销。但累积确认也有代价。如果ACK 3丢了但ACK 2到了发送方只知道0到2被收到了不知道3的情况。它会把3重传。如果接收方其实已经收到了3就会丢弃这个重复包再发一次ACK 3。这种重复确认和重复数据在GBN中是常态。3.2 接收方视角不按序到达就一律丢弃GBN接收方的逻辑更简单只接收按序到达的包乱序到达的包直接丢弃并重复发送最后一个正确接收的包的ACK。假设接收方期待序号3结果收到了序号4。它会丢弃4然后重发ACK 2假设2是最后一个正确接收的包。发送方收到这个重复的ACK 2就知道3丢了于是从3开始把所有已发送未确认的包全部重传。这种设计的优点是接收方不需要缓存任何乱序包实现极其简单。缺点是一个包的丢失会导致大量不必要的重传。如果窗口大小是1000序号3丢了后面997个已经正确到达接收方的包全部要被重传。在高带宽、低丢包率的链路上这种浪费可能还能忍受但在丢包率较高的无线链路上GBN的性能会急剧恶化。3.3 一个具体的GBN时序推演我用一个具体的例子把GBN的完整流程走一遍。假设序号空间是0到73比特发送窗口W4发送方要发送序号0到7共8个包。发送方先发0、1、2、3。接收方正确收到0、1、2、3依次回ACK 0、ACK 1、ACK 2、ACK 3。发送方收到ACK 0后窗口滑到1-4收到ACK 1后滑到2-5收到ACK 2后滑到3-6收到ACK 3后滑到4-7。此时发送方可以发4、5、6、7。假设包4在传输中丢失了。接收方期待4但收到了5。它丢弃5重发ACK 3。发送方收到重复的ACK 3意识到4丢了。此时发送方已经发了4、5、6、7窗口是4-7。它会把4、5、6、7全部重传。接收方这次正确收到4、5、6、7依次回ACK 4、ACK 5、ACK 6、ACK 7。注意这里的关键点5、6、7其实第一次就已经正确到达接收方了但因为4丢了它们被丢弃不得不重传。这就是GBN回退N步这个名字的由来——从丢失的那个包开始后面N个包全部回退重传。3.4 GBN的超时机制怎么设GBN发送方为每个已发送但未确认的包维护一个定时器但实际上通常只用一个定时器指向最早的那个未确认包。当这个定时器超时就重传整个窗口。定时器超时时间RTO的设定是个技术活。设太短会导致不必要的重传设太长丢包恢复慢吞吐量下降。经典的RTO计算采用Jacobson/Karels算法核心是根据RTT的均值和方差动态调整先测量每个包的RTT样本计算平滑RTTSRTT (1-α)×SRTT α×RTT_sampleα通常取0.125计算RTT偏差RTTVAR (1-β)×RTTVAR β×|SRTT - RTT_sample|β通常取0.25RTO SRTT 4×RTTVAR这个公式的含义是RTO要覆盖大部分RTT波动4倍偏差大约对应99%的置信区间。在实际实现中RTO还有一个下限比如200毫秒防止在低延迟链路上过于激进。4. SR的工作机制精准打击每一个丢失的包4.1 发送方视角每个包独立确认独立重传SR发送方为窗口内的每个包维护独立的定时器。每个包有自己的超时时间超时了只重传那一个包不影响其他包。ACK也不再是累积的而是逐个确认Individual ACKACK n只表示序号n被正确接收不隐含n之前的所有包都被接收。这种设计的好处显而易见一个包丢了只重传那一个其他已经正确到达的包不受影响。在窗口很大、丢包率较高的场景下SR的带宽利用率远高于GBN。但代价是复杂度上升。发送方需要为每个未确认的包维护定时器需要记录每个包的确认状态窗口滑动逻辑也更复杂——因为ACK可能乱序到达窗口左边沿不能简单地跟着最新ACK走而要等到连续的一段都被确认后才能滑动。4.2 接收方视角缓存乱序包等缺口补齐SR接收方和GBN最大的区别在于它会缓存乱序到达的包。接收方维护一个接收窗口窗口内的包无论是否按序到达都先收下按序号放入缓冲区。只有当窗口左边沿的包到达后才把连续的一段交付给上层然后窗口向右滑动。继续用前面的例子。接收方期待4收到了5、6、7。在GBN中这些包会被丢弃但在SR中它们会被缓存起来。接收方回ACK 5、ACK 6、ACK 7。发送方收到这些ACK后知道5、6、7已经到了只有4还没确认。当4的定时器超时发送方只重传4。接收方收到4后发现4、5、6、7都齐了于是把这一批全部交付上层回ACK 4窗口滑到8-11。对比一下GBN重传了4个包4、5、6、7SR只重传了1个包4。在窗口更大、丢包更分散的场景下这个差距会更加悬殊。4.3 SR的ACK丢失问题与应对SR有一个GBN没有的麻烦ACK丢失的处理更复杂。在GBN中累积ACK天然具有容错性一个ACK丢了后面的ACK可以补上。但在SR中每个ACK只代表一个包如果ACK 5丢了发送方会以为5没到超时后重传5。接收方收到重复的5需要判断这是重传还是新包。这就是为什么SR的窗口大小必须限制在序号空间的一半。接收方需要能够区分这是一个新包和这是一个重传的旧包。如果窗口太大序号回绕后新旧包的序号会重叠接收方就无法判断了。实际实现中接收方通常维护一个已接收序号集合收到包时先查这个集合。如果在集合里说明是重复包丢弃但重发ACK如果不在说明是新包收下并加入集合。这个集合的大小就是接收窗口的大小。4.4 两种协议的逐项对比对比维度GBNSR接收方缓存不需要需要缓存乱序包ACK类型累积确认逐个确认重传范围从丢失包开始的所有未确认包仅丢失的包发送方定时器通常一个最早未确认包每个未确认包一个窗口大小上限2^n - 12^(n-1)实现复杂度低高带宽利用率高丢包时低高带宽利用率低丢包时接近SR接近GBN接收方内存开销小大这张表是面试和实际选型时最常被问到的。但光记住这张表没用关键是要理解每个差异背后的原因以及在实际系统中如何权衡。5. 窗口大小与序号空间的数学关系为什么SR只能用到一半5.1 GBN的窗口上限推导假设序号用n比特表示序号空间大小是N 2^n。GBN发送窗口大小为W。考虑一种最坏情况发送方发了窗口内的所有W个包接收方全部正确收到并回了ACK但所有ACK都丢失了。发送方超时后重传这W个包。接收方此时期待的是下一个新包序号是W假设从0开始。如果W N那么重传的第一个包序号是0而接收方期待的序号也是0因为N个包之后序号回绕到0。接收方无法区分这个0是新包还是旧包的重传就会把它当成新包接收导致数据重复或错乱。如果W N - 1重传的第一个包序号是0接收方期待的是N-1。0不等于N-1接收方知道这是旧包会丢弃并重发ACK。所以GBN的窗口最大是N-1。5.2 SR的窗口上限推导SR的情况更复杂因为接收方也维护窗口。设发送窗口大小为W_s接收窗口大小为W_r。通常两者相等记为W。考虑最坏情况发送方发了0到W-1共W个包。接收方全部收到窗口滑到W到2W-1并回了ACK 0到ACK W-1。但所有ACK都丢失了。发送方超时重传0到W-1。接收方此时期待的是W到2W-1范围内的包。如果重传的0到W-1中的某个序号落在了W到2W-1范围内接收方就会把它当成新包。要避免这种情况需要W ≤ N - W即2W ≤ NW ≤ N/2 2^(n-1)。直观理解发送窗口和接收窗口不能有重叠。发送方还在等确认的序号范围和接收方愿意接收的新序号范围必须完全分开。两个窗口加起来不能超过整个序号空间。5.3 实际系统中窗口大小怎么定理论上的上限是一回事实际设多少是另一回事。窗口大小的选择要综合考虑几个因素带宽时延积BDP窗口至少要能覆盖一个RTT内链路上能容纳的数据量。BDP 带宽 × RTT。比如100Mbps链路RTT 50msBDP 100×10^6 × 0.05 / 8 625KB约416个1500字节的包。窗口至少要这么大才能跑满带宽。接收方缓冲区窗口越大接收方需要的内存越多。在嵌入式设备上内存可能是瓶颈。序号字段宽度TCP用32位序号理论上窗口可以很大但实际受接收窗口字段16位限制最大65535字节后来通过窗口缩放选项可以扩大到约1GB。丢包率丢包率越高GBN的性能越差越应该考虑SR或者类似的机制。在实际的可靠UDP传输库中窗口大小通常是可配置的默认值从几十到几百不等根据网络条件动态调整。6. 从理论到代码手写一个简化版GBN和SR6.1 GBN发送方的核心逻辑下面用Python伪代码展示GBN发送方的核心逻辑重点看窗口滑动和超时重传的处理。class GBNSender: def __init__(self, window_size, total_packets): self.window_size window_size self.base 0 # 窗口左边沿 self.next_seq 0 # 下一个要发的序号 self.total total_packets self.timer_active False def send(self): while self.base self.total: # 窗口未满可以继续发 if self.next_seq self.base self.window_size and self.next_seq self.total: self.send_packet(self.next_seq) if not self.timer_active: self.start_timer() # 只为base启动定时器 self.timer_active True self.next_seq 1 # 等待ACK或超时 event self.wait_for_event() if event.type ACK: # 累积确认ACK n表示n之前全部收到 if event.ack_num self.base: self.base event.ack_num 1 if self.base self.next_seq: self.stop_timer() self.timer_active False else: self.restart_timer() # 为新的base重启定时器 elif event.type TIMEOUT: # 超时重传整个窗口 for seq in range(self.base, self.next_seq): self.send_packet(seq) self.restart_timer()这段代码的关键点只有一个定时器绑定在base上。当base更新时如果窗口内还有未确认的包定时器要重启。超时时重传从base到next_seq-1的所有包。6.2 SR发送方的核心逻辑SR发送方需要为每个包维护独立的定时器和确认状态。class SRSender: def __init__(self, window_size, total_packets): self.window_size window_size self.base 0 self.next_seq 0 self.total total_packets self.acked [False] * total_packets # 每个包的确认状态 self.timers {} # 每个未确认包的定时器 def send(self): while self.base self.total: if self.next_seq self.base self.window_size and self.next_seq self.total: self.send_packet(self.next_seq) self.start_timer(self.next_seq) # 每个包独立定时器 self.next_seq 1 event self.wait_for_event() if event.type ACK: seq event.ack_num if not self.acked[seq]: self.acked[seq] True self.stop_timer(seq) # 窗口左边沿滑动找到连续已确认的最长前缀 while self.base self.total and self.acked[self.base]: self.base 1 elif event.type TIMEOUT: seq event.seq_num self.send_packet(seq) # 只重传超时的那个包 self.restart_timer(seq)对比两段代码SR的复杂度明显更高需要维护acked数组、每个包的定时器、窗口滑动的逻辑也更复杂。但它的重传效率也明显更高。6.3 接收方的差异GBN接收方class GBNReceiver: def __init__(self): self.expected_seq 0 def receive(self, packet): if packet.seq self.expected_seq: self.deliver(packet) self.send_ack(self.expected_seq) self.expected_seq 1 else: # 乱序包丢弃重发最后一个正确接收的ACK self.send_ack(self.expected_seq - 1)SR接收方class SRReceiver: def __init__(self, window_size): self.window_size window_size self.base 0 self.buffer {} # 缓存乱序包 def receive(self, packet): if self.base packet.seq self.base self.window_size: if packet.seq not in self.buffer: self.buffer[packet.seq] packet self.send_ack(packet.seq) # 检查是否可以交付连续的一段 while self.base in self.buffer: self.deliver(self.buffer.pop(self.base)) self.base 1 else: # 窗口外的包重发ACK self.send_ack(packet.seq)SR接收方的buffer就是它比GBN多出来的内存开销。在窗口很大时这个buffer可能占用可观的内存。7. 实际项目中的选型经验与踩坑记录7.1 什么时候GBN反而比SR更合适教科书往往给人一种印象SR全面优于GBN。但实际工程中GBN在很多场景下是更明智的选择。场景一低丢包率的有线链路。在数据中心内部或者质量很好的广域网上丢包率通常在0.01%以下。这种情况下GBN因为丢包而重传的浪费微乎其微而SR带来的额外复杂度和内存开销反而成了负担。很多早期的TCP实现就采用了类似GBN的机制效果很好。场景二接收方资源极度受限。嵌入式设备、传感器节点这类内存只有几十KB的设备根本没有余力缓存大量乱序包。GBN接收方不需要任何缓存实现简单代码量小非常适合这类场景。场景三链路RTT很小。当RTT很小时超时重传的代价低GBN的粗暴重传影响不大。而在高RTT的卫星链路上一次重传的代价很高SR的优势才明显。场景四实现团队经验不足。SR的边界条件很多窗口滑动、序号回绕、重复包判断每一处都容易出bug。如果团队对协议实现经验不足GBN是更稳妥的选择。我见过不少项目一开始用SR结果在序号回绕的边界条件下出了诡异的数据错乱排查了好几天。7.2 SR实现中最容易踩的三个坑坑一窗口滑动时机错误。SR的窗口左边沿不能简单地跟着最新ACK走必须等到连续的一段都被确认。我见过一个实现收到ACK 5就把base设成6结果ACK 3还没到base跳过了3导致3永远无法被确认最终超时重传后接收方又因为窗口已经滑过而丢弃形成死循环。坑二序号回绕处理不当。当序号从最大值回绕到0时所有比较操作都要用模运算不能直接用大于小于。比如判断seq在窗口内不能写base seq base window因为basewindow可能溢出。正确的写法是(seq - base N) % N window。这个bug在窗口接近序号空间上限时才会暴露测试时很容易漏掉。坑三重复ACK导致的定时器重置。收到重复ACK时不应该重置该包的定时器。有些实现收到任何ACK都重置定时器导致一个包反复被确认但定时器一直不超时实际上这个包可能早就丢了。正确的做法是只在第一次收到某个序号的ACK时停止对应定时器。7.3 一个混合方案的思路在实际的高性能传输库中纯粹的GBN或SR都很少见更多的是混合方案。比如TCP的SACK选择性确认选项就是在累积确认的基础上额外告诉发送方哪些不连续的块已经收到了。这样发送方可以只重传真正丢失的包同时保留累积确认的容错性。另一个常见思路是自适应切换在丢包率低时用类似GBN的简单逻辑丢包率升高时切换到SR。这种方案实现复杂度高但在动态网络环境下能取得更好的综合性能。7.4 性能实测的注意事项如果你要对比GBN和SR的性能有几个测试细节必须注意丢包模式随机丢包和突发丢包对两种协议的影响完全不同。GBN在突发丢包时性能下降更严重因为一次突发会导致大量包需要重传。窗口大小窗口太小时两种协议差异不明显窗口至少要覆盖BDP才能看出区别。RTT设置RTT越大重传的代价越高SR的优势越明显。建议在10ms、50ms、200ms三档RTT下分别测试。接收方缓冲区测试SR时接收方缓冲区不能太小否则会人为限制性能。缓冲区至少要能容纳一个窗口的数据。我在实际测试中观察到在1%随机丢包、RTT 50ms、窗口100的条件下SR的吞吐量大约是GBN的1.8倍。但当丢包率降到0.01%时两者差距缩小到5%以内。这个数据可以作为选型时的参考。8. 从协议机制到工程直觉把GBN和SR搞清楚价值远不止于应付考试或面试。这两个协议体现的是可靠传输设计中最核心的权衡简单性与效率之间的取舍。GBN用接收方的简单换取了发送方的浪费SR用发送方和接收方的复杂度换取了带宽效率。这个权衡思路在分布式系统的很多地方都能看到。比如消息队列的消费确认机制是每条消息独立确认还是批量累积确认本质上就是SR和GBN的翻版。再比如数据库的主从复制是每条事务独立同步还是批量同步也是同样的逻辑。理解了窗口大小和序号空间的数学关系你在设计任何带序号和确认机制的协议时都会本能地检查窗口是否会溢出、序号回绕是否会引发歧义。这些直觉不是背公式能得来的而是把原理吃透之后自然形成的。最后分享一个我在调试协议实现时常用的小技巧把收发双方的窗口状态、定时器状态、ACK状态全部打印成时序图用不同颜色标注发送、接收、超时、重传事件。很多看起来诡异的bug在时序图上一眼就能看出问题。比如窗口滑动时机不对时序图上会显示某个序号被跳过定时器管理错误会显示某个包被反复重传但ACK其实早就到了。这个习惯帮我省下了大量排查时间推荐给每一个做协议开发的人。
阅读完成 · 觉得有帮助?
咨询建站