Python 本机模拟 GBN 滑动窗口协议:累积确认、超时重传与序号回绕

1. 先把 GBN 这件事说明白:为什么值得在笔记本上跑一遍

Go-Back-N(GBN)是计算机网络里"滑动窗口"这一族协议中最适合上手的一个:它比停止等待协议多了流水线,又比选择重传协议少了对缓存和乱序处理的复杂要求。可大多数人学完这一章,脑子里只留下一张发送窗口的示意图和一个"回退 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 − 1,n 是序号字段的位数。很多教材只写"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指向的帧。规则有四条:

  1. 窗口为空时(base == next_seq),定时器不启动。
  2. 发送一个新帧,如果定时器没在跑,就启动它,截止时间设为"现在 + 超时时间"。
  3. 收到一个能推进 base 的 ACK,如果窗口还没空,重置定时器为"现在 + 超时时间";如果窗口空了,关闭定时器。
  4. 定时器超时,把 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 p=payload: 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_prob=0.0, delay=0.02, jitter=0.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 p=payload: self._q.put(p)) timer.daemon = True timer.start() def recv(self, timeout=0.002): try: return self._q.get(timeout=timeout) except queue.Empty: return None

Link里没有锁,因为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__(daemon=True, name="sender") 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(timeout=0.002) while pkt is not None: if pkt["ack"] >= self.base: self.base = pkt["ack"] + 1 acked = True pkt = self.in_link.recv(timeout=0.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__(daemon=True, name="receiver") 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(timeout=0.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_prob=0.15, delay=0.03, jitter=0.01) bwd = Link("反向", loss_prob=0.15, delay=0.03, jitter=0.01) sender = GBNSender(TOTAL, WINDOW, SPACE, timeout=0.25, out_link=fwd, in_link=bwd) receiver = GBNReceiver(TOTAL, SPACE, out_link=bwd, in_link=fwd) t0 = time.monotonic() sender.start() receiver.start() sender.join(timeout=30) 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 p=payload: ...绑定
偶尔少最后一帧主程序收尾太快,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,看看它退化成停止等待协议的样子——那些变化本身,就是最好的教材。