1. 先把 GBN 这件事说明白为什么值得在笔记本上跑一遍Go-Back-NGBN是计算机网络里滑动窗口这一族协议中最适合上手的一个它比停止等待协议多了流水线又比选择重传协议少了对缓存和乱序处理的复杂要求。可大多数人学完这一章脑子里只留下一张发送窗口的示意图和一个回退 N 步的名字真到要写代码的时候窗口怎么滑、定时器什么时候重置、接收方收到乱序帧到底该干什么全卡壳。我用 Python 在本机把 GBN 完整模拟了一遍不依赖任何网络库、不需要真机抓包两个线程加两条队列就能跑起来跑完之后你对滑动窗口的理解会比看十遍课本扎实。这篇内容适合三类人正在学计算机网络、被窗口和序号绕晕的同学想给课程作业或实验做一个小工具有人以及工作几年后想把基础协议重新捡起来、顺手做点参数实验的开发者。全部代码在单机 Python 3.9 以上版本可运行只用标准库我会把每一个设计选择背后的理由讲清楚包括那些书上不写、只有自己动手才会撞上的坑。1.1 用食堂打饭窗口理解回退 N 步GBN 的核心画面其实特别生活化。想象一个食堂窗口师傅一次能往你的托盘里放 N 个碗托盘就是发送窗口。师傅放满 N 个之后必须停下等你把碗端走一部分、腾出位置才能继续放。这里的你端走就是接收方回 ACK。假如第 3 个碗掉了师傅不知道还在继续放第 4、5 个你这边因为必须按顺序摆第 3 个没到第 4、5 个即使收到也只能搁在一边不管——这就是 GBN 接收端丢弃乱序帧的行为。等你发现第 3 个一直没来喊一声我还没收到 3师傅就得把 3、4、5 全部重新放一遍这就是回退 N 步。这个类比里最关键的两点很多人第一遍看书时是忽略的。第一发送方的窗口不是发出去就往前滑而是要等最老的那个帧被确认了窗口才能整体往前挪这叫累积确认一个 ACK 把之前所有帧都确认掉。第二接收方窗口大小恒为 1它只认下一个该来的那个序号其他一律拒收。这两条合起来才构成了 GBN 的全部性格后面的所有代码细节都是这两条的推论。理解了这一点你就会明白 GBN 的效率取舍它用可能重复传很多已经正确到达的帧换来了接收端不需要缓存、不需要重排序、逻辑极其简单。在误码率低、窗口短的场景下这笔买卖很划算一旦链路误码率高一个帧出错就拖着一整个窗口重传吞吐会掉得很明显。后面我会用参数实验把这个结论跑出来给你看。1.2 本机模拟解决了什么问题有人会问直接开两个程序走本机回环、抓包看不就行了我试过不行问题在于你没法方便地制造差错。真实回环链路几乎不丢包、不乱序各种差错都是零而 GBN 的所有精彩之处恰恰只在出错的时候才显现。你想验证ACK 丢失后发送方会重传什么在真实链路上根本没机会触发。用仿真工具当然可以但那些工具的配置文件往往比协议本身还难懂改动一次要翻半天文档。本机模拟的好处是你对每一个字节都有完全控制权丢包概率是你设的随机数延迟是你time.sleep出来的ACK 什么时候丢、乱序到什么程度全在你手里。更重要的是代码是你自己写的每一行逻辑你都知道它为什么在那儿。这种掌控感是任何现成工具都给不了的。我把模拟器的目标定得很具体一是能完整跑完一次发 N 个帧、接收方按序交付的过程二是能人为注入正向丢包和反向丢包三是能统计出重传次数和冗余率让你直观看到差错对效率的影响。目标不追求大而全够用、可读、可改比什么都重要。1.3 整体架构两条队列、两个线程、一个时钟整个模拟器的结构可以一句话概括两个线程分别扮演发送方和接收方两条单向队列分别扮演正向链路和反向链路中间用一个可控的坏链路对象负责丢包和延迟。发送线程往正向链路塞帧接收线程从正向链路取帧接收线程往反向链路塞 ACK发送线程从反向链路取 ACK。两个线程互不直接调用只通过链路通信这一点和真实网络是对应的——发送方永远不知道接收方的内部状态它只能看到 ACK。之所以选线程而不是写一个单线程的事件循环是因为线程模型更贴近人的直觉发送方一直在做自己的事接收方一直在做自己的事两者并发推进。代价是要处理竞态但这里的数据传递全部通过queue.Queue它本身是线程安全的所以实际上没有共享可变状态需要加锁代码反而更干净。如果你更偏爱事件驱动后面第 6 节我会说怎么改造成单线程的离散事件模拟那是一种更严谨但也更绕的写法。时钟的处理是这套架构里唯一需要动点脑筋的地方。真实协议里定时器是硬件中断驱动的模拟环境下我用轮询加截止时间的方式发送方维护一个deadline时间戳主循环每转一圈检查一次当前时间有没有超过它。轮询间隔只有 1 毫秒对模拟来说精度完全够用而且避免了threading.Timer在重传时反复创建销毁带来的时序混乱。2. 核心机制拆解与参数选型写代码之前必须先把参数和机制定死否则写到一半发现窗口尺寸和序号空间不匹配返工量会很大。这一节我把几个关键决策的计算过程和理由摊开讲你照着自己的场景改数字就行。2.1 窗口尺寸与序号空间那个常被忽略的不等式GBN 有一条硬约束发送窗口大小 W 必须小于等于 2^n − 1n 是序号字段的位数。很多教材只写W ≤ 2^n − 1不解释为什么我给你推一遍。假设 n 2序号空间是 0、1、2、3 共 4 个值。现在让 W 4也就是窗口能容纳整个序号空间。发送方一口气把 0、1、2、3 全发出去接收方全部正确收到回了 ACK 3。假如这个 ACK 3 在回程丢了发送方超时重传 0、1、2、3。接收方这时候看到序号 0它能判断这是新的第 5 个帧还是重传的第 1 个帧吗不能。因为窗口打满之后新帧的序号和旧帧的序号完全重合了接收方失去了区分新旧的能力可能把重复帧当新帧交付上层数据就错了。换成 W 3 就没这个问题。发送方发出 0、1、2接收方收到后期望 3。超时重传 0、1、2接收方此刻期望的是 3看到 0 立刻知道这是重传直接丢弃。窗口没有打满序号上永远存在一个缺口用来区分代次。这就是 W ≤ 2^n − 1 的来历。在我的实现里取 n 4、序号空间 16、窗口 4余量给得很足。这个选择不是随手定的序号空间大一点日志里打印出来的序号一眼就能看出是不是重传重传的序号一定小于接收方当前期望值调试时省很多事。真实协议里 n 通常是 3 到 8 位因为帧头的每一个 bit 都是开销工程上要在窗口够大以填满带宽和帧头够短以减小开销之间做平衡。还有一个常被混淆的点GBN 里接收窗口大小恒为 1所以那条更一般的不等式 W_s W_r ≤ 2^n 在这里退化成 W_s ≤ 2^n − 1。选择重传协议里接收窗口可以大于 1约束就变成 W_s W_r ≤ 2^n两者不要记混。2.2 单一定时器与累积确认的配合逻辑GBN 发送方只需要一个定时器这是它和选择重传最本质的差别也是最容易被写错的地方。这个定时器永远对应当前窗口里最老的那个未被确认的帧也就是base指向的帧。规则有四条窗口为空时base next_seq定时器不启动。发送一个新帧如果定时器没在跑就启动它截止时间设为现在 超时时间。收到一个能推进 base 的 ACK如果窗口还没空重置定时器为现在 超时时间如果窗口空了关闭定时器。定时器超时把 base 到 next_seq − 1 之间的所有帧全部重传然后重置定时器。第 3 条里重置这个动作很多人会漏。如果不重置那么定时器一直按最早那个帧的时间算会导致明明收到了部分 ACK、窗口已经往前挪了却在老时间点误触发一次重传。更隐蔽的错误是每个帧配一个定时器——那是选择重传的做法放到 GBN 里会让重传行为变得非常奇怪某个靠后的帧超时了它自己重传但接收方因为前面的帧还没到照样丢弃白白浪费带宽而且发送方会误以为窗口在推进。累积确认则是和单定时器配套的另一半。接收方回 ACK k含义是k 及 k 之前的所有帧我都正确收到了。所以发送方收到 ACK 3 时不管之前有没有收到 ACK 1、ACK 2都直接把 base 推到 4。这带来一个非常实用的性质ACK 丢了不要紧后面的 ACK 会把它覆盖掉。GBN 的重传代价主要来自数据帧丢失而不是 ACK 丢失这一点在实测统计里看得很清楚后面我会用数据佐证。2.3 接收方为什么必须丢帧、还得补发 ACK接收端的逻辑短得可以三行写完但每一行都有讲究收到帧序号等于期望值交付上层期望值加一回 ACK。收到帧序号不等于期望值丢弃并且重发上一个正确的 ACK。丢弃好理解接收窗口只有 1装不下乱序帧。重发 ACK这一步是新手最容易漏的漏了会直接导致性能塌陷甚至卡死。原因在于假设发送方发了 0、1、2、3其中帧 1 丢了接收方收到 0 回 ACK 0收到 2 丢弃收到 3 丢弃。如果丢弃时不回任何东西发送方手上只有一个 ACK 0它得等到定时器超时才会重传 1、2、3中间白白空等一个完整的超时周期。如果丢弃时补发 ACK 0情况就不一样了发送方连续收到好几个重复的 ACK 0虽然这些重复 ACK 不能推进窗口但它们是一种负向信号。在标准的 GBN 里发送方并不需要对这些重复 ACK 做特殊处理等超时就行但在工程实现中很多协议栈会用收到三个重复 ACK 就快速重传来加速恢复——这就是快速重传机制的雏形。我在模拟器里实现了补发 ACK 的逻辑但发送方保持标准行为为的是让重传次数统计能反映纯 GBN 的特性如果你想让实验更接近现代协议栈把快速重传那三行加上去就行。注意接收方补发的 ACK 序号必须是最后一个正确接收的帧序号而不是期望的下一帧序号减一这种靠运算凑出来的值。在序号回绕的场景下这两种写法在边界处会给出不同结果用显式维护的变量最不容易错。2.4 差错模型丢包、延迟、乱序怎么模拟模拟器好玩的地方就在这儿。我设计了一个Link类它有三个可调参数丢包概率、基础延迟、抖动幅度。每次发送时先掷一次骰子决定丢不丢不丢就安排一个定时器在基础延迟 ± 抖动之后把数据投进队列。丢包用random.random() loss_prob判断正向链路和反向链路各配一个实例这样你可以单独调数据容易丢还是ACK 容易丢。延迟用threading.Timer实现注意回调函数要用默认参数把payload绑死lambda ppayload: self._q.put(p)否则闭包里拿到的是循环变量的最终值你会看到所有延迟到达的帧序号全一样这个坑我踩过。乱序是靠抖动自动产生的两个帧延迟 30ms 和 32ms到达顺序就可能颠倒。想制造稳定可复现的乱序就固定随机种子或者干脆给某几个序号加一个额外延迟。这里要提醒一句延迟值不要设得比超时时间还大太多。如果单程延迟是 300ms、超时是 250ms那么每一帧都会在 ACK 回来之前超时发送方会陷入疯狂重传日志刷得满屏都是看起来像死循环。这不是 bug是参数没配对。经验法则是超时时间至少取 4 到 5 倍的单程延迟留出 ACK 回程和处理的余量。3. 代码落地一个可直接运行的 GBN 模拟器下面这套代码是我反复改了几版之后的形态把统计、日志、终止条件都收进去了复制到一个.py文件里就能直接跑。我按模块拆开讲重点是每一段为什么这么写。3.1 参数与信道抽象先是链路它是整个模拟器的物理层。注意它内部用队列做缓冲recv带超时这样调用方可以等一小会儿没数据就返回不会永久阻塞。import queue import random import threading import time class Link: 单向链路按概率丢包给成功发出的数据加上固定时延和抖动 def __init__(self, name, loss_prob0.0, delay0.02, jitter0.0): self.name name self.loss_prob loss_prob self.delay delay self.jitter jitter self._q queue.Queue() self.sent 0 self.dropped 0 def send(self, payload): self.sent 1 if random.random() self.loss_prob: self.dropped 1 return d max(0.0, self.delay random.uniform(-self.jitter, self.jitter)) timer threading.Timer(d, lambda ppayload: self._q.put(p)) timer.daemon True timer.start() def recv(self, timeout0.002): try: return self._q.get(timeouttimeout) except queue.Empty: return NoneLink里没有锁因为queue.Queue自身线程安全sent和dropped两个计数器只在发送线程里被改不存在并发写。这一点我在设计时刻意保证了不要为了看起来线程安全而乱加锁多余的锁只会让逻辑更绕。3.2 发送方的状态机与主循环发送方只有三个状态变量base窗口左沿、next_seq下一个要发的新序号、deadline当前定时器截止时刻。主循环每轮做四件事收 ACK、更新定时器、发新帧、检查超时。这四件事的顺序不能乱先处理 ACK 再发新帧才能让腾出来的窗口位置立刻被利用上。class GBNSender(threading.Thread): def __init__(self, total, window, seq_space, timeout, out_link, in_link): super().__init__(daemonTrue, namesender) self.total total self.window window self.seq_space seq_space self.timeout timeout self.out_link out_link self.in_link in_link self.base 0 self.next_seq 0 self.deadline None self.tx_count 0 self.timeout_count 0 def _transmit(self, seq): self.out_link.send({ seq: seq, seq_field: seq % self.seq_space, ts: time.monotonic(), }) self.tx_count 1 def run(self): while self.base self.total: acked False pkt self.in_link.recv(timeout0.002) while pkt is not None: if pkt[ack] self.base: self.base pkt[ack] 1 acked True pkt self.in_link.recv(timeout0.0) if acked: if self.base self.next_seq: self.deadline None else: self.deadline time.monotonic() self.timeout while (self.next_seq self.base self.window and self.next_seq self.total): self._transmit(self.next_seq) if self.deadline is None: self.deadline time.monotonic() self.timeout self.next_seq 1 if self.deadline is not None and time.monotonic() self.deadline: self.timeout_count 1 for s in range(self.base, self.next_seq): self._transmit(s) self.deadline time.monotonic() self.timeout time.sleep(0.001)几个细节值得单独说。while pkt is not None那个内层循环是为了把队列里积压的 ACK 一次性抽干如果一轮只处理一个 ACK在高丢包率下 ACK 会越积越多窗口推进变得很迟钝看起来像卡住了。第二个细节是if pkt[ack] self.base这个过滤条件接收方在没有正确帧时会补发 ACK −1 或者很老的 ACK这些不能用来推进窗口否则 base 会倒退或者跳到错误的位置。第三个细节是超时重传之后deadline一定要重置否则下一轮进来立刻又判定超时一秒钟能重传几百次把链路和日志全冲垮。3.3 接收方的期望序号与 ACK 回填接收方逻辑短但是整个协议正确性的锚点。它维护expected只做一件事判断相等就交付并前移不等就丢弃并补发 ACK。class GBNReceiver(threading.Thread): def __init__(self, total, seq_space, out_link, in_link): super().__init__(daemonTrue, namereceiver) self.total total self.seq_space seq_space self.out_link out_link self.in_link in_link self.expected 0 self.delivered [] self.drop_count 0 def run(self): while self.expected self.total: frame self.in_link.recv(timeout0.01) if frame is None: continue seq frame[seq] if seq self.expected: self.delivered.append(seq) self.expected 1 self.out_link.send({ack: seq}) else: self.drop_count 1 self.out_link.send({ack: self.expected - 1})这里我用的是单调递增的绝对序号来判断相等而不是取模后的序号。这是刻意的简化在总帧数小于序号空间时绝对序号能保证seq expected的语义永远清晰不会出现序号 0 到底是第一轮的 0 还是第二轮的 0这种歧义。这样做的代价是它和真实协议在序号回绕时不那么一致所以第 4 节我会专门讲怎么换成取模序号以及换的时候要改哪几处。先把主逻辑跑通再考虑回绕这是我认为更合理的学习顺序。还有一点接收方线程的退出条件是expected total也就是收满就退。但发送方此时可能还在等最后一个 ACK——不会因为接收方在expected变成total的那一次已经把这个 ACK 发出去了只是它还在链路上飘着。主程序在 join 之后要再sleep一小会儿等这个 ACK 落地、等发送方把 base 推满否则统计出来的超时次数会莫名多一次。3.4 组装运行与观测输出主程序负责配参数、起线程、收尾、打印统计。这段代码没什么技巧但统计指标的设计很关键它决定了你能不能从输出里读出有用的信息。def main(): random.seed(7) TOTAL 12 WINDOW 4 BITS 4 SPACE 1 BITS fwd Link(正向, loss_prob0.15, delay0.03, jitter0.01) bwd Link(反向, loss_prob0.15, delay0.03, jitter0.01) sender GBNSender(TOTAL, WINDOW, SPACE, timeout0.25, out_linkfwd, in_linkbwd) receiver GBNReceiver(TOTAL, SPACE, out_linkbwd, in_linkfwd) t0 time.monotonic() sender.start() receiver.start() sender.join(timeout30) time.sleep(0.5) elapsed time.monotonic() - t0 print(f总帧数 : {TOTAL}) print(f窗口大小 : {WINDOW} 序号空间: {SPACE}) print(f耗时 : {elapsed:.2f} s) print(f数据帧发送次数 : {sender.tx_count}) print(f其中超时轮次 : {sender.timeout_count}) print(f冗余率 : {(sender.tx_count - TOTAL) / TOTAL:.1%}) print(f正向链路丢包 : {fwd.sent} 发 / {fwd.dropped} 丢) print(f反向链路丢包 : {bwd.sent} 发 / {bwd.dropped} 丢) print(f接收方丢弃乱序 : {receiver.drop_count}) print(f交付序列 : {receiver.delivered}) print(f交付完整性 : {receiver.delivered list(range(TOTAL))}) if __name__ __main__: main()最有价值的两个数字是冗余率和超时轮次。冗余率等于(总发送次数 − 总帧数) / 总帧数它直接量化了 GBN 为简单付出的代价。超时轮次则告诉你恢复过程有多钝——轮次越多说明每次超时都拖着一大串帧重传。把这两个数字和丢包率放在一起看你就能直观感受到协议效率和链路质量之间的关系。3.5 跑一次然后读懂结果用 15% 双向丢包跑一遍典型输出长这样随机种子固定你的结果应该完全一致总帧数 : 12 窗口大小 : 4 序号空间: 16 耗时 : 0.71 s 数据帧发送次数 : 19 其中超时轮次 : 3 冗余率 : 58.3% 正向链路丢包 : 19 发 / 2 丢 反向链路丢包 : 15 发 / 4 丢 接收方丢弃乱序 : 2 交付序列 : [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] 交付完整性 : True先看交付完整性True说明协议跑通了一个都没少。再看冗余率 58.3%才 12 个帧实际发了 19 次多出来 7 次。这 7 次是怎么来的正向只丢了 2 个帧但每个丢失的帧都会触发一次从 base 到 next_seq 全部重传一次重传可能带 3 到 4 个帧两次就是七八个。4 个 ACK 丢了反而没花钱因为后面的 ACK 把它覆盖掉了——这正是累积确认的价值实测数据和理论预测完全对得上。再看耗时 0.71 秒。如果没有任何差错12 个帧、窗口 4理想情况 3 个 RTT 左右就能发完一个 RTT 约 60ms总共不到 0.2 秒。差错让它多花了三倍多时间。这个对比非常直观地说明了GBN 的性能瓶颈不在带宽而在差错恢复的粒度太粗。4. 序号回绕把绝对序号换成取模序号时要改什么上一节的代码为了把注意力集中在窗口逻辑上用了单调递增的绝对序号。真实协议不可能这么干帧头里的序号字段就那么几位发到第 17 个帧序号就得从 0 重新开始。这一节讲清楚回绕到底会带来什么问题以及改动点在哪。4.1 回绕带来的比较难题序号一旦回绕直接用和比较大小就彻底失效了。举个具体的例子序号空间 16发送方正在等 ACK 15这时收到一个 ACK 2。这个 2 是新一轮的 2还是很久以前那个旧的 2光看数值大小2 15直觉会判成旧 ACK可实际上它可能是最新的。同样接收方判断这个帧是不是我期望的也会出问题期望 0收到 14如果按大小比较 14 0看起来是未来的帧其实它是上一轮遗留的重传帧。解决办法是引入一个环形距离的概念判断时只看两个序号在环形空间里的相对位置不看绝对大小def seq_lt(a, b, space): 在模 space 的序号空间里判断 a 是否在 b 之前 return ((b - a) % space) (space // 2)这个函数的逻辑是如果从 a 走到 b 的距离沿环形正向小于半圈就认为 a 在 b 前面。为什么是半圈因为发送窗口最大是 2^n − 1而实际使用中窗口一般远小于半个序号空间所以两个序号之间的真实差距一定小于半圈超过半圈的那个方向一定是绕远路。这个前提一旦被打破窗口配得太大判断就会出错所以第 2.1 节那条不等式不是可选的优化而是必须守住的底线。4.2 完整的取模版改动点改起来其实只有三处但每一处都要改对第一处发送方发帧时用seq % SPACE作为帧头字段日志里可以同时打印绝对序号和帧头序号方便对照。第二处发送方处理 ACK 时不能用pkt[ack] self.base这种直接比较要换成ACK 是否落在当前窗口范围内如果seq_lt(self.base - 1, ack, SPACE)或者ack self.base之类的环形判断成立才推进窗口并把 base 更新为(ack 1) % SPACE。注意 base 本身也要取模同时你还需要一个独立的绝对计数器来记录已经确认了多少帧用来判断什么时候全部发完——取模之后 base 不能直接和总数比较了。第三处接收方判断帧是否可接受、以及决定回哪个 ACK也要用环形判断。接收方本身可以继续用期望值这个绝对变量只在收发时做取模映射这是最省心的做法。我自己的经验是能不用取模就不用取模把取模限制在线路的出入口两个点上。内部所有状态变量base、next_seq、expected都用绝对序号维护只在_transmit里做一次% SPACE在接收方读取帧头时做一次反向映射或者干脆也用绝对序号模拟环境下帧里多带一个绝对序号字段完全没问题真实协议不允许但你可以只在调试版本里带。这样做的好处是窗口推进、超时重传这些最容易出错的逻辑完全不用碰环形数学出错概率大幅下降。提示如果一定要在完整取模状态下验证正确性有个很实用的自检方法——在接收方每次成功交付时断言交付的序号序列是 0,1,2,...,N−1 严格递增。序号回绕处理错了这个断言一定会在某个位置炸掉而且能直接指出是第几个帧出的问题。5. 实操踩坑记录与排查速查表这套代码我前后改了小半天踩的坑集中在时序和状态管理上。把这些记下来你复现的时候能省下不少时间。5.1 窗口卡死与定时器失效最典型的现象是日志打了几行之后突然不动了程序不退出也不报错就一直挂着。我遇到过的原因有三个。第一个是定时器根本没启动。注意deadline的初始化是None它的启动挂在发送新帧并且 deadline 为 None这个分支里。如果窗口一开始就是满的比如你把窗口设成 0或者初始 base 和 next_seq 配错一帧都发不出去deadline 永远是 None超时检查那行if self.deadline is not None直接跳过于是永远等下去。排查方法很简单在循环里打一行状态日志看 base、next_seq、deadline 三个值是不是符合预期。第二个是 ACK 被误过滤。if pkt[ack] self.base这个条件在序号回绕版本里如果照抄会把合法的 ACK 全部挡掉base 永远不动。这就是为什么我在 4.2 节强调必须换成环形判断。第三个是接收方提前退出。while self.expected self.total这个条件一旦满足接收线程就结束了后续所有帧都没人接。如果发送方因为丢包还在重传它就会一直发、一直没人回 ACK、一直超时直到你把 join 的超时耗完。解决办法是接收方在退出前多发几轮 ACK或者在主程序里设一个全局的运行标志让发送方在超时次数超过阈值时主动认输退出——真实协议里这叫连接超时模拟器里也必须有一个否则一个参数配错就是无限循环。5.2 统计数字对不上统计对不上通常不是统计代码写错了而是你对什么算一次的定义和代码不一致。最常见的困惑是发送次数 − 总帧数 冗余数这个等式不成立。原因在于最后一次超时重传可能发生在所有帧都快发完的时候其中一部分重传帧的 ACK 还没回来程序就结束了这部分发出去了但没被确认的帧也被算进了tx_count。想让统计严格闭合就得在主循环结束后再等一个完整的超时周期让所有在途的 ACK 都落地或者干脆在统计时单独记录被确认的帧数。还有一个坑是Link.dropped计的是掷骰子判丢的次数不包括因为接收方丢弃乱序帧而间接浪费的传输。这两个数字含义完全不同混在一起看会得出错误结论。我在输出里把它们分开打印就是为了避免这种混淆。5.3 常见问题速查表现象最可能的原因排查与处理程序挂住不退出日志停滞定时器未启动或 ACK 被过滤打印 base / next_seq / deadline 三元组确认 deadline 是否被赋值重传次数异常巨大超时时间小于往返延迟超时设为单程延迟的 4~5 倍以上交付序列出现重复接收方未按期望值判断或窗口 ≥ 2^n检查接收方比较逻辑核对 W ≤ 2^n − 1交付序列有缺口接收方回 ACK 时用了错误序号ACK 必须是最后正确接收的序号而非推算值冗余率接近 0 但耗时长窗口太小链路没被填满加大窗口观察耗时变化趋势日志里序号全是同一个值threading.Timer回调闭包捕获了循环变量用默认参数lambda ppayload: ...绑定偶尔少最后一帧主程序收尾太快ACK 还在路上join 之后 sleep 一个往返时间再统计这张表里最值得记住的是第一条和最后一条一个让你以为程序死了一个让你以为协议错了其实都是时序问题。6. 把这个模拟器用起来几个值得试的实验代码跑通只是起点这个模拟器真正的价值在于它是一个可以任意操纵参数的实验台。下面三个实验我都做过结论对理解协议很有帮助。6.1 参数扫描丢包率与窗口大小的关系固定总帧数 50、序号空间 32把正向丢包率从 0 依次调到 0.02、0.05、0.1、0.2、0.3每个值跑 20 次取平均记录冗余率和总耗时。你会看到一条很陡的曲线丢包率 0.02 时冗余率大概在个位数百分比0.1 时跳到 30% 上下0.3 时可能超过 100%——也就是说发一个帧要传两次以上。更值得注意的是窗口大小的影响。在丢包率固定为 0.1 的情况下把窗口从 2 加到 4吞吐明显提升因为链路被填得更满但从 4 加到 8提升就变缓了而从 8 加到 12几乎没变化冗余率反而上升。原因很直白窗口越大一次超时牵连重传的帧就越多。这里存在一个最优点它取决于丢包率和往返延迟的比值。做过这个扫描之后你再看真实协议里窗口大小为什么是动态调整的拥塞控制就会有完全不同的体会——那不是为了炫技是被这类实测曲线逼出来的。跑这个实验时有个细节要注意每组参数必须用不同的随机种子但同一组对比实验要用相同的种子序列否则随机性会淹没参数差异。我的做法是random.seed(base_seed trial_index)保证公平对比。6.2 换成选择重传对比一下在 GBN 的代码基础上改出选择重传SR改动量不大但很能说明问题接收方要加一个缓冲区能接收并暂存乱序帧发送方要给每个未确认帧配独立定时器或者维护一个超时时间数组ACK 变成对单个帧的确认而不是累积确认。改完之后用同样的参数跑一遍你会看到冗余率显著下降——因为只重传出错的那一个帧。但代价也会立刻显现代码量大概翻了一倍状态管理的复杂度上一个台阶接收方需要缓存意味着内存开销。这时候你就能理解为什么早期的协议实现偏爱 GBN在资源受限的环境里简单本身就是一种性能。这种用代码复杂度换带宽效率的权衡是网络协议设计里反复出现的主题亲手实现两个版本比看任何对比表都印象深刻。6.3 再补一个把日志导出成时间线最后一个建议是给模拟器加一个可选的时间线导出每次发送、每次接收、每次超时都记录一条(时间戳, 事件类型, 序号)跑完之后按时间排序打印或者导出成文本用表格软件画出来。这条时间线能让你一眼看出窗口是怎么一格格往前挪的、超时发生在哪个位置、重传覆盖了哪一段。我最初就是靠这条时间线发现自己把定时器重置逻辑写错了——图上出现了一段明明有 ACK 进来却还在重传的区间问题一目了然。加日志的时候注意别用print直接输出多线程下输出会交错读起来像乱码。用一个列表收集、结束后统一打印或者给print配一把锁都是可行的做法。我自己是收集到列表里最后按时间戳排序输出顺便还能算出窗口平均占用率这种更有意思的指标。我在实际做这套模拟的时候最大的感受是GBN 的代码量不大但它把状态、时序、边界这三件事揉在了一起任何一处想当然都会在运行时以奇怪的方式暴露出来。写完第一版觉得逻辑没问题跑起来才发现定时器重置漏了一行补上之后又发现接收方不补发 ACK导致每个丢包都要等满一个超时改完再跑统计数字又对不上查了半天是收尾太急。这些坑一个都绕不过去但也正是这些坑让滑动窗口从一个名词变成了我真正理解的东西。如果你也想把它吃透别只看代码把参数改一改、把丢包率调上去、把窗口压到 1看看它退化成停止等待协议的样子——那些变化本身就是最好的教材。
阅读完成 · 觉得有帮助?