用Socket编程模拟GBN滑动窗口协议:掌握TCP可靠传输核心机制 简介这套代码以Socket编程模拟数据链路层的滑动窗口协议面向计算机网络或数据通信课程的学习者用于解决1bit滑动窗口协议理解难、实验环境搭建繁琐的问题。资源共2个文件均为C语言源码压缩包整体仅4KB。其中发送端与接收端程序通过TCP连接协同运行可直观演示停等式滑动窗口的帧发送、确认与超时重传流程并可在本机或局域网内直接编译运行便于对照教材验证协议行为。目前已有1308人学习/下载适合初学者在课程设计或实验报告中参考。代码结构简洁无多余依赖既能帮助读者吃透滑动窗口的核心机制也可作为进一步扩展为GBN或SR协议的起点。1. 用socket编程模拟滑动窗口协议到底要模拟什么很多人以为滑动窗口协议只能在TCP协议栈里看其实用socket编程自己模拟一遍才能把“窗口”这个概念彻底玩明白。这个模拟实验不做底层的拥塞控制只把一个关键问题摆到你面前发送方最多能有多少个包在未收到确认的情况下继续发序列号怎么编、超时重传又怎么选全都要你用代码表态。它能直接解决的问题是——把TCP可靠传输的几根支柱拆到socket代码里一根一根看清。适合刚学完《计算机网络》想做课设的学生也适合准备面试想补协议细节的开发者。2. 滑动窗口协议实现选型UDP打底、Go-Back-N与Selective Repeat怎么选2.1 为什么不在TCP上做非要用UDP滑动窗口是TCP可靠传输的核心机制但直接用TCP编程时窗口管理和确认逻辑全在内核里你只能调参数不能改行为。模拟的目的是把序列号、确认、重传算法的控制权拿回自己手里。所以常见做法是用UDP作为底层信道自己定义报文格式。UDP只负责把数据包尽量送到对端不保证顺序、不保证送达。这些“没有的东西”恰好是你需要自己实现的。我在写这类实验时socket一律用socket.SOCK_DGRAM收发两端各自bind不建立连接省掉所有和滑动窗口无关的握手逻辑。UDP报文的ttl和端口都直接透传后续做丢包注入也方便——你在sendto之前拦一下就行完全不影响协议实现。提示TCP的滑动窗口由内核基于接收窗口和拥塞窗口共同推进跟实验里“自制滑动窗口”是不同的层次混在一起看很容易绕晕。2.2 三选一停等、回退N、选择重传滑动窗口协议家族里停等协议窗口为1发一个等一个逻辑最简吞吐量也最差回退N帧Go-Back-NGBN窗口大于1接收端只按序接收一旦发现某个包丢了就把从丢包位置开始的整个窗口全部重发选择重传Selective RepeatSR则允许接收端缓存乱序包只重传真正丢失的那一个。协议窗口1乱序包处理实现复杂度教学价值停等否不需要处理最低只看基础确认Go-Back-N是直接丢弃中等累积ACK批量重传现象直观Selective Repeat是缓存稍后排序较高接收缓冲管理代码量大GBN是性价比最高的模拟选择。它既有累积ACK的批量确认逻辑又有超时后整套窗口重传的壮观画面在socket层看得清清楚楚。SR实现时要额外管理接收缓冲区和乱序缓存多出来的代码量几乎能把第一个实验拖垮。做课程设计或者面试演示GBN完全够用。2.3 状态机与关键变量序列号、累积ACK和窗口右移不管选哪个协议核心状态都是发送方的send_base和next_seq_num以及接收方的expected_seq_num。发送窗口就是[send_base, send_base window_size)这个半开区间send_base左边界表示所有小于它的序号都已确认next_seq_num指向下一个待发送数据。class GBNNode: def __init__(self, window_size, timeout_ms): self.send_base 0 # 窗口左边界小于它的序号全部确认 self.next_seq 0 # 下一个要发送的序号 self.window_size window_size self.timeout_ms timeout_ms self.packets {} # seq - 原始字节超时重传用这段代码定义了发送方最核心的状态。send_base是窗口左墙所有窗口右移动作都发生在收到ACK的那一刻next_seq是右墙每次发送一个包就自增。packets字典是重传缓冲因为UDP不帮你缓存任何东西协议自己必须留着没被确认的报文副本。window_size是实验中唯一要反复调的参数它直接决定窗口满时发送端会不会停住。3. 动手实现GBN滑动窗口Python socket发送端与接收端全流程3.1 发送端事件循环窗口满时停发超时批量重传发送端主循环不能开一堆线程否则两台机器上极难排查时序问题。我一般用单线程事件循环把“发送数据”“等待ACK”“超时重传”全部串在一个while里。def sender_loop(sock, addr, data_chunks, window_size, timeout_ms): base, next_seq 0, 0 while base len(data_chunks): # 填满窗口 while next_seq base window_size and next_seq len(data_chunks): pkt build_pkt(next_seq, data_chunks[next_seq]) sock.sendto(pkt, addr) next_seq 1 try: ack, _ sock.recvfrom(128) if parse_ack(ack) base: # 只接受不小于左墙的ACK base parse_ack(ack) 1 # 累积ACK右移到确认号下一格 except socket.timeout: # 整个窗口全部重传 for seq in range(base, next_seq): sock.sendto(build_pkt(seq, data_chunks[seq]), addr)逻辑核心在两个地方。第一个while只负责发数据条件是next_seq没有顶到窗口上沿一旦base不右移next_seq也不会超过base window_size发送端自然卡死。收到ACK后base直接跳到ack编号加1的位置这就是累积ACK的精髓——一个ACK编号N等于告诉发送方序号0到N全部安全右移的量比实际收到的ACK数量大得多。超时后重传的是从base到next_seq之间的整个窗口不是只重传一个包这是GBN最明显的特征。timeout_ms这个参数我给100到300毫秒别设成1秒否则本地实验一次得到几十秒调参效率太低。这只是一个初始参考第四章会讲怎么把这个值从固定改成动态。3.2 接收端只收顺序包乱序分包直接丢弃GBN接收端的代码比发送端短但设计决定要清晰它不缓存任何乱序包。所有seq不等于expected_seq_num的包都被当成“过期包”扔掉只回复当前期望的ACK。def receiver_loop(sock, total_packets): expected 0 while expected total_packets: data, addr sock.recvfrom(2048) seq parse_seq(data) if seq expected: expected 1 sock.sendto(build_ack(expected - 1), addr) else: # GBN不缓存乱序包丢弃后仍回复旧ACK sock.sendto(build_ack(expected - 1), addr)这里的核心是“不管收到什么都回同一个旧ACK”。如果收到的是期望的下一个包ACK编号就往前推一格如果收到的是乱序包ACK编号保持不动。从发送方的角度看这种重复ACK等于在喊“我还在等序号X”超时后发送方就能重发出正确的那一段数据。我个人建议在这个函数里加一行日志打印seq和expected的差值。差值非0的次数就是你碰撞到乱序包的次数这个指标在验证丢包率时相当有用。3.3 报文格式设计序列号、标志位和固定长度载荷报文头直接决定可靠性逻辑能不能写简单。我习惯用4字节序列号加1字节标志位再加最多1024字节载荷。序列号用无符号整数标志位简单分三种0表示数据1表示ACK2表示FIN。收发两端用同样的格式解析避免小端大端的问题。import struct def build_pkt(seq, payload, flag0): header struct.pack(! I B, seq, flag) return header payload这段代码里struct.pack有两个关键点。第一个是网络字节序感叹号表示用大端序C和Java混写的时候不会翻车。第二个是载荷固定不大于1024字节整个UDP报文控制在MTU以内避免IP分片把“丢包模拟”变成“分片重组的玄学现场”。提示本地回环测试时MTU是65535但在真机对传时千万别把报文搞大超过MTU会导致UDP分片丢一个分片整包全丢丢包率直接失控。4. 滑动窗口参数标定窗口大小、超时时间与丢包率怎么配4.1 先把窗口设为1验证停等协议的正确基线动手调参之前务必先跑一遍window_size1的基线实验。窗口为1时滑动窗口退化成停等协议发一个、等一个ACK、再发下一个。这个状态下发送窗口永远不会填满超时重传只会重发当前这一个包。我一般用5000个数据包跑基线全程不应出现任何重传本地无丢包时耗时应该和发包数量呈严格正比。如果连窗口1都跑出乱序或重复那问题多半在ACK解析或包头格式上而不是滑动窗口逻辑。这一步的意义在于把协议bug和参数问题剥离开——只有基线全绿后面调窗口大小才有意义。4.2 窗口从4到32重传次数与吞吐量的两组实测下面这张表是我在本地回环加5%模拟丢包时跑出来的典型数据10000个包每个1024字节。重传次数从窗口4到窗口32不是线性增长而是像阶梯一样跳变。窗口大小无丢包耗时(s)5%丢包重传次数5%丢包耗时(s)12.15052.841.46232.181.27892.5161.111203.4321.020445.2你会发现无丢包时窗口越大越快但丢包率一上来大窗口反而被重传拖垮。原理很直接丢包率固定时窗口越大丢包后需要重传的数据就越多——GBN是整批重传一个包丢了等于窗口内所有包都得再来一遍。窗口32时一次超时重传32个包而这些包里可能又一两个是新的丢包陷入重传雪崩。这个实验是我建议每个做GBN的人都跑一遍的它比任何书上的理论曲线都印象深刻。4.3 超时时间别写死RTT采样与SRTT指数平均固定超时时间是这个项目里最坑的“玄学”参数。设太短网络抖动一下就是一堆重传设太长真丢包时发送端要傻等好久窗口才动。正确做法是对RTT做指数加权平均每次收到ACK就更新一次超时阈值。def update_srtt(sample_rtt, srtt): alpha 0.125 return (1 - alpha) * srtt alpha * sample_rttsample_rtt是你从发送一个数据包到收到它ACK的时间差srtt是平滑后的估计值。alpha取0.125是TCP协议里的经典参数新样本占的权重不大能过滤掉个别的毛刺延迟。超时阈值我习惯设为2倍SRTT或者300ms取较大者低于200ms的阈值在链路抖动时几乎必翻车。我在做丢包率实验时会特意观察打印出的重传次数如果重传次数明显多于实际丢包数第一件事不是怀疑丢包统计而是把阈值调大重跑。固定超时值扛不住负载变化这点做内网测试时会骗人UDP在本地几乎无损你根本看不出超时阈值是否合理。4.4 用netstat观察UDP收发包与丢包统计调参不能只看日志还要看系统侧的UDP统计。Linux上我用netstat -su来查收发队列和丢包计数重点看UdpRxErrors和UdpRcvbufErrors这两个字段。如果你在发送端开启窗口16发现UdpRcvbufErrors突然从0涨到几百说明接收缓冲区溢出了非协议层丢包开始混进来实验数据就会失真。数据校验的思路是发送端统计的“发出的包数”减去接收端打印的“收到的包数”应该恰好等于丢包注入器丢掉的包数。任何多出来的差值都意味着你的协议在漏包或重复发包。把这一步固定进实验流程比盯着程序输出猜问题靠谱得多。5. 滑动窗口模拟常见问题避坑记录四个翻车现场与修复5.1 现象接收端打印的ACK序号乱跳程序却不报错跑窗口4的实验时发送端打印收到的ACK是2、1、3、0顺序完全乱但收发双方都不异常退出。这种现象最坑的是没有任何异常可抓只能靠日志逐个核。原因出在我把接收端的确认逻辑写成了“收到一个包就回一个确认”而不是“累积确认”。这就退化成了逐包确认ACK的数值自然跟随到达顺序乱跳。滑动窗口的累积ACK应该是不管收到哪个包只确认到第一个不连续的位置。解决方法是放弃对每个包的独立回复只维护expected_seq_num收到seqexpected的包才把ACK往前推其余情况一律回旧的期望序号。改完后日志里ACK号严格单调递增再也不跳。5.2 现象窗口调到16以上发送端卡死在窗口满状态窗口设8一切正常调到16后发送端发了几百个包就不再动作base停在某个值不动窗口余量变成0。排查时先打印base和next_seq的差发现base始终没有右移。追踪到解析ACK的函数发现ACK报文里序列号解析出来是负数——payload被按有符号整数解读了。UDP报文里4字节序号用I格式是0到42亿按有符号解析就把高位当符号位序号直接变成负的累积ACK条件ack base自然永远不成立。解决方法是把struct解析格式统一改成! I B收发两侧一致。这个坑提醒我socket编程里跨端解析包头时符号位和字节序必须写进协议文档里不然大窗口下迟早撞上。5.3 现象模拟丢包时把第一个包丢了连接直接卡死我写丢包注入器时图省事对所有的UDP包按概率丢弃结果实验一开始就死等——发送端发出的第一个数据包被丢弃接收端还在等期望的第0包发送端超时后重发结果重发的包又按概率给丢了连接彻底建立不起来。原因很直白丢包注入对握手包和普通数据包一视同仁而GBN协议的期望序号从0开始固定没有任何“重新请求”机制。解决方法是丢包注入只作用在seq大于0的包上或者干脆给控制帧打标志位丢包逻辑先判断标志位再决定是否丢弃。从那以后我养成一个习惯所有丢包脚本都在报文标志位过滤后执行宁可丢错数据包也不丢关键的控制包。5.4 现象两个线程同时sendto收到一模一样的重复包为了加快重传速度我给超时重传单独开了一个线程结果接收端收到两个相同seq的包expected直接错乱后面全部乱套。UDP支持多线程同时写同一个socket但两个线程发出去的包顺序没有保证重传的包和正常发送的包可能互相穿插接收端看到的就是序列号回退。根本原因是设计上违反了单写原则。socket编程里同一个socket的所有写操作必须在同一个线程里串行化否则就要加锁。加锁的代价是重传逻辑要和发送逻辑抢锁反而更慢。解决方法是删掉重传线程把超时重传放进单一事件循环里用socket的timeout触发重传。从那次之后我的所有socket模拟项目都强制走单线程循环重传、发送、ACK接收全在一个while里按顺序处理。6. 可靠传输自检技巧丢包注入脚本与窗口右移断言6.1 丢包注入在socket层写一个独立的转发器模拟丢包不能靠改业务代码否则会污染协议逻辑。我习惯写一个UDP转发器两端都是固定端口中间按概率丢包。转发器里只做一件事收到一个包决定转发还是丢弃别的不碰。def lossy_forwarder(local_port, remote_addr, loss_rate): s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind((127.0.0.1, local_port)) s.settimeout(0.01) while True: try: data, _ s.recvfrom(2048) if random.random() loss_rate: s.sendto(data, remote_addr) except socket.timeout: continueloss_rate是0到1之间的浮点0.05表示5%丢包率。运行时先启动接收端和发送端连转发器再慢慢把loss_rate从0调到0.1看滑动窗口在真实丢包下的重传反应。这段脚本最大的价值是让丢包发生在“线路上”而不是发生在“协议代码里”协议本身从头到尾不知道丢包是被注入的更接近真实网络行为。6.2 窗口右移断言验证累积ACK没写错很多bug都出在“ACK到了但base不右移”上肉眼检查太慢我写了一个断言测试来兜底。def test_window_slide(): node GBNNode(window_size4, timeout_ms100) node.next_seq 4 node.send_base 0 # 模拟收到ACK3说明0到3全部确认 node.send_base 3 1 assert node.send_base 4, 窗口左墙没有右移到确认号之后这个测试核心就一个ACK编号N到达后send_base必须变成N1。窗口从0滑到4意味着前4个包已经全部安全抵达可以腾出空间给新包。如果断言没过问题基本在parse_ack或ACK语义上别往重传逻辑里找。6.3 一条实操习惯分层验证不混参数最后的建议是把验证拆成三层第一层窗口固定为1无丢包确认基础逻辑第二层window_size8加5%丢包确认重传逻辑第三层窗口从8往上调观察吞吐和重传的拐点。每一层通过后再进入下一层任何一步异常问题范围立刻缩小一个量级。记得有次调窗口参数调到大半夜文件传了一半接收端不走了我以为是丢包逻辑的bug折腾半天发现是窗口32时本地循环接收缓冲区溢出了丢包根本不在注入器那而在系统层。后来我在跑任何配置前都先看netstat的UDP丢弃计数确认系统没掺和进来再下结论。从那以后我每次做滑动窗口实验都强制走这套分层验证流程希望帮到你。本文还有配套的精品资源点击获取