ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python实现UDP可靠传输:滑动窗口与校验和实战

Python实现UDP可靠传输:滑动窗口与校验和实战 简介一套基于 Python 实现的 UDP 可靠数据传输协议工程包面向计算机网络课程设计、实验报告撰写及协议编程学习者。包内从停等协议入手逐步扩展到 GBN、SR 协议覆盖单向与双向可靠传输、丢包模拟验证以及基于 C/S 结构的文件传输应用适合需要完整方案与可运行代码的高校学生参考。资源共 14 个文件以 8 个 Python 源码文件为核心包含 server、client、协议实现及工具模块另有 3 个文本数据文件、设计报告 docx、README 与 license可配合报告写作和实验复现。压缩包整体约 493KB结构紧凑已有 1014 人学习下载。通过源码与设计报告读者能拿到完整的协议状态机设计、拆包/确认/重传思路、双向通信改进过程及实验结果便于快速理解 UDP 之上实现可靠传输的要点并直接复用或二次开发。1. 基于Python实现的可靠数据传输协议搜到 zip 之后先搞清楚它到底在干什么很多人拿到「基于Python实现的可靠数据传输协议.zip」第一反应是去搜 python 源码大全找一份能直接跑的课设代码。但我建议先换一个问题TCP 已经把可靠传输做完了为什么还要自己造一个答案不是“老师让写的”而是游戏同步、实时音视频、自定义消息总线这些场景恰恰需要在 UDP 上叠一套轻量可控的可靠传输。这套协议的核心目标就一句话在可能丢包、损坏、乱序的不可靠信道上用校验、确认、重传和序号机制让上层收到一条不丢、不乱、不重的字节流。它可以从几十行的停等协议开始也可以成长到带回退N步的滑动窗口版本。这篇按我从课设调通到模拟丢包压测的经验来写适合正在做计网实验、准备手撕代码、想在 UDP 上做通信底座的人。2. 可靠传输要解决的三个问题丢包、损坏、乱序以及为什么从 UDP 动手2.1 为什么不直接用 TCP把黑匣子的边界画清楚TCP 就是一套成熟的可靠传输协议握手、序号、校验、确认、超时重传、拥塞控制全都有。但它是内核协议栈里的黑匣子你没法在用户态改它的窗口策略也没法观察丢包时它到底重传了什么。课程设计和自研协议要的是“按需裁剪的可靠传输”所以最常见的做法是用 UDP 的 socket 打底把网络不可靠的本质暴露出来然后在应用层自己实现可靠机制。UDP socket 一发一收包头没有序号也没有确认号丢包丢得无声无息这恰好是研究可靠传输最好的起点——它把 TCP 藏起来的三个问题全部还原到明面上。你可能会问既然 UDP 不可靠为什么很多实时通信场景不用 TCP因为 TCP 遇到丢包会停在原地等重传、排队对低延迟场景体验很差。自研协议可以做得更激进丢一个包只重传那一个甚至根据业务选择“这个包过期了我就不重传”。这就是基于 Python 实现可靠数据传输协议的根本原因可靠不只有 TCP 一种形态它是可配置的策略组合。往大了说HTTP/3 的 QUIC 也是把可靠传输从内核挪到用户态自己实现思路和课设里在 UDP 上叠窗口是一回事。2.2 网络会犯的错和协议对应的解药把问题拆开看不可靠信道犯四类错比特损坏、报文丢失、报文乱序、报文重复。第一类靠校验和识别出来后直接丢弃第二类靠 ACK 加超时重传发出去没确认就当丢第三类靠序号让接收端排序配合滑动窗口决定“乱序的包是收还是丢”第四类靠序号去重收到重复序号直接丢弃或重发 ACK。这张表是整篇的实现地图信道问题协议机制代价比特损坏校验和 丢弃每个包多 4 字节报文丢失超时重传 ACK/NAK延迟增加可能出现重复报文乱序序号 缓存排序接收端内存报文重复序号去重同上在这个阶段先别急着写死代码先把两个问题的答案定下来“每收到一个包我要不要回 ACK每发一个包我要不要记时间”。我一般会先在纸上画一遍发送端状态和接收端状态再动手。Python 的循环写起来快但逻辑抄起来也容易乱状态图画清楚后面排错能省一半时间。2.3 停等协议为什么不够链路利用率这道算术题最朴素的可靠传输是停等协议发一个包等一个 ACK收到再发下一个。它逻辑简单二十行就能跑通但对带宽的浪费是灾难性的。假设链路带宽 10 MbpsRTT 100 ms应用层包长 1 KB发送一个包需要 0.8 ms然后干等 100 ms。链路利用率约等于 0.8 / (100 0.8)不到 1%。也就是说带宽再大停等协议也只能跑出几十 Kbps 的效果。滑动窗口就是把“等 ACK 的时间”填满窗口大小接近带宽延迟积理论上链路利用率可以接近 100%。带宽延迟积等于带宽乘 RTT10 Mbps 乘 0.1 s 大约是 125 KB换算成 1 KB 的包就是一百多个。课程设计里窗口给 816 个包不是拍脑袋是让发送端在等 ACK 的同时多塞几个包。这里有个不带公式的直觉链路越宽、距离越远窗口需要越大窗口里放着的是“已经发出但还没被确认”的包。于是协议从停等演进到流水线GBN回退N步和 SR选择重传。GBN 实现简单代价是乱序代价大——丢一个包接收端会丢弃后续所有包发送端超时后把整个窗口重传一遍。SR 为每个包单独计时、单独重传接收端要缓存乱序包代码复杂一截。课程设计里做 GBN 的最多因为它比停等更能讲清楚“窗口”和“累积确认”的关系也比 SR 更容易在答辩时讲透。3. 报文与校验和写一个能跑通的最小封包/解包骨架3.1 报文格式每个字段都是对抗一类问题协议层之上是报文格式。自研协议最忌讳“发送直接拼字符串、接收直接切片”因为语音、二进制、文件内容都可能有任意字节必须用固定头部加长度字段来切。环境上我默认 Python 3.8 以上版本只用标准库 socket 和 struct不需要第三方依赖。下面是一份 20 字节的头部设计工程里我习惯把它单独放在 packet.py 里字段类型/宽度作用magic2 字节识别协议包过滤串包version1 字节协议版本方便演进type1 字节DATA / ACK / NAK / FINseq4 字节数据序号去重与排序ack4 字节确认号累积确认语义length4 字节payload 长度checksum4 字节全包校验magic 看起来是小事但真有用。同一个端口如果被别的进程占着或者网卡上混着其他 UDP 流量没有 magic 的协议会把别人的数据当自己的 payload 解析轻则乱码重则把 seq 解析成巨大数字导致消息队列爆掉。version 是为了以后加字段时旧节点还能识别版本不兼容而不是直接把结构体读歪。type 字段把控制包和数据包分开这是停等和滑动窗口能跑的前提。seq 和 ack 的语义我建议在注释里固定下来seq 表示“这个数据包是发送方的第几个数据包”ack 表示“发送方下一个期待收到的数据包序号”。这两个语义不统一后面排查死锁会很痛苦第 5 章会专门说。3.2 校验和用累加取反而不是一把梭 hash校验和最常见的实现有两种CRC 和类 TCP 的 16 位累加取反。课程设计与面试里类 TCP 风格更合适因为它好讲、好手写溢出后能回到 16 位循环进位。我用的是“逐 16 位累加溢出回卷最后取反”的做法写起来不长效果也够用。注意这里不对 payload 单独做 hash而是对完整报文算校验和因为 hash 要逐块从头算强度高但慢UDP 场景下校验和强度够用就行。# packet.py 中的校验和实现TCP 16 位校验和思路 def compute_checksum(data: bytes) - int: # 奇数长度补一个空字节保证逐 16 位累加时不会漏尾巴 if len(data) % 2: data b\x00 total 0 for i in range(0, len(data), 2): word (data[i] 8) data[i 1] total word total (total 0xFFFF) (total 16) # 回卷进位 return (~total) 0xFFFF说明total 0xFFFF截断到低 16 位total 16把溢出的进位加回低位这一步是“回卷”。最终取反是为了让全零报文不容易被误判成合法校验。这里有个必须讲清楚的关键规则发送方计算时checksum 字段必须置为 0接收方验证时同样先把 checksum 字段清零再算比对是否一致。收发两端用同一条规则否则永远对不上。3.3 封包与解包把字节流切成能认的包接下来是最小骨架make_packet 负责把消息变成 bytesparse_packet 负责把 bytes 变回结构体同时完成校验。用 struct 的!前缀表示网络字节序避免跨平台大小端问题。import struct MAGIC 0x7E57 VERSION 1 TYPE_DATA 1 TYPE_ACK 2 TYPE_NAK 3 TYPE_FIN 4 def make_packet(seq: int, ptype: int, payload: bytes b, ack: int 0) - bytes: length len(payload) # 先按 checksum0 组一个完整报文再算校验和 head struct.pack(!HBBIIII, MAGIC, VERSION, ptype, seq, ack, length, 0) packet head payload cs compute_checksum(packet) return struct.pack(!HBBIIII, MAGIC, VERSION, ptype, seq, ack, length, cs) payload def parse_packet(data: bytes): if len(data) 20: return None magic, ver, ptype, seq, ack, length, cs struct.unpack(!HBBIIII, data[:20]) if magic ! MAGIC or ver ! VERSION or length ! len(data) - 20: return None payload data[20:20 length] # 关键把 checksum 字段置 0 后重新计算并比较 body struct.pack(!HBBIIII, magic, ver, ptype, seq, ack, length, 0) payload if compute_checksum(body) ! cs: return None return {ptype: ptype, seq: seq, ack: ack, payload: payload}这段代码有三个值得说明的点。第一make_packet 里先组一个 checksum0 的包计算再把真实 checksum 填进去不能对“已经含 checksum 的报文”再算一次否则接收方验证时永远对不上。第二parse_packet 直接用 head 加 payload 重新组包并把 checksum 字段清零再调用 compute_checksum保证两端规则一致。第三length 字段同时承担了边界校验收到的字节数不对直接返回 None宁可丢包也不能把错包当有效包处理。3.4 MSS 怎么选别超过 1450 字节封包骨架有了之后下一个问题就是数据块切多大。Python 的 UDP socket 理论上最大能发 65507 字节但超过 MTU典型以太网 1500 字节就会触发 IP 分片。分片后的 IP 包在链路上极易丢丢一片整个 UDP 报文就没了重传还得重发一整块效率反而更低。所以常用做法是直接把 MSS 定为 1450 字节也就是 1500 减 20 字节 IP 头减 8 字节 UDP 头留出余量给隧道头或 IP 选项。窗口和缓冲区再大单包也得按这个大小切。这里给个可以直接抄的切分函数MSS 1450 def split_chunks(payload: bytes, mss: int MSS): # 按固定大小切成 list最后一块不足 mss 也没关系 return [payload[i:i mss] for i in range(0, len(payload), mss)]调用方只需要保证 payload 是 bytes传文件、传 JSON、传序列化后的对象都行。我一般会在工程里把“分块”和“重组”写在同一边接收端维护一个 pending 结构按 seq 拼好再交给上层避免把分块逻辑散落到业务代码里。4. 停等协议到滑动窗口超时重传、累积确认与窗口参数的实现细节4.1 先把停等协议跑通二十行内的可靠传输最小的可靠传输就是停等协议。发送端逐块发数据收到对应 ACK 才移向下一个块接收端只接受恰好等于期望序号的块其余一律丢弃或重发 ACK。下面是完整可跑的骨架ACK 携带的 ack 字段表示“下一个期望的序号”。注意 import socket、time 这些基础库代码里不再重复贴。def stop_wait_send(sock, addr, payload, timeout0.5): chunks split_chunks(payload) for seq, chunk in enumerate(chunks): while True: sock.sendto(make_packet(seq, TYPE_DATA, chunk), addr) try: sock.settimeout(timeout) data, _ sock.recvfrom(2048) pkt parse_packet(data) if pkt and pkt[ptype] TYPE_ACK and pkt[ack] seq 1: break except socket.timeout: # 超时重发同一个块直到确认 continue def stop_wait_recv(sock): expected 0 while True: data, addr sock.recvfrom(2048) pkt parse_packet(data) if not pkt or pkt[ptype] ! TYPE_DATA: continue if pkt[seq] expected: # deliver 是上层回调你可以改成 print、写文件或交给队列 deliver(pkt[payload]) expected 1 sock.sendto(make_packet(expected, TYPE_ACK, ackexpected), addr) else: # 收到旧序号的重复数据再回一个期望 ACK sock.sendto(make_packet(expected, TYPE_ACK, ackexpected), addr)超时处理是整个协议能否站住的关键。recvfrom一旦设置了 settimeout超时会抛 socket.timeout所以停等发送端不需要额外线程一个 while True 就能循环重发。接收端用 expected 做期望序号重复包不回数据直接补 ACK。这段代码很短但已经覆盖了校验、丢包重传、乱序去重三个机制窗口之外可靠传输的底座已经齐了。4.2 升级到回退N步窗口、累积确认与一次超时重传整个窗口停等跑通之后把“发一个等一个”改成“一次发多个、窗口内自由前进”就是 GBN。发送端维护两个指针base 是窗口里最老未确认的包next_seq 是下一个要发的包。窗口没满就继续发收到 ACK 就推进 base。def gbn_send(sock, addr, payload, window16, timeout0.3): chunks split_chunks(payload) total len(chunks) base 0 next_seq 0 last_ack 0 while base total: # 窗口没满就先发 while next_seq base window and next_seq total: sock.sendto(make_packet(next_seq, TYPE_DATA, chunks[next_seq]), addr) next_seq 1 try: sock.settimeout(timeout) data, _ sock.recvfrom(2048) pkt parse_packet(data) if pkt and pkt[ptype] TYPE_ACK and pkt[ack] last_ack: last_ack pkt[ack] # ack 是下一个期望序号 base last_ack except socket.timeout: # 回退N重传从 base 开始到 next_seq-1 的所有包 for seq in range(base, next_seq): sock.sendto(make_packet(seq, TYPE_DATA, chunks[seq]), addr)GBN 接收端几乎没有变化唯一的区别是收到乱序包时不再只回 ACK而是连续回“当前期望序号”的 ACK让发送端知道窗口根本没推进def gbn_recv(sock): expected 0 while True: data, addr sock.recvfrom(2048) pkt parse_packet(data) if not pkt or pkt[ptype] ! TYPE_DATA: continue if pkt[seq] expected: deliver(pkt[payload]) expected 1 # 无论是否乱序都回复当前期望序号 sock.sendto(make_packet(expected, TYPE_ACK, ackexpected), addr)GBN 里 ACK 的累积语义是关键。发送端收到 ack5 意味着“5 以前的数据包我都收到了下次请发 5”不需要为每个包单独确认。接收端只要发现 seq 不等于 expected就说明中间丢了包后续到达的包一律不作为推进依据全部丢弃后继续回期望 ACK。这就是“回退N”名字的由来。送实验报告一句话GBN 用牺牲窗口内部分重传来换取接收端零缓存。你可能想问为什么不用 NAK停等里 NAK 很直观但 GBN 里 ACK 已经能传递“我没收到哪个”的信息控制面少一种包就少一类 bug这也是 TCP 的选择。4.3 超时时间怎么设不是拍的是算的超时时间是除窗口外最影响性能的参数。设小了链路一抖动就疯狂重传设大了真丢包时要等很久才补。我一般不会用固定值而是先测 RTT用 EWMA 平滑再把超时设为平滑 RTT 的 1.5 到 2 倍。课程设计没时间动态测直接把超时定在 0.2 到 0.5 秒起步再在丢包模拟下观察重传次数来微调。# 简易 RTT 估算每次收到 ACK 更新 sample_rtt time.time() - send_time[seq] estimated_rtt 0.875 * estimated_rtt 0.125 * sample_rtt timeout max(0.1, 2 * estimated_rtt)这个 EWMA 公式是 TCP 经典做法权重 0.125 让新样本的影响不至于太跳。把它合进 4.2 的 gbn_send每次 sendto 后记 send_time[seq] time.time()收到 ACK 时取样本。注意动态测 RTT 时重传过的包不能重新采样否则估算会被重传惩罚拉偏这个坑在第 5.1 节展开。4.4 窗口大小、MSS、缓冲区的配合动窗口参数之前先记住一个比例窗口要覆盖带宽延迟积但也不能超过接收端缓冲区。带宽 10 Mbps、RTT 100 ms 时带宽延迟积约 125 KB除以 MSS 1450 B 大约是 88 个包这是理论上限。课设里窗口给 8 到 16 起步很稳因为丢包模拟下重传效率会随窗口增大而下降窗口太大反而让回退N的连锁重传放大。下面这张参数表可以直接抄参数停等GBN 初值调整依据窗口116带宽乘 RTT 后再除以 MSS超时0.5 s0.3 s1.5~2 倍平滑 RTTMSS14501450以太网 MTU 余量接收缓冲1 块窗口大小块避免不可达后内存吃到满我见过很多课设代码把接收缓冲开成固定 65536 字节窗口设到 64结果在模拟 20% 丢包时接收端因为要消化大量乱序信息而内存占用飙升。稳妥做法是接收端缓冲开成窗口等量每个位置放一个分块或空位标记宁可让包丢在网络上也别让它堆在进程里。5. 可靠数据传输协议踩坑清单超时、序号、ACK 语义和回环后的假象5.1 超时时间太小一阵通一阵断像接触不良现象本地回环一切正常放到模拟丢包环境里吞吐骤降发送端疯狂重发同一个包抓包看 ACK 其实已经回到对端了。程序看起来在正常工作速度却慢得让人怀疑人生。原因超时设得比真实 RTT 还小或者重传后 RTT 估算没有排除重传样本。ACK 在路上发送端等不及就重传重传的包再触发重复 ACK链路被自激式重传占满。解决超时按 EWMA 的 1.5 到 2 倍平滑 RTT 走重传过的包不进 RTT 样本。如果没做 RTT 采样宁可把超时踩大到 0.5 秒也比 50 毫秒的抖动强。5.2 校验和算错对方永远沉默你永远重发现象发送端把包发出去接收端一个 ACK 都不回用 print 在两端分别打日志接收端确实 recvfrom 到了数据但就是不做确认。排查半天发现是校验和逻辑不对。原因这是最典型的“校验和自洽性”错误——发送端用含 checksum 的报文再算一遍接收端把 checksum 字段清零重算两边规则不一致或者接收端忘了清零导致任何报文都过不了校验。解决发送端算校验和时 checksum 位置 0接收端验证时间样置 0 再算比对一致才收。第 3.3 节的 parse 代码就是这个规则照抄不会错。排错时写一行 print把 un pack 出来的 cs 和重算的值打出来一两个来回就能定位。5.3 ACK 语义漂移两个端点对“ack 指什么”的理解不同现象窗口偶尔能推进推到某个序号后就卡死重启又正常单包文件能传多包文件必卡。越是大文件越明显排查时往往盯着窗口大小看半天。原因发送端认为 ack3 是“收到 3 号包”接收端回的是“期望 3 号包”两者差 1。乱序时差 1 恰好能让窗口假推进直到某个边界才暴露死锁。解决把 ack 的语义写进代码注释ack 等于下一个期望收到的 seq。接收端第一次收到 seq0确认 ACK 的 ack 填 1发送端收到 ack1base 前移到 1。两端用同一套固化逻辑不要临时“顺手填了 seq”。5.4 本地回环测试全是假象不掉包的测试测不出可靠协议现象本机测试 10 MB 文件秒传重传计数为 0一搬上局域网或真机链路丢包 5% 后直接卡死最后才发现重传逻辑根本没被触发过。原因loopback 接口的丢包率几乎为零RTT 也比真实网络小几个数量级。可靠传输协议真正要对抗的丢包、乱序、延迟抖动回环测试一个都测不出来。解决用第 6 章的 tc/netem 在回环上人为加丢包和延迟让本地开发环境模拟出广域网特征。没有 Linux 环境就写一个概率丢包代理层在 sendto 和 recvfrom 之间故意丢弃一部分包效果一样。5.5 窗口与 recvfrom 阻塞互相卡死程序“死了”但进程还活着现象窗口填满后程序不再发包也不再收包CPU 占用不高进程看起来活着实际在 recvfrom 里阻塞等一个永远不会来的 ACK。如果不打日志根本看不出它卡在哪一行。原因发送端把“窗口推进”和“超时重传”写进同一次 recvfrom 阻塞操作。窗口满时发送端没有可发的包却在等 ACK而 ACK 需要接收端先收包接收端如果也在等就成了经典死锁。解决把发送循环和收包循环分开。常见做法是发送端起一个后台线程专门收 ACK发送主循环只管发包和超时或者用非阻塞 socket 加 select/poll 做多路复用别让任何一个循环独占 recvfrom。我一般用非阻塞 socket代码里没有线程行为也更好预测。6. 证明协议可靠丢包模拟、吞吐统计和三次验证6.1 用 tc/netem 把丢包率“调”出来不用真机Linux 本机就能模拟广域网。tc 命令挂在回环接口上加丢包率和延迟本地测试立刻变成真丢包环境sudo tc qdisc add dev lo root netem loss 5% delay 20ms # 测试完成后一定要清理 sudo tc qdisc del dev lo root注意lo 是回环设备加上丢包后本机所有环回流量都会受影响测完立刻删除规则。实际跑的时候可以做一组 0%、2%、5%、10% 的对比测试每档记录总耗时和重传次数。这张表放实验报告里比任何“效率很高”的形容词都有说服力。6.2 三个可信的验收指标协议靠不靠谱只看三个数字有效吞吐、重传率、乱序率。有效吞吐用 payload 总字节数除以总耗时重传率统计发送端实际 sendto 次数与分块数的比值乱序率在接收端统计“seq 不等于 expected”的次数占总包数比例。代码层面就是在两侧各加一个计数器# 发送端统计片段 send_cnt 1 if pkt and pkt[ptype] TYPE_ACK: ack_cnt 1 # 结束后打印重传率 (send_cnt - total) / total参数不要散落在代码里。把窗口、超时、MSS、最大重传次数集中到一个配置字典跑实验时只改配置重跑脚本看三个指标。这个“后悔药”式配置能让调参从半小时缩短到两分钟。6.3 一个收尾习惯做成这一步其实你已经不是在交课设而是在评判自己的协议。我后来做任何自研通信协议第一步不是写功能而是先把丢包模拟脚本和统计打点搭好因为协议没有度量就只是玄学有了丢包率、重传率、吞吐这三个数所有“感觉卡”“感觉稳”的反馈都能落地成一个能改的参数。这个习惯帮我少走了很多弯路也希望帮到你。本文还有配套的精品资源点击获取
返回列表