资讯动态

从零实现Go-Back-N:滑动窗口、累积确认与超时重传

发布时间:2026/10/27 13:43:46 来源:尧图企业网站定制
把 GBNGo-Back-N自己动手写一遍我认为是计算机网络这门课里投入产出比最高的一件事。看课本上的滑动窗口图一眼就懂真正用 Python 把发送方、接收方和一条会丢包的信道拼起来跑一遍你会发现脑子里那些“应该没问题”的地方全是空的定时器到底什么时候启动、累积确认的 ack 号到底代表什么、接收方窗口为什么死死卡在 1。这篇就把 Go-Back-N 从概念到可运行的模拟代码拆一遍全部在本机单进程里完成不依赖任何网络环境一台笔记本就能跑。适合正在做课程实验的同学也适合工作几年后想把传输层地基重新夯一遍的人——尤其是那些写惯了现成库、想搞清楚“回退重传”到底退到哪、退多少的读者。1. 把GBN放回它该在的位置1.1 停等协议的空转有多浪费先说清楚 GBN 是被什么逼出来的。最朴素的可靠传输叫停等发一个分组停下来等确认收到确认再发下一个。逻辑上无懈可击性能上惨不忍睹。假设一个分组的发送时延是 1ms一来一回的传播时延加起来是 20ms那么每 21ms 只能推进 1ms 的有效工作链路利用率大概是 4.8%。剩下 95% 的时间发送方在干什么在等。等的时候链路是空的带宽是浪费的。这个瓶颈的根源不在于处理速度而在于“必须等确认”这条规则本身。要打破它就得允许发送方在没收到确认的情况下继续往外发——这就是流水线的思想。但一旦允许连续发送新问题立刻出现如果中间某个分组丢了后面那些已经发出去的分组怎么办接收方收到乱序的分组是缓存起来还是丢掉确认怎么回GBN 就是对这个问题的第一种、也是最简单的一种回答。理解这一点很重要因为很多人学 GBN 的时候把它当成一个孤立的协议背下来实际上它是“流水线 最省事的错误处理策略”这个组合的产物。你把错误处理策略换成缓存乱序就得到选择重传 SR换成每个包单独确认就得到另一种形态。GBN 的每一个设计决定都是在“实现简单”这一侧加码。1.2 窗口是“未确认在途额度”而不是并发线程初学者最容易把滑动窗口理解成“同时开几个线程发”。这个类比会把人带偏。窗口本质上是一个计数配额发送方最多允许有 N 个已发出但尚未被确认的分组同时存在于网络中。它约束的是协议状态不是执行模型。具体到实现发送方维护两个指针就够了base指向最老的、还没被确认的分组next_seq指向下一个要发的新分组。这两个指针之间的区间[base, next_seq)就是当前在途的分组集合它的长度永远不能超过窗口大小 N。窗口“滑动”这个动作其实就是收到确认后把base往前推推完之后next_seq又有了新的可用额度可以继续发新数据。这个模型有个很实在的好处你不需要真的开并发。在整个模拟里所有“同时在路上”的分组只是信道队列里的几个对象主循环按时间片轮转处理即可。真正的并发语义被压平成了一张状态表这也是为什么 GBN 特别适合拿来练手——它把一个看起来需要多线程的问题变成了一道纯粹的状态机题。1.3 GBN的三条硬性取舍GBN 的全部性格浓缩在三条规则里理解了这三条代码基本就是翻译。第一条接收方窗口恒为 1。接收方只认“我正在等的那个序号”序号对得上就收下并交付上层对不上就直接丢弃连缓存都不缓存。这条规则换来的是接收方极度简单——一个整数变量就能表达全部状态。第二条累积确认。接收方回的 ACK n 意思是“n 之前的我全收到了下一个我要 n”。注意这里没有“我收到了第 5 个”这种精确表达只有“第 5 个之前的都齐了”。一条 ACK 可以同时确认掉一批分组省确认报文代价是信息粒度粗发送方无法知道具体是哪一个丢了。第三条整个窗口共用一个定时器。发送方不为每个分组单独计时只为“最老的那个未确认分组”计时。一旦超时从base开始把整个窗口里的分组全部重发一遍——这就是“回退 N 步”这个名字的由来。三条规则合在一起形成了 GBN 的典型故障形态丢一个包后面已经正确到达的 N-1 个包全部白发。这个浪费看起来很蠢但换来的是接收方零缓存、发送方单定时器、代码不到一百行。工程上很多场景要的就是这个。2. 拆成可编程的四个零件2.1 发送窗口的两个指针与一个缓冲写代码之前先把发送方的状态列清楚。它需要四个字段base最老未确认的绝对序号、next_seq下一个新分组的绝对序号、window窗口大小 N、timer_start当前定时器的启动时刻没有在跑的定时器就设为 None。判断能不能发新数据只需要一个表达式next_seq - base window。这个减法的结果就是当前在途分组的数量。窗口满了就必须停下来等确认不能硬发——硬发就破坏了“最多 N 个在途”的约束接收方那边会彻底乱套。还有一个容易被忽略的点发送方需要保留已发出但未确认的分组内容因为超时要重发。这意味着发送方必须有一个缓冲区按序号存着这些分组的载荷。缓冲区的大小恰好等于窗口大小因为窗口外的分组要么已经确认可以丢要么还没生成。很多人第一次写的时候只存了个序号等到超时重传时发现没数据可发只能现场重新造一个——如果载荷是文件内容这就直接错了。2.2 累积确认到底确认了什么接收方回的 ACK 里的数字是“我下一个期望收到的序号”也就是它已经连续收到的最后一个序号加一。这个定义方式比“我收到了几号”更好用因为expected本身就是接收方唯一需要的状态变量。发送方收到 ACK n 之后要做的判断是如果 n 落在(base, next_seq]这个区间内说明它是一个有效的新确认把base直接跳到 n如果 n 小于等于base那是一个重复确认说明之前的 ACK 丢了或者乱序到达直接忽略如果 n 大于next_seq那是非法值属于实现 bug应该丢弃并报警。这里有个很实际的坑累积确认让单个 ACK 的丢失变得几乎无害但让 ACK 的乱序变得危险。假设发送方连续收到 ACK 5 和 ACK 3先到的是 5base推到 5后到的 3 小于 base被忽略这是对的。但如果网络把顺序反过来先到 3base推到 3再到 5base推到 5结果一样。所以累积确认天然对乱序有免疫力这一点比逐包确认要省心得多。2.3 一个窗口只维护一个定时器定时器的语义必须掰扯清楚因为这是 GBN 里最容易被写错的地方。定时器计的是“最老的未确认分组已经等了多久”而不是“最近发出的分组等了多久”。正确的行为是当定时器没有在跑而窗口里出现了第一个未确认分组时启动定时器当收到一个有效的新 ACK 后如果窗口还没空重启定时器因为最老未确认分组换人了它的等待时间要从现在重新算如果窗口空了关闭定时器。错误的行为是每发出一个新分组就重启一次定时器。这样做的后果是只要发送方还在持续发新数据定时器就永远被重置超时永远不会触发。一旦网络真的丢了包发送方会一直傻等到窗口塞满、发不出新数据、定时器终于有机会跑完——这中间的延迟可能比正确实现大好几倍。我在第一次写的时候就踩了这个坑日志上表现为“丢包之后要等很久才重传”排查了半天才反应过来是定时器被频繁重置。2.4 接收方窗口恒为1意味着什么接收方的状态简单到只有一个expected变量和一个已交付列表。收到分组时序号等于expected收下expected加一回 ACK 新的expected - 1序号不等于expected丢弃回 ACKexpected - 1重复确认告诉发送方“我还在等这个”。“恒为 1”这个设计带来的一个直接后果是接收方永远不需要处理乱序交付。它交付给上层的顺序天然就是发送方发送的顺序。这个性质在真实系统里很值钱——上层应用不需要自己做重排序TCP 之所以能提供字节流语义靠的就是这个。另一个后果是接收方没有“缓存压力”这个概念。你不需要考虑内存不够时丢哪个包因为压根不缓存。这也是 GBN 在嵌入式、低资源环境里还有生命力的原因之一。3. 用Python在本机把这条链路搭起来3.1 为什么用虚拟时钟而不是真 sleep做本机模拟第一个要决策的是时间模型。用time.sleep模拟传播时延看起来很直观但会带来两个麻烦一是慢要把丢包率测出统计规律得跑几百轮真等下去黄花菜都凉了二是不可复现同样的随机种子在不同机器上跑出来的时序可能不一样出问题没法稳定重现。所以这里用虚拟时钟tick整个模拟是一个循环每轮now加一代表一个时间单位。信道的传播时延就是一个整数 delay分组在now delay时刻才变得可收取。丢包用一个随机数判定随机源固定种子。这样做的好处是一轮完整实验几十毫秒就能跑完而且每次跑的结果完全一致方便对照。这个技巧不只适用于网络模拟任何“需要时间推进但又不想真等”的场景都能用——事件驱动仿真、排队系统、任务调度算法验证思路是一样的把时间抽象成一个可以自由拨动的变量。3.2 信道模拟器丢包和延迟各自独立信道要模拟两件事丢包和延迟。这两件事是正交的不要混在一起写。一个分组可能不丢但延迟很大也可能延迟正常但直接消失。实现上丢包判定放在入队之前延迟体现在到达时刻上。import random from dataclasses import dataclass dataclass class Packet: kind: str # data 或 ack seq: int # 序号这里用取模后的值 payload: str class Channel: def __init__(self, loss_rate, delay, rng): self.loss_rate loss_rate self.delay delay self.rng rng self.inflight [] # [(arrive_tick, packet), ...] def send(self, pkt, now): # 先判丢包丢了就当它不存在连延迟都不用算 if self.rng.random() self.loss_rate: return False self.inflight.append((now self.delay, pkt)) self.inflight.sort(keylambda x: x[0]) return True def collect(self, now): ready [p for t, p in self.inflight if t now] self.inflight [(t, p) for t, p in self.inflight if t now] return ready一个关键细节数据信道和确认信道必须是两个独立实例。ACK 也会丢而且 ACK 丢失比数据丢失更隐蔽——数据丢了发送方能靠超时发现ACK 丢了发送方会重传一个其实早就被收到的包接收方收到重复包要能正确识别并再回一次 ACK。第一次写模拟的人十有八九会忘了给 ACK 方向也配一个会丢包的信道结果测出来的性能和理论值对不上。3.3 发送方与接收方的完整实现发送方的核心就是前面说的两个指针加一个定时器。注意序号取模的处理这里用 256 作为序号空间是因为模拟里总量很小等价于“序号不会绕回”的理想情况真实协议必须处理绕回这一点我放到后面单独讲。SEQ_SPACE 256 class Sender: def __init__(self, window, total, timeout): self.N window self.total total self.timeout timeout self.base 0 self.next_seq 0 self.timer_start None self.buffer {} # 绝对序号 - 载荷 def can_send(self): return self.next_seq - self.base self.N and self.next_seq self.total def build(self): abs_seq self.next_seq pkt Packet(data, abs_seq % SEQ_SPACE, fpkt-{abs_seq}) self.buffer[abs_seq] pkt.payload self.next_seq 1 return pkt def on_ack(self, ack, now): # 累积确认ack.seq 表示“它下一个期望的绝对序号 - 1” abs_ack ack.seq if abs_ack self.base or abs_ack self.next_seq: return None # 重复或非法确认忽略 self.base abs_ack 1 if self.base self.next_seq: self.timer_start None # 窗口空了关定时器 else: self.timer_start now # 窗口非空重启定时器 return self.base def on_timeout(self, now): resent [] for abs_seq in range(self.base, self.next_seq): resent.append(Packet(data, abs_seq % SEQ_SPACE, fpkt-{abs_seq})) self.timer_start now # 重传后重新计时 return resent接收方更短。这里有个小的边界处理值得说当expected是 0也就是一个包都还没收到的时候收到一个乱序包该回什么 ACK答案是回(expected - 1) mod 256也就是 255。这个 255 会被发送方识别为“小于 base 的重复确认”直接忽略逻辑上是自洽的。但如果你在代码里没做取模直接写expected - 1就会给发送方送去一个 -1然后发送方的范围判断就会出问题。class Receiver: def __init__(self, total): self.expected 0 self.total total self.delivered [] def on_packet(self, pkt): if pkt.seq self.expected % SEQ_SPACE: self.delivered.append(pkt.payload) self.expected 1 return Packet(ack, pkt.seq) # 新的累积确认 else: last_ok (self.expected - 1) % SEQ_SPACE # 可能是 255 return Packet(ack, last_ok) # 重复确认3.4 主循环与日志格式主循环按固定的顺序做四件事填窗口、收数据、收确认、查超时。这个顺序不能乱因为同一个 tick 里可能既收到 ACK 又触发超时先处理 ACK 就能避免一次无谓的重传。def simulate(window4, total12, loss0.1, delay5, timeout20, seed7, max_ticks2000): rng random.Random(seed) down Channel(loss, delay, rng) # 数据方向 up Channel(loss, delay, rng) # 确认方向 snd Sender(window, total, timeout) rcv Receiver(total) now, stats 0, {sent: 0, resent: 0, timeouts: 0} while snd.base total and now max_ticks: # 1. 只要窗口有额度就灌数据 while snd.can_send(): pkt snd.build() if snd.timer_start is None: snd.timer_start now down.send(pkt, now) stats[sent] 1 # 2. 接收方处理到达的数据并回确认 for pkt in down.collect(now): ack rcv.on_packet(pkt) up.send(ack, now) # 3. 发送方处理到达的确认 for ack in up.collect(now): snd.on_ack(ack, now) # 4. 超时检查一次性重发整个窗口 if snd.timer_start is not None and now - snd.timer_start timeout: out snd.on_timeout(now) stats[timeouts] 1 stats[resent] len(out) for pkt in out: down.send(pkt, now) now 1 return now, stats, rcv.delivered跑起来加一行打印就行print(simulate(loss0.1))。返回的now是总耗时stats里能看到发了多少个、重发了多少个、超时了几次这三个数字是后面调参的主要观察对象。4. 跑十次实验把窗口滑动看明白4.1 零丢包下窗口匀速前移先把丢包率设成 0窗口 4总量 12延迟 5超时 20。跑出来的时间线是这样的0 时刻把 0 到 3 号包一次性灌进信道窗口填满。5 时刻四个包同时到达接收方expected从 0 一路涨到 4回四个 ACK4、5、6、7 号的含义是“下一个我要 x”。10 时刻 ACK 回到发送方base直接跳到 4累积确认的威力四条 ACK 里最后一条就把 base 推到位了窗口腾出额度继续灌 4 到 7 号包。这个过程中定时器根本没机会跑完——每次收到有效 ACK 就重置而 ACK 的往返时间10 个 tick远小于超时阈值20 个 tick。把时间线画出来会看到一条非常规律的梯形波发一批、等一个 RTT、再发一批。这就是理想状态下的流水线。这里可以验证一个理论值停等协议完成 12 个包需要 12 个 RTT 量级的时间而窗口 4 的 GBN 只需要大约 3 个 RTT。实测下来停等耗时约 120 个 tickGBN 窗口 4 约 50 个 tick比值接近但略小于 3差的那部分是启动和收尾的固定开销。4.2 注入丢包后回退的连锁反应把丢包率提到 0.2随机种子固定情况立刻不一样。假设 2 号包在数据方向上丢了接收方在 5 时刻收到 0、1、3前两个正常收下expected变成 23 号包因为不等于 2 被丢弃接收方回 ACK 1表示“我还在等 2”。发送方在 10 时刻收到 ACK 1 和后续那两个重复的 ACK 1发现都小于等于base全部忽略。窗口保持在原位不动next_seq也没法继续推进。等到 20 时刻超时触发发送方把base此时是 2到next_seq - 1之间的所有包重发一遍也就是 2 号和 3 号一起重发。注意 3 号包明明已经成功到达过一次只是被接收方丢了现在还得再发一次——这就是“回退 N 步”的成本。如果窗口是 8一次超时要重发的就是最多 8 个包其中可能只有一个是真的丢了。这个连锁效应在日志里非常明显超时那一刻的resent计数会突然跳一下跳的幅度大致等于当时窗口里的在途数量。我在一次 20% 丢包的实验里观察到总共发了 12 个新包重发却有 18 个重传率 150%——也就是说每发一个有效包网络上实际跑了两倍多的流量。4.3 窗口大小的拐点在哪窗口不是越大越好这一点必须靠实测建立直觉。固定丢包率 0.1、总量 60、延迟 5、超时 20扫一遍窗口大小能看到一条先降后升的曲线。窗口大小总耗时tick新发包数重发包数重传率1退化为停等约 73060813%4约 260601728%8约 190603152%16约 210605897%32约 30060104173%趋势很清晰小窗口阶段瓶颈是流水线不够深等确认的时间占了主导加大窗口收益明显过了某个拐点窗口大到“一个丢包连坐一大片”重传成本开始吃掉流水线带来的收益继续加大反而变慢。这个拐点跟丢包率、RTT 和超时阈值都相关不是一个固定数字。理论上可以用一个粗略公式估算理想吞吐在没有丢包的情况下链路利用率是min(1, N × Tt / (Tt 2Tp))也就是“窗口内所有包的发送时间”和“一个往返时间”的比值。但这个公式完全不考虑丢包所以只能当作上界参考。真要评估还是得把丢包率代进去跑。4.4 ACK丢失比数据丢失更隐蔽一个很有意思的对照实验把数据信道丢包率设成 0只让确认信道以 10% 的概率丢包。结果整个系统的表现几乎正常只是偶尔多一次重传。原因是累积确认有天然的抗丢包能力——后续的 ACK 一旦到达就把丢失的那条 ACK 覆盖掉了。但把确认信道丢包率提高到 40%情况就变了。发送方频繁超时重传接收方不断收到重复包一边丢弃一边回重复 ACK网络里大量流量是无效的。这时候你会发现一个反直觉的现象ACK 丢包导致的性能下降最终是通过“发送方误判为数据丢失”来体现的。发送方根本无法区分是数据丢了还是确认丢了它唯一的判断依据就是定时器。这也是为什么在真实协议里超时时间必须留足余量否则会把网络拥塞造成的延迟误判成丢包进而触发重传让拥塞雪上加霜。5. 自己写才会踩的五个坑5.1 定时器被“每发一个包就重置”毁掉这个坑前面提过但值得再展开因为它的表现形式非常迷惑。代码里如果写成“每次build()之后都执行timer_start now”逻辑上似乎也说得通——毕竟刚发了个新包重新计时好像没毛病。但在窗口持续有数据可发的情况下定时器永远走不到超时。实际观测到的现象是丢包发生后发送方还能继续发新包因为窗口有额度发着发着窗口满了停在原地。这个时候定时器才开始从最后一次发包算起。如果窗口是 8、丢包发生在窗口的第一个位置那么从丢包到重传之间的延迟等于“发满剩下 7 个包的时间 整个超时阈值”。这段时间里接收方一直在等链路一直在空转。正确的解法是把启动和重置分成两个逻辑定时器为 None 时启动对应窗口从空变非空收到有效 ACK 后重置对应最老未确认分组换人窗口变空时关闭。三个状态转换一个都不能少。5.2 序号空间取成等于窗口大小有一个流传很广的错误说法是“序号空间只要大于等于窗口大小就行”。这个说法是错的正确答案是序号空间必须大于窗口大小严格说SEQ_SPACE ≥ N 1。举个具体的反例N 4序号空间也是 40 到 3 循环。发送方连续发 0、1、2、3 四个包接收方全收到了回 ACK 3表示下一个要 0 号——因为绕回了。但这个 ACK 如果在路上丢了发送方超时后重发 0、1、2、3。接收方此时期望的是 0 号收到重发的 0 号会欢天喜地地收下然后继续等 1 号。结果是这批本该被丢弃的重复数据被当成了新数据交付给了上层。数据的正确性被彻底破坏而且不会报任何错。把序号空间加到 2N 或更大这个歧义就消失了。真实协议里TCP 的序号是 32 位窗口大小受接收窗口和拥塞窗口共同限制远小于序号空间所以绕回问题不会在正常参数下暴露但如果你自己实现一个玩具协议这一条必须刻在脑子里。5.3 窗口满了之后的死循环模拟里的主循环写while snd.base total如果因为某个 bug 导致base永远不推进循环就永远不结束。第一次跑的时候我就遇到了on_ack里的范围判断写成了abs_ack self.base把“ACK 恰好等于 base 时应该推进”的情况漏掉了结果第一个包的确认永远推不动 base程序跑了十几秒没输出。防御措施很简单给主循环加一个max_ticks上限超了就强制退出并把状态打出来。这个习惯在所有模拟类代码里都值得养成——任何时候只要有一个“期望它最终会结束”的循环就给它配一个硬性上限。调试的时候上限触发的那一刻的状态快照往往比跑一百次正常流程更有信息量。5.4 重复ACK没有被正确忽略接收方在丢弃乱序包的时候会回“重复确认”也就是重复发它上一次发过的 ACK 号。发送方收到这些重复 ACK必须原样忽略不能做任何动作。如果代码里把范围判断写宽松了比如写成abs_ack self.base就会在abs_ack base的时候把base推进到base 1——凭空确认了一个根本没被收到的包。这个 bug 的破坏性是静默的模拟能跑完交付列表看起来也完整但仔细对数会发现某些包在接收方那边被丢弃了却在发送方这边被当成已确认。真实协议里重复 ACK 是有正面价值的——TCP 用三个重复 ACK 来快速重传就是利用了“接收方卡在同一个期望值上”这个信号。但在 GBN 里重复 ACK 就是纯粹的噪音处理策略只能是忽略。5.5 接收方在期望0时回了ACK 255这个边界情况非常容易被漏掉因为它只在“第一个包就乱序”的时候出现。expected 0收到 1 号包回expected - 1 -1。如果你的 ACK 序号字段是整数-1 会一路传到发送方然后发送方的范围判断abs_ack self.base变成-1 0判定为重复确认忽略——恰好没出事。但如果代码里加了“小于 base 就报警”之类的断言就会直接崩。正确写法是(self.expected - 1) % SEQ_SPACE让它在 0 的时候绕成 255。255 一定大于 base0会被判定为“大于等于 next_seq”的非法值同样被忽略但走的是“明确非法”这条路径而不是“意外正确”。这两者在调试时的区别很大——后者会让你误以为逻辑没问题。5.6 “本机模拟”带来的时序错觉最后一个坑不是代码 bug是认知偏差。在单线程模拟里所有事件都是按 tick 严格排序的同一个 tick 内的事情还要靠代码顺序决定先后。真实网络里两个分组的到达顺序完全可能和发送顺序相反延迟也不是固定值而是有抖动的分布。我从这个模拟里得到的一条经验是模拟验证的是协议状态机的正确性不是性能。它能告诉你累积确认的逻辑对不对、窗口滑动有没有越界、超时重传的范围准不准但它不能告诉你真实网络下的吞吐因为信道模型太理想了。想往这个方向再走一步可以把固定延迟换成随机分布把丢包模型换成 Gilbert-Elliott 那种有突发性的双状态模型那样才能模拟出“连续丢一串包”的场景——而那恰恰是 GBN 最难受的情况。6. GBN不是最优但它的简单是真实的价值6.1 和选择重传的成本对照把 GBN 和 SR选择重传摆在一起看差别只有一句话接收方要不要缓存乱序分组。这一句话的分量很重。GBN 的接收方不缓存所以它丢弃乱序包导致发送方必须整窗重传。SR 的接收方缓存乱序包只要求发送方重传真正丢失的那一个。在网络丢包率低的时候两者性能差不多丢包率一高GBN 的重传量会呈线性上升而 SR 基本保持在“只补丢的那一个”。但代价也是实打实的SR 需要接收方维护一个接收窗口需要能按序号把缓存的分组重新排序交付需要发送方为每个未确认分组单独维护定时器需要 ACK 从累积确认变成逐包确认或者用选择确认 SACK。代码量、状态复杂度、内存占用全是成倍增加。在我自己的模拟里GBN 的发送方加接收方不到 60 行改成 SR 之后直接翻了三倍。6.2 什么场景该果断换掉GBN判断标准其实不复杂看两个维度丢包率和分组大小。如果链路质量很好、丢包率长期在 1% 以下GBN 的整窗重传几乎不会发生它的简单性就是纯收益没必要上 SR。局域网内部通信、有线链路、已经做了链路层纠错的场景都符合这个画像。如果链路丢包率超过 5%或者分组本身很大重传一个包的成本很高那 GBN 的回退代价就难以接受了。这时候要么换 SR要么缩小窗口把回退范围压住——但缩小窗口又会牺牲吞吐等于用一个问题换另一个问题。还有一个容易被忽略的因素乱序。GBN 假设接收方丢弃乱序包之后发送方会很快发现并重传。但如果网络本身乱序严重多路径、负载均衡场景接收方会频繁丢弃那些其实没丢、只是晚到的包触发大量不必要的重传。这种情况下 GBN 的表现会急剧恶化因为它把“乱序”和“丢失”当成了同一件事。6.3 这个模拟还能往哪儿长骨架搭好之后往几个方向扩展都很有价值。第一个方向是把信道模型做厚。现在的丢包是独立同分布的改成双状态马尔可夫模型之后就能模拟“一段好、一段坏”的突发丢包观察 GBN 在突发场景下的崩溃过程。实现上无非是给 Channel 加一个状态变量状态转移概率决定下一时刻是丢还是通。第二个方向是加拥塞控制。现在的窗口固定不变真实协议里窗口是动态调整的——超时就减半正常就慢慢加。在模拟里加一个简单 AIMD观察窗口大小随时间的锯齿波形能很直观地把“拥塞控制”和“流量控制”这两件事分开。第三个方向是做可视化。把base、next_seq、expected三个数字随时间的变化画成三条折线或者把每个 tick 的信道内容打印成时间轴图回退重传的瞬间会一目了然。这个对教学演示特别有用比自己脑补时序图强得多。我自己在这上面花的时间大部分不是写协议本身而是写日志和调参数——这大概也是所有模拟类项目的共同规律。协议逻辑一旦想清楚了代码其实很短真正消耗时间的是搞清楚“为什么结果和预期不一样”而那个过程恰恰是学到东西最多的地方。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑