简介:这套代码以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-N,GBN)窗口大于1,接收端只按序接收,一旦发现某个包丢了,就把从丢包位置开始的整个窗口全部重发;选择重传(Selective Repeat,SR)则允许接收端缓存乱序包,只重传真正丢失的那一个。
| 协议 | 窗口>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表示ACK,2表示FIN。收发两端用同样的格式解析,避免小端大端的问题。
import struct def build_pkt(seq, payload, flag=0): 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_size=1的基线实验。窗口为1时,滑动窗口退化成停等协议:发一个、等一个ACK、再发下一个。这个状态下,发送窗口永远不会填满,超时重传只会重发当前这一个包。
我一般用5000个数据包跑基线,全程不应出现任何重传(本地无丢包时),耗时应该和发包数量呈严格正比。如果连窗口1都跑出乱序或重复,那问题多半在ACK解析或包头格式上,而不是滑动窗口逻辑。这一步的意义在于把协议bug和参数问题剥离开——只有基线全绿,后面调窗口大小才有意义。
4.2 窗口从4到32:重传次数与吞吐量的两组实测
下面这张表是我在本地回环加5%模拟丢包时跑出来的典型数据,10000个包每个1024字节。重传次数从窗口4到窗口32不是线性增长,而是像阶梯一样跳变。
| 窗口大小 | 无丢包耗时(s) | 5%丢包重传次数 | 5%丢包耗时(s) |
|---|---|---|---|
| 1 | 2.1 | 505 | 2.8 |
| 4 | 1.4 | 623 | 2.1 |
| 8 | 1.2 | 789 | 2.5 |
| 16 | 1.1 | 1120 | 3.4 |
| 32 | 1.0 | 2044 | 5.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,收到seq=expected的包才把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_size=4, timeout_ms=100) node.next_seq = 4 node.send_base = 0 # 模拟收到ACK=3,说明0到3全部确认 node.send_base = 3 + 1 assert node.send_base == 4, "窗口左墙没有右移到确认号之后"这个测试核心就一个:ACK编号N到达后,send_base必须变成N+1。窗口从0滑到4,意味着前4个包已经全部安全抵达,可以腾出空间给新包。如果断言没过,问题基本在parse_ack或ACK语义上,别往重传逻辑里找。
6.3 一条实操习惯:分层验证,不混参数
最后的建议是把验证拆成三层:第一层窗口固定为1,无丢包,确认基础逻辑;第二层window_size=8,加5%丢包,确认重传逻辑;第三层窗口从8往上调,观察吞吐和重传的拐点。每一层通过后再进入下一层,任何一步异常,问题范围立刻缩小一个量级。
记得有次调窗口参数调到大半夜,文件传了一半接收端不走了,我以为是丢包逻辑的bug,折腾半天发现是窗口32时本地循环接收缓冲区溢出了,丢包根本不在注入器那而在系统层。后来我在跑任何配置前,都先看netstat的UDP丢弃计数,确认系统没掺和进来再下结论。从那以后,我每次做滑动窗口实验都强制走这套分层验证流程,希望帮到你。
本文还有配套的精品资源,点击获取