ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python实现GBN协议:滑动窗口与可靠文件传输实战指南

Python实现GBN协议:滑动窗口与可靠文件传输实战指南 简介一份计算机网络课程实验源码基于Python模拟数据链路层的GBNGo-Back-N回退N帧协议并实现可靠文件传输适合计算机网络、通信工程、自动化等专业的在校学生和教师用于课程设计、实验与毕业设计也适合初学者对照理解滑动窗口、序号确认和自动重传机制。资源为zip压缩包大小约22KB共21个文件其中12个Python源文件构成协议核心与工具模块7个INI配置文件用于参数初始化另有README和说明文档辅助快速上手。实现内容涵盖帧构造与解析、差错校验、发送和接收窗口维护、超时重传、文件分块重组、UDP封装及日志配置等完整流程并附有自动测试脚本可直接运行验证协议状态变化也方便在此基础上扩展其他功能。目前已有425人学习/下载是一份轻量且典型的GBN可靠传输参考实现。1. 计算机网络课程实验里的GBN一个Python模拟器能解决什么数据链路层GBN协议的课程实验通常有个硬性交付要求用Python模拟实现协议并让它完成一次可靠文件传输。教材上的滑动窗口画得清清楚楚真到写代码收发文件时丢包重传、超时定时、序号回绕轮番上场能把一台电脑从“传输完成”卡到“进程假死”。这个实验的意义不在于背下Go-Back-N的定义而是把“确认”“重传”“窗口”这些抽象机制落到socket、字节流和定时器上让自己亲手造出一个能在不可靠信道里把文件原样送到的程序。本文按课程实验的常见做法把GBN协议从原理到源码拆开讲该写哪些类、帧怎么设计、参数怎么定、哪里最容易翻车照着复现就能交付。2. 先把GBN协议拆明白滑动窗口、累积确认与序号回绕在动手写任何代码之前先把协议的行为边界定清楚。GBN全称Go-Back-N发送端维护一个大小为N的滑动窗口窗口内未确认的帧可以连续发出去接收端只按序接收、只回累积确认。信道只要丢一个帧发送端超时后就把窗口内所有帧重传一遍“后退N帧”因此得名——这是它在效率上优于停等协议、又比选择重传SR简单的原因。2.1 滑动窗口为什么比停等协议快带宽时延积是根本原因停等协议发送一帧就要等确认一个RTT内信道大部分时间是空的。GBN用窗口把多个帧“塞”在链路上让吞吐量逼近带宽与时延之积。课程实验里通常用UDP模拟底层信道接收方收到正确帧就回一个ACK发送方在没有收到ACK的超时点重传这样就把“流水线”效果跑出来了。窗口数N不是越大越好。N受两个约束一是接收端的接收能力二是序号空间必须能容纳窗口内所有帧否则接收端无法区分新帧和重传帧。实验里常见做法是把N设成4到16之间的整数帧大小1KB既能看到窗口滑动效果又不会因为序号回绕把自己绕进去。2.2 累积确认是GBN的命门接收端只认按序到达的帧GBN接收端的逻辑比发送端简单得多只接收seq等于期望序号的那一帧收到后把期望序号加1同时回一个携带该序号的ACK任何乱序到达的帧无论它是不是已经在接收缓存里全部丢弃并重新发送最后一个ACK。这个“丢弃乱序帧”的行为是GBN和SR协议最本质的区别。不少人在实现里给接收端加了缓存把乱序帧先存起来——这实际上是SR的做法不是GBN。接收端不加缓存换来的是接收逻辑极其简单一个expected_seq变量、一个按序存储的字典、一条“丢帧就重发ACK”的规则。发送端收到重复ACK后不会立即重传那是快速重传属于TCP的机制而是继续等超时超时后才把整个窗口重发这就是“后退N”的含义。实验报告里如果能把这个区别写清楚老师会认为你是真的理解协议而不是照抄。2.3 序号空间与窗口大小的不等式回绕问题在动手前就定死序号回绕是GBN实验里最隐蔽的坑。假如序号空间只有8个序号0到7窗口也是8发送端发完0到7号帧后下一个待发序号变成0此时接收端收到的0号帧到底是新帧还是重传帧根本无法区分。标准结论是序号空间必须大于等于窗口大小的两倍即max_seq 2 * window_size才能确保窗口内的任意两个帧序号互不重叠。实验里我一般把序号上限设成256窗口设成8留足余量。这样在判断“收到ACK后窗口下沿base移动到哪里”时只需要简单的整数加法和取模不需要处理“窗口跨过序号最大值”的环形边界。如果你想让窗口和序号空间都压到极限比如窗口8、序号空间16那就要在收发两端同时维护“当前窗口跨越0点”的状态位复杂度会明显上升——课程实验不追求这个劝你不要自己加难度。2.4 Python模拟不可靠信道丢包、损坏、乱序三层叠加GBN的“可靠”是相对“不可靠信道”而言的。如果直接用TCP传输协议栈自己就做了重传和排序GBN的模拟就完全失去意义。课程实验的标准做法是用UDP收发数据在UDP之上自己加一个信道模拟层让帧以一定概率被丢弃、被破坏、被延迟。模拟信道通常做成一个独立类内部维护三个参数丢包率loss_rate、损坏率corrupt_rate、乱序开关。丢包就是随机丢帧不发损坏是把帧里的某个字节翻转后发送乱序则是把后发送的帧先发出去一般通过随机交换sendto顺序来模拟。这三层可以叠加也能单独开关用来做对照实验非常方便。后面的实现章节里这个信道类只需要几十行代码重点在于参数暴露给外部方便测试时调。3. 用Python实现GBN最小闭环帧格式、Sender与Receiver三个核心代码结构上我建议按三个文件拆分frame.py放帧封装和解析channel.py放不可靠信道模拟gbn.py放Sender和Receiver两个类。课程设计查重看的是逻辑不是文件数量但清晰分层的好处是排错时能快速定位问题。下面从帧格式开始逐个给出可直接用的实现。3.1 帧格式与校验和struct打包把头部定死帧是收发双方唯一的交流语言格式必须完全一致。常见的做法是头部固定11字节按网络序大端打包1字节类型、2字节序号、4字节数据长度、4字节校验和后面紧跟数据体。校验和用zlib.crc32对数据部分取低16位虽然碰撞概率在实验场景下足够低但比纯加和取模可靠得多。import struct import zlib FRAME_TYPE_DATA 1 FRAME_TYPE_ACK 2 FRAME_TYPE_FIN 3 BASE_HEADER_SIZE struct.calcsize(!BHII) def build_frame(seq, data, ftypeFRAME_TYPE_DATA): # 数据部分是 bytes长度由 len(data) 决定 checksum zlib.crc32(data) 0xffff header struct.pack(!BHII, ftype, seq, len(data), checksum) return header data def parse_frame(raw): if len(raw) BASE_HEADER_SIZE: return None ftype, seq, length, checksum struct.unpack(!BHII, raw[:BASE_HEADER_SIZE]) data raw[BASE_HEADER_SIZE:] if len(data) ! length: return None if (zlib.crc32(data) 0xffff) ! checksum: return None # 校验失败按损坏帧丢弃 return ftype, seq, datastruct.pack的格式串!BHII表示网络字节序、1字节无符号整数、2字节无符号整数、两个4字节无符号整数收发两端只要用同一套格式和顺序就不会解析错。校验和在这里只覆盖数据体严谨的协议会把头部字段一并纳入校验范围课程实验这样写已经够用但报告里可以主动指出这个取舍。接收端解析时先判断长度是否达到头部大小再核对数据长度与校验和。任何一个环节失败都返回None调用方直接丢弃该帧——这就是模拟“损坏帧被接收端识别并抛弃”的过程。3.2 不可靠信道类把UDP变成会丢帧的黑匣子信道类的设计目标是让Sender和Receiver在代码里感受不到“信道不可靠”这回事所有丢包、损坏都发生在sendto的一瞬间。这样做的好处是协议代码里不需要掺入random判断逻辑更贴近教材描述。import random class UnreliableChannel: def __init__(self, sock, loss_rate0.2, corrupt_rate0.05): self.sock sock self.loss_rate loss_rate self.corrupt_rate corrupt_rate def sendto(self, package, addr): # 模拟信道丢包直接不发 if random.random() self.loss_rate: return # 模拟信道损坏翻转数据体的第1个字节 if random.random() self.corrupt_rate: pkg bytearray(package) pkg[BASE_HEADER_SIZE] ^ 0xFF package bytes(pkg) self.sock.sendto(package, addr)调用时只要把全局所有sendto换成分发到channel.sendto收发双方便都不需要感知丢包逻辑方便后面做对照实验。损坏帧翻转的是数据体首字节这个位置最容易让接收端的校验失败同时又不会破坏头部导致解析直接崩溃——故意设计成“能被识别出来的损坏”。注意乱序模拟这里没有展开。常见的做法是在信道内部维护一个小队列随机把新到的帧插到队首实现乱序到达。课程实验里丢包和损坏两档已经足够演示可靠传输的核心逻辑乱序开关按需再加。3.3 Sender端发送窗口、缓存队列与超时重传Sender是GBN实现中最复杂的部分它同时要管三件事窗口没满时持续发帧、收到ACK后推进窗口下沿、超时后重传窗口内所有帧。下面这个实现用select.select做定时等待避免了用time.sleep阻塞主循环导致无法及时响应ACK的问题。import select import socket class GbnSender: def __init__(self, sock, receiver_addr, window_size8, timeout0.5): self.sock sock self.receiver_addr receiver_addr self.window_size window_size self.timeout timeout self.base 0 # 窗口下沿最小的未确认序号 self.next_seq 0 # 下一个待发送帧的序号 self.buffer {} # 已发出但未确认的帧缓存 self.channel UnreliableChannel(sock) def run(self, file_bytes, chunk_size1024): chunks [file_bytes[i:ichunk_size] for i in range(0, len(file_bytes), chunk_size)] total len(chunks) fin_sent False while self.base total or not fin_sent: # 窗口未满且还有数据帧要发 while self.next_seq total and \ self.next_seq - self.base self.window_size: pkg build_frame(self.next_seq, chunks[self.next_seq]) self.buffer[self.next_seq] pkg self.channel.sendto(pkg, self.receiver_addr) self.next_seq 1 # 所有数据帧已发出补一发 FIN 帧 if self.next_seq total and not fin_sent: self.channel.sendto(build_frame(total, b, FRAME_TYPE_FIN), self.receiver_addr) fin_sent True readable, _, _ select.select([self.sock], [], [], self.timeout) if readable: raw, _ self.sock.recvfrom(65535) frame parse_frame(raw) if frame and frame[0] FRAME_TYPE_ACK: ack_seq frame[1] while self.base ack_seq: self.buffer.pop(self.base, None) self.base 1 else: # 超时把窗口内所有未确认帧全部重传 for seq in range(self.base, self.next_seq): if seq in self.buffer: self.channel.sendto(self.buffer[seq], self.receiver_addr)while self.base total or not fin_sent是整个发送过程的终止条件窗口内的帧没确认完或者FIN没发出循环就不能结束。select.select收到超时返回时会命中else分支把base到next_seq之间的所有缓存帧重新发一遍——这正是“后退N”的体现而不是只重传丢失的那一帧。窗口是否满的判断写成了self.next_seq - self.base self.window_size。窗口大小为8时未确认的帧数最多8个。需要注意这里没有处理序号回绕因为实验里文件拆出的帧数远小于序号上限序号单调递增不会绕回0。3.4 Receiver端按序接收、丢弃乱序帧与ACK回传Receiver的任务比Sender简单循环收帧校验遇到期望的帧就存下来并回ACK遇到乱序帧就丢弃并重发上一次的ACK。流程写得朴素一点反而更符合GBN的语义。class GbnReceiver: def __init__(self, sock, output_path): self.sock sock self.output_path output_path self.channel UnreliableChannel(sock) def run(self): frames {} expected_seq 0 while True: raw, addr self.sock.recvfrom(65535) frame parse_frame(raw) if frame is None: continue # 损坏帧直接丢 ftype, seq, data frame if ftype FRAME_TYPE_FIN and seq expected_seq: break # FIN 序号等于期望序号说明数据帧全部收完 if ftype FRAME_TYPE_DATA and seq expected_seq: frames[seq] data self.channel.sendto(build_frame(seq, b, FRAME_TYPE_ACK), addr) expected_seq 1 else: # 乱序帧或重复帧丢弃并重发最后一个 ACK last_ack build_frame(expected_seq - 1, b, FRAME_TYPE_ACK) self.channel.sendto(last_ack, addr) with open(self.output_path, wb) as f: for i in range(expected_seq): f.write(frames[i])乱序帧被丢弃时重发expected_seq - 1的ACK这个细节很关键。发送端收到重复ACK只会知道“某些帧还没到”仍然等超时重传符合GBN不采用快速重传的设定。如果在这里发送expected_seq而不是expected_seq - 1反而会误导发送端把窗口下沿往前推进破坏累积确认的语义。Receiver结束条件是收到FIN且其序号正好等于期望序号。这意味着在所有数据帧已经被按序接收之后FIN才会被接受。FIN帧本身携带的序号等于total正常情况下FIN不会被误认为数据帧。3.5 文件拆帧与重组跑通一次完整还原把上面两个类串起来的主流程非常简单发送方读文件字节、调用run接收方预先开始run等待帧到达。要注意UDP socket需要绑定端口且两端端口不能冲突。# 接收端 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind((127.0.0.1, 9000)) receiver GbnReceiver(sock, received.bin) receiver.run() # 发送端在另一个进程/线程 sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sender GbnSender(sock, (127.0.0.1, 9000), window_size8, timeout0.5) with open(original.bin, rb) as f: sender.run(f.read())发送端跑完后比较original.bin和received.bin的字节数以及内容哈希就能判断传输是否可靠。这一步是整个实验的验收标准文件必须逐字节一致而不是“传输过程中偶尔丢点数据没关系”。用zlib.crc32或者hashlib.md5做一致性校验是最省事的验证方式。4. 可靠文件传输的参数怎么调才不翻车窗口、超时与序号空间代码能跑通只是第一步课程实验通常要求做性能对照——调整参数观察重传次数和传输时间的变化。参数选得不合理最典型的结果是丢包率只有10%传输时间却暴涨十倍看起来像死循环。这一章把四个必调参数的取值逻辑讲清楚。4.1 帧大小1KB是保守起点别挑战UDP MTU帧大小直接影响两个东西单帧的传输效率和损坏概率。以太网MTU通常是1500字节UDP数据报超过这个值会触发IP分片分片后只要一片丢失整个UDP数据报就废了——这会让你的“GBN重传”实际在重传一个超过MTU的大包效率极低。课程实验里把帧大小设为1024字节是稳妥的既不超过常见MTU又能把1MB文件拆成1024帧左右便于观察窗口滑动。帧调大确实能减少ACK数量但带来的问题是单帧损坏时重传的代价更大。GBN对每个损坏帧都要重传窗口内的所有帧帧越大一次错误付出的带宽成本越高。做过对照实验你会发现帧从1KB跳到8KB时丢包率10%环境下传输耗时几乎翻了四倍这就是“后退N”的放大效应。结论本地回环实验用1024字节起步不要贪大。4.2 超时时间RTT测量的两个极端与重传风暴线超时时间设多长是GBN实验里最典型的玄学。设太短ACK只是稍微晚到一点就触发重传信道里会堆满重复帧接收端忙于丢弃乱序帧、重发ACK形成重传风暴设太长丢一个帧后信道空转很久吞吐率惨不忍睹。本地回环测试中UDP的RTT通常在亚毫秒到几毫秒量级0.1秒的超时已经足够宽松。如果移动到局域网环境测试建议先发几个探测帧测量RTT把超时设为2到3倍平均RTT这是能兼顾客错敏感性和吞吐量的经验值。课程实验固定用0.5秒也能过但报告里如果能写“根据RTT动态调整超时”会明显加分。端口和防火墙也会影响超时表现。如果接收端进程崩溃或者端口没绑定发送端会一直收不到ACK每0.5秒重传一轮看起来像死循环。排查时先确认接收端socket确实在监听再处理协议逻辑。4.3 窗口大小与序号空间怎么配合一个不等式两条经验前面2.3节讲过序号空间必须至少是窗口大小的两倍。实现时序号上限MAX_SEQ通常固定为256窗口大小表驱动可调。课程实验里窗口从1调到16能画出“吞吐率随窗口增大而上升随后趋平甚至下降”的曲线这条曲线本身就是报告里最好的图表素材。窗口继续增大会发生什么窗口内未确认帧太多接收端缓冲区压力变大ACK返回前发送端就已经把窗口发满后续只能干等。真实的GBN协议接收端不需要大缓存但发送端缓存会线性增长。本实验里缓存是Python字典窗口16时占用不大如果为了性能把窗口调到64以上缓存和重传开销都会明显上升曲线会出现拐点。两条经验窗口大小不超过序号空间的一半本地实验窗口取8即可不需要追高。4.4 最后一帧确认丢失怎么办FIN握手把文件传输收干净很多人在数据帧全部发完、收到所有ACK后直接退出程序结果接收端文件少了一截——因为没有处理“最后一个ACK丢失”的场景。数据帧重传机制只能保证发送端确认接收如果最后一帧的ACK丢了发送端会把最后一帧重传接收端收到重复帧后回ACK这个循环能自行收敛。但结束阶段不同发送端发完FIN后如果FIN的ACK丢了接收端会一直等FIN发送端会一直以为自己已经结束并退出两边就僵住了。可靠的做法是发送端发FIN后不立即退出而是继续用select等待ACK超时则重发FIN直到收到FIN对应的ACK。接收端收到FIN后回ACK并进入结束流程。下面这段是对3.3节Sender的补充逻辑# FIN 已发出继续等待 FIN 的 ACK fin_ack_seq total fin_ack_received False while not fin_ack_received: readable, _, _ select.select([self.sock], [], [], self.timeout) if readable: raw, _ self.sock.recvfrom(65535) frame parse_frame(raw) if frame and frame[0] FRAME_TYPE_ACK and frame[1] fin_ack_seq: fin_ack_received True else: self.channel.sendto(build_frame(total, b, FRAME_TYPE_FIN), self.receiver_addr)注意frame[1] fin_ack_seq的判断。因为FIN序号是total接收端回ACK时携带的序号是FIN的序号所以这里要比较大于等于而不是严格的等于——重复ACK可能晚到但不会小于total。这是整个可靠文件传输的“关门动作”少了它会成为验收时最尴尬的失败场景。5. GBN实验常见踩坑排查五个典型问题的现象与解法写GBN实验翻车翻得最多的不是协议理解而是实现细节。下面五条都是实操中反复出现的坑按“现象→原因→解决”给出排查路径每一条都能直接对照你自己的代码。5.1 坑1序号回绕判断写错协议窗口彻底错乱现象文件传了三分之一后突然开始疯狂重传接收端写入的文件出现大段重复数据或者程序直接抛KeyError。原因帧数量超过序号上限next_seq模运算回绕到0新帧和未确认的旧帧共享同一个序号接收端无法区分。解决把序号上限设为远大于总帧数的值比如文件1MB、帧1KB共有1024帧序号上限设4096就足够。如果你一定要压着上限跑就必须在窗口跨过0点时额外维护一个current_epoch状态接收端按“序号批次”双重判断复杂度翻倍不推荐。5.2 坑2校验和用sum取模损坏帧被当成了好帧现象丢包率设为0、损坏率设为20%时接收端完全不报错但还原出的文件和原文件不一致。原因用sum(data) % 65536计算校验和字节顺序调整或成对翻转时校验和不变损坏帧通过校验。解决改用zlib.crc32或hashlib.md5至少在实验报告中给出“因简单加和无法检测字节顺序变化故改用CRC32”的说明。校验不能覆盖头部的另一个隐患是攻击者或信道篡改序号后接收端按错误序号存帧重组时数据错位这种错误同样要靠在头部上增加校验来兜底。5.3 坑3超时重传用time.sleep程序卡成单线程现象设置了丢包率后发送端表现极不稳定有时等好几秒才发下一批帧接收端进度条一动不动。原因time.sleep(timeout)会阻塞整个线程阻塞期间ACK到达了也收不到只能等sleep结束再处理于是每个丢包都额外付出一个超时时间的代价。解决用select.select替代sleep把等待时间交给内核去监听socket可读状态超时只在“确实没有ACK到达”时触发。这是整个实现里最值得改的一处改完后丢包率20%环境下的传输时间能缩短一个数量级。5.4 坑4文件末尾不足一帧重组时少一段数据现象发送端send完后直接退出接收端报错或生成文件比原文件小几个字节。原因拆帧时按chunk_size切片最后一块通常不满1KB接收端按固定帧长去读或者按头部length字段解析时把最后一次recvfrom的数据截断了。解决帧头的length字段必须用于重组逻辑而不是默认凑满chunk_size接收端写文件时也要累计实际收到的字节数不能简单按“帧数乘帧长”计算。用文件大小除以帧大小得到的余数正是末尾帧的真实长度务必让接收端依赖length字段而不是固定值。5.5 坑5收发共用一个端口ACK被解析成数据帧现象数据帧和ACK都发往同一个接收端口接收端的处理逻辑里没有对帧类型做分支导致ACK被当成数据帧写入文件文件内容混入乱码。原因收发双方共用一个socket发送端发出的数据帧和接收端发出的ACK源端口相同接收端收到后只看了序号没看类型。解决在解析帧后的第一个分支里就判断ftype只对FRAME_TYPE_DATA做数据写入FRAME_TYPE_ACK直接跳过FIN帧必须有独立分支。类型字段设了三种却在接收端只写了一个if这种疏忽会在混合测试时暴露得很彻底。6. 拿什么证明你的GBN真的可靠三档丢包测试与窗口滑动验证代码写完不是终点课程实验验收通常要看“你凭什么说它可靠”。只用一句话“测试通过”说服力不足做三档对照实验把数据记录下来比任何解释都管用。6.1 对照实验丢包率0%、20%、50%的吞吐与重传统计信道类把丢包率做成参数跑测试时分别设成0、0.2、0.5传输同一个文件分别记录发送帧总数、重传帧总数、实际耗时。0%档的重传数应该为020%档重传数约等于总帧数的三分之一到一半50%档耗时明显上升且发送端会频繁重传整个窗口。这三组数据能直接证明两层结论丢包率升高时重传确实发生了同时文件内容依然保持一致。对比时注意固定窗口大小和超时时间否则两组数据之间没有可比性。6.2 用发送序号曲线验证窗口真的在滑动把Sender每个时刻的base和next_seq打印到日志里横轴是时间、纵轴是序号你会看到一条阶梯状曲线next_seq上升得快base跟随ACK慢慢追赶两条线的垂直距离就是当前窗口中未确认的帧数。如果画出来的next_seq一直不动说明发送窗口被堵死了大概率是ACK处理有问题如果base和next_seq几乎同步上升说明窗口形同虚设退化成停等协议。打印方式很简单在Sender主循环里每处理一个ACK就追加一行base, next_seq, time.time()到列表结束时写CSV。这个日志本身也是排错工具比单步断点更直观——你能看到整个传输过程中窗口有没有卡住。6.3 进阶把窗口状态落成CSV用matplotlib画滑动图如果想在报告里放一张有说服力的图把上面的日志存成CSV后用matplotlib画两条折线即可。横轴elapsed_ms纵轴seqbase和next_seq两条线之间的阴影面积就是滑动窗口的实时占用。import matplotlib.pyplot as plt seqs [] with open(window_log.csv) as f: for line in f: t, base, next_seq map(float, line.strip().split(,)) seqs.append((t, base, next_seq)) ts [row[0] for row in seqs] base [row[1] for row in seqs] next_seq [row[2] for row in seqs] plt.plot(ts, base, labelbase, linewidth1.5) plt.plot(ts, next_seq, labelnext_seq, linewidth1.5) plt.fill_between(ts, base, next_seq, alpha0.2) plt.xlabel(elapsed time (ms)) plt.ylabel(sequence number) plt.legend() plt.savefig(gbn_window.png, dpi150)画出来的图如果“两条线咬得很紧”说明信道的重传主导了传输如果“剪刀差”稳定在8左右说明窗口在正常工作。我自己的习惯是先把0%丢包跑一遍存图再跑20%和50%三张图放在一起重传风暴一目了然。这也成了我后来写所有协议实验的固定套路不急着调代码先让数据说话。希望这套方法能帮你的GBN实验少走几轮弯路。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表