简介:面向计算机网络课程学习者与相关开发者的编程模拟资源,演示如何用socket建立TCP连接,在应用层还原数据链路层的1bit滑动窗口协议,适合课程实验与自学。资源共2个文件,均为C语言源文件,压缩包大小仅4KB,代码量精简;发送端与接收端分开组织,完整实现确认等待、超时重传与窗口滑动等核心流程,便于快速阅读、调试和二次改造。目前已有1308人学习下载。通过对照源码,既能直观理解滑动窗口协议中帧序号变化、确认帧处理等细节,也能掌握基于TCP socket的通信编程方法,尤其适合完成数据链路层实验或准备课程设计时作为参考。该实现将抽象协议机制转化为可运行程序,有助于加深对停等协议与TCP可靠传输异同的认识,边读边跑可获得更直观的体会。
1. 用 socket 编程模拟滑动窗口协议:为什么越像 TCP,越容易跑偏
用 socket 编程模拟滑动窗口协议,直觉上的做法是起一对 TCP 连接,给每个包标上序号,再按窗口大小一批批发。可真把日志拉出来看时,多数人会对着“窗口没动、吞吐没变”发半天呆——发送方明明按窗口发了,接收方也说收到了,重传一次都没发生过,那滑动窗口到底在哪里?答案藏在层次里:TCP socket 的内核实现已经把滑动窗口的调度、确认、重传全部接管了,你应用层写的那套逻辑根本没进入发送路径。滑动窗口不是挂在传输通道上的装饰,它决定了发送速率、内存占用,以及丢包之后系统怎么恢复。想看清它,就得绕开内核里现成的可靠传输,在 UDP socket 之上用自定义报文重建这套机制。这也是这次模拟最关键的认知拐点。
2. 滑动窗口:从 ACK 语义到管道化传输的底层逻辑
2.1 停止等待为什么慢:一条链路上只能有一个包在途
先看没有窗口时最朴素的可靠方案:发送一个段,等 ACK 回来,再发下一个段。假设一个段的发送时延是 T_f,往返时延是 RTT = 2 × 单程传播时延 T_p,那么发送方在一个完整周期里只有 T_f 的时间在真正干活,其余时间都在空等。链路利用率 = T_f / (T_f + 2T_p) ≈ 1 / (1 + 2a),其中 a = T_p / T_f。
这个 a 是关键。如果单个段的发送只花 1ms,而链路单程传播是 10ms,a = 10,利用率只有 4.8%——每 100ms 的链路时间里只有不到 5ms 在传数据。跨广域网的场景里 a 到几十、上百都很常见,停等协议的吞吐就被死死摁在了带宽的零头。滑动窗口机制要解决的核心问题就是:把这段被迫空闲的等待时间填满,让多个段同时驻留在网络中。窗口大小 W 允许 W 个段在未被确认的情况下连续发送,链路利用率近似提升到原来的 W 倍。
由此得到一个非常重要的工程直觉:窗口的理想尺寸不是越大越好,而是刚好等于“带宽 × 往返时延”(Bandwidth-Delay Product, BDP)。100Mbps 的链路、20ms 的 RTT,BDP 就是 250KB。窗口比这个小,链路喂不饱;窗口比这个大,多余的数据只会排进网络队列,增加时延甚至触发丢包。很多模拟实验里“窗口越大吞吐反而越低”,并不是协议写错了,而是你自己的链路层模型里塞进了排队丢包。
2.2 三个指针切分序号空间:SND.UNA、SND.NXT 与窗口边界
窗口机制的抽象可以用一个发送状态变量集合描述:SND.UNA 是最老的未确认序号,SND.NXT 是下一个将要发送的序号,两者之间的区间就是“已发送、未确认”的在途数据。发送窗口右边界是 SND.UNA + W。于是序号空间被切成三个区段:
- 小于 SND.UNA 的部分:已经确认,发送缓冲可以释放;
- SND.UNA 到 SND.NXT 之间:已发出但还没收到 ACK,必须保留在缓冲里供重传;
- SND.NXT 到 SND.UNA + W 之间:窗口还允许发送的“可发送区”。
窗口“滑动”这件事经常被误解成一个周期性动作,实际上它完全是事件驱动的:ACK 到达时 SND.UNA 向右推进,发送方腾出发送缓冲,应用层才能把新数据排进可发送区。也就是说,滑动窗口同时管住了两样东西:网络管道里在途数据的数量,以及发送缓冲区里待重传数据的生命周期。
这里有一个做模拟器时很容易忽略的细节:真实 TCP 的窗口字段只有 16 位,最大通告 65535 字节。当 BDP 超过 64KB 时,必须靠窗口缩放因子(WSCALE)把实际窗口放大。你的模拟器如果直接把窗口写成字节数的 Python int,等于跳过了这个报头约束;可一旦报文格式里只放 16 位窗口,发送方和接收方之间就必须协商好缩放比。选择哪种方案无所谓,但要明确写进设计文档,否则改来改去会把自己绕进去。
2.3 两个窗口叠加:rwnd、cwnd 与真正的发送限制
TCP 发送侧的窗口限制不是单独一个,而是两个窗口取最小值:接收方通过每个报文通告的 rwnd(接收窗口),和发送方自己维护的 cwnd(拥塞窗口)。rwnd 保护的是接收缓冲区——发送方不能灌爆接收方的内存;cwnd 保护的是网络——发送方不能把中间链路的队列灌爆。实际允许发送的判定条件是:
if snd_nxt - snd_una < min(cwnd, rwnd): send_segment(snd_nxt) snd_nxt += len(segment)这条判定是 TCP 每发送一个段时的核心路径。滑动窗口实验里最常见的一个建模错误,就是只保留一个固定窗口值,把 min(cwnd, rwnd) 简化为常量——这等于假设接收缓冲永远充足、网络永远不拥塞。作为“模拟滑动窗口”的练习,简化当然可取,但要意识到你模拟的只是流量控制这一半;一旦实验结果出现异常,先问自己一句:我现在到底在调哪个窗口?很多困惑都来自把这两个概念混在一起。
如果想把拥塞窗口也加进来做对比实验,最合适的做法是复刻一个慢启动 + 拥塞避免的算法模块,通过插拔来切换“纯固定窗口”和“带拥塞控制”两种模式。模拟器骨架里窗口大小和窗口类型分开设计,后面做吞吐对比时才不会被迫重写。
3. 模拟器骨架:把窗口状态机变成事件循环
3.1 状态变量落地:SND.UNA、SND.NXT 与窗口类
设计模拟器时,我习惯直接从协议的状态变量出发:SND.UNA、SND.NXT、SND.WND,每个都在类里占一个字段。窗口底和窗口顶都是字节序号,窗口大小以字节为单位的做法最贴近真实 TCP 语义。一类一字段的映射方式能让你在排查问题时,把日志里打印的每一条 ACK 直接对应到协议状态上。
class WindowSender: def __init__(self, sock, max_win, mss=1460): self.sock = sock self.mss = mss self.max_win = max_win # 发送窗口大小,单位:字节 self.snd_una = 0 # 最老未确认字节序号 self.snd_nxt = 0 # 下一个待发送字节序号 self.outbox = {} # seq -> payload,重传依赖的缓冲 self.rto = 0.5 # 超时重传时间,秒 self.deadline = None # 最早的定时器到期时刻 def window_full(self): return self.snd_nxt - self.snd_una >= self.max_win def fill_window(self): # 窗口没满就一直发;满了就停在原地等 ACK while not self.window_full(): data = app_next_chunk(self.mss) if data is None: # 应用层暂时没数据 break seq = self.snd_nxt self.outbox[seq] = data self._send(seq, data) self.snd_nxt += len(data) if self.deadline is None: self.deadline = time.time() + self.rto注意 seq 是按字节递增的,不是按“包序号”递增。这也是新手最容易搞错的境界:真实 TCP 的 ACK 和序号都指到字节,而不是第几个包。模拟器里如果按包计数,第一次把数据切成不等长分片时,整个窗口的确认逻辑就会错位。snd_nxt - snd_una 的差就是当前在途的字节数,窗口满停发、ACK 到达后继续填,这个循环就是滑动窗口的“泵”。
3.2 ACK 到达与窗口滑动:只有这一个事件能让窗口底部右移
窗口滑动的触发点只有一个:收到 ACK。发送事件本身不移动窗口底部,只是推动顶部。ACK 处理逻辑里要区分三种情况:过期 ACK(序号小于 SND.UNA)、重复 ACK(等于 SND.UNA)、有效 ACK(大于 SND.UNA)。
def on_ack(self, ack, ack_flags=0): if ack < self.snd_una: return # 已过期,直接忽略 if ack == self.snd_una: self.dup_ack[ack] = self.dup_ack.get(ack, 0) + 1 if self.dup_ack[ack] == 3: # 连续 3 次重复 ACK 触发快重传 self._send(self.snd_una, self.outbox[self.snd_una]) return # 有效 ACK:窗口底部一次性滑到 ack self.snd_una = ack for seq in list(self.outbox): if seq < ack: del self.outbox[seq] self.deadline = min(self.outbox) + self.rto if self.outbox else None # 窗口腾出空间,立刻继续填充 self.fill_window()这里值得细看两点。第一,处理的是“累计确认”:ACK = 1000 意味着 1000 之前的所有字节都收到了,不管它们是否乱序到达。所以删除 outbox 里的旧段时不能用 for seq in self.outbox 边遍历边删,必须先用 list() 快照;否则 Python 运行时会在迭代中抱怨字典变了尺寸。第二,窗口底部右移后要立刻调用 fill_window(),把空出来的窗口位置补上——这是滑动窗口维持管道满流的根本动作。错过这一步,吞吐曲线会掉得很难看,而且只用日志不好追,只能从时序上发现“收到 ACK 之后没有马上发包”。
3.3 超时重传:定时器只需要一个,但重传对象只有一个
多数模拟器第一次写超时逻辑时,都会在定时器回调里遍历整个 outbox 把所有段重发一遍,理由是“这样最保险”。这正是重传风暴的起点。真实 TCP 的超时重传语义是:RTO 到期时,重传最早未确认的那一个段,也就是 SND.UNA 对应的那一段,而不是把所有在途数据喷出去。
def on_timer_expiry(self): if not self.outbox: return seq = min(self.outbox) # 最早的未确认段 self._send(seq, self.outbox[seq]) # 更新 deadline:只针对队头段重新计时 self.deadline = time.time() + self.rto为什么只重传一个?因为 TCP 的确认语义是累计的:队头段一旦到达,接收方返回的 ACK 会直接把窗口底部推到它之后,后面那些段即使丢了或被延迟,发送方也能通过后续 ACK 的推进感知到。如果一次性把整个窗口重传,等于把网络负载瞬间放大窗口倍,本来只是轻微拥塞的链路会被你自己压垮。模拟实验里设置 1% 丢包率、窗口 64 段时,全量重传会把重传负载放大到接近 64%,吞吐率直接崩到接近零,而且这个故障从概率上很难定位成“协议逻辑写错”还是“链路模型不对”。
定时器的管理也有取舍:每个段独立定时器实现直观,但 TCP 协议栈不会为每个包各开一个定时器,而是在发送控制块里维护最早到期时间。模拟器用一个全局 deadline 变量就足够了,它就是“最早的未确认段的超时时刻”。RTO 的初始值建议设成 2 倍实测 RTT,并在实验里固定下来;先不要上 Karn 算法和自适应 RTO,把固定值下的协议行为跑准,再谈动态调整。
提示:RTO 如果设得比实际 RTT 还小,会产生非丢包型重传——ACK 还在路上你就重发了,而且每次重传都重置计时器,造成“RTT 虚高、重传不停”的假象。这是模拟器刚上手时最容易忽视的配置项。
4. 为什么用 UDP socket 承载模拟:把可靠传输从内核手里拿回来
4.1 用 TCP socket 承载模拟,得到的是伪模拟
TCP socket 的可怕之处在于它太完整了。当你用 send() 把数据交给 TCP socket,内核协议栈会负责分段、序号分配、重传定时、拥塞窗口更新、接收端乱序重组。你的用户态代码根本碰不到这些路径,能看到的只是 send() 是否阻塞、recv() 返回了什么。在这个前提下再写一套“滑动窗口”逻辑,等于让两个协议栈同时工作:内核窗口在你的窗口之上叠了一层,最终生效的永远是更紧的那一个,而你观察到的现象无法归因到任何一层。
用 UDP socket 承载模拟,情况完全不同:sendto 的数据报被原样丢给网络,不保证顺序、不保证送达、更没有任何内核对它做可靠性处理。你定义的每一个判断、每一次重传,都是自己写出来的逻辑真实执行了。UDP 数据报还有另一个优点:每次 sendto 与 recvfrom 的天然边界就是一个“段”,省去了在字节流上自行切分段的功夫。把 UDP 当作一条不可靠的包管道,在上面堆叠你自己的窗口状态机,这才是模拟协议栈的正确姿势。
4.2 自定义 PDU:在数据报前加一个 9 字节头
UDP 只管把数据报从 A 搬到 B,不负责携带序号。所以模拟协议的第一步是给每个数据报设计一个头部,把 seq、payload 长度、标志位装进去。参照 TCP 头的最小语义,我习惯用 9 字节固定头:4 字节序号、4 字节负载长度、1 字节标志位。
import struct FLAG_ACK = 0x04 FLAG_SYN = 0x02 FLAG_FIN = 0x01 def pack_pkt(seq, data, flags=0): # 大端序:序号占 4 字节,长度占 4 字节,标志占 1 字节 return struct.pack("!IIB", seq & 0xFFFFFFFF, len(data), flags) + data def unpack_pkt(pkt): header = struct.unpack("!IIB", pkt[:9]) seq, length, flags = header return seq, pkt[9:9 + length], flags这个头部的设计动机是:让数据报文和纯 ACK 报文共用同一套打包/拆包代码。数据报文里可以捎带 ACK 字段,ACK 报文的 seq 字段则可以携带接收方期望的下一字节序号——这与 TCP 头里“确认号”和“序号”分列的思路一致。如果日后想扩展 SACK 选项,只需要在头部之后追加可变长度的选项区,不影响现有字段的解析。
4.3 链路模拟器:丢包、乱序、延迟都注入在中间层
滑动窗口实验必须能控制网络行为:丢包率、延迟抖动、ACK 滞后。这些都不能靠“把两台机器放到不同网段”来近似,因为不可控。最稳妥的方案是在发送端和接收端之间插一个转发层,由它决定每个包什么时候到、会不会到。
import random, time, heapq class LinkEmulator: def __init__(self, loss_rate=0.01, delay_jitter=(0.01, 0.05)): self.loss_rate = loss_rate self.jitter = delay_jitter self.queue = [] def forward(self, pkt): if random.random() < self.loss_rate: return # 直接丢弃,模拟丢包 delay = random.uniform(*self.jitter) heapq.heappush(self.queue, (time.time() + delay, pkt)) def pump(self): now = time.time() while self.queue and self.queue[0][0] <= now: _, pkt = heapq.heappop(self.queue) self.sock.sendto(pkt, TARGET_ADDR)丢包逻辑没什么可说的,但乱序要单独讲:乱序不是靠“交换两个包的位置”生成的,而是靠延迟抖动自然涌现的。先发的包如果被分配到一个更大的延迟,后发的包就会先到达接收端,形成乱序。数值上只要抖动范围大于“包间发送间隔”,乱序就会稳定出现。这个设计思路贴近真实网络:IP 网络本身不保证排队时延一致,抖动就是乱序的来源。
ACK 滞后也有专门的注入方式:让链路模拟器在识别到 ACK 报文后,额外 sleep 一段时间再转发。这样可以在不丢包的情况下制造“超时”现象,迫使发送方进入无谓重传——这是验证 RTO 设置和重传抑制逻辑最有用的故障注入手段。
4.4 接收方状态机:乱序缓存与接收窗口通告
接收窗口是滑动窗口协议的另一半,很多模拟器只做发送方,接收方直接用 recvfrom 然后把数据交给上层,导致乱序段到达后直接被丢掉。真正的接收方必须维护 RCV.NXT 和一个乱序缓存。
class Receiver: def __init__(self, sock, rcv_buf_size): self.sock = sock self.rcv_nxt = 0 # 期望接收的下一个字节号 self.rcv_buf_size = rcv_buf_size self.leftover = {} # seq -> data,乱序段缓存 def on_data(self, seq, data): if seq == self.rcv_nxt: self.rcv_nxt += len(data) # 按序到达,推进期望号 # 消费缓存里连续的数据,把空洞补上 while self.rcv_nxt in self.leftover: chunk = self.leftover.pop(self.rcv_nxt) self.rcv_nxt += len(chunk) elif seq > self.rcv_nxt: self.leftover[seq] = data # 乱序段,先进缓存 # 窗口通告 = 缓冲区剩余量 window = self.rcv_buf_size - (self.rcv_nxt - self.delivered_upto) self.send_ack(self.rcv_nxt, window)这段逻辑里最关键的是窗口通告的算法:接收方通告的 rwnd 是“缓冲区剩余量”,不是固定值。如果接收方上层消费数据的速度跟不上,剩余量变小,发送方的窗口会被压缩,这就是流量控制的直接体现。模拟器里如果不模拟上层消费速率,窗口通告就永远不变,等于把接收缓存当成无限大,也就看不见“窗口逐渐缩到零”的真实行为。想复现著名的“零窗口”状态,只需要把上层消费速率设为 0,接收缓冲区被填满后,发送方会停在小窗口下,反复用坚持定时器探测——这一步跑通了,滑动窗口的流控语义才是完整的。
注意:链路模拟器和接收端在同一台机器时,内核的 UDP 接收缓冲区会先于你的应用层代码接收数据。如果 SO_RCVBUF 太小,报文在内核缓冲溢出时被静默丢弃,你会观察到“丢包率比设定值高得多”的幻觉。动手实验前先把 socket 的 SO_RCVBUF 调到 2MB 以上,让丢包只来自你自己的链路模拟器。
5. 模拟器避坑:五个最容易翻车的细节
5.1 坑一:TCP socket 上叠窗口,调参调到怀疑人生
现象:用 TCP 连接承载模拟,窗口参数无论怎么调,吞吐曲线都纹丝不动。偶尔窗口调大了,流量上去了,但曲线形状跟协议理论完全对不上。
原因:内核的窗口控制和拥塞控制接管了所有发送节奏。你写的 fill_window 也许执行了,但 send() 返回只是把数据放进内核缓冲,真正控制发送节奏的是内核的拥塞窗口和接收通告窗口。你在应用层改的“窗口”根本没进发送路径。
解决:换用 UDP socket,让自定义 PDU 直接进入网络。内核不再有任何可靠性机制介入,发送与否完全由应用层窗口状态机决定。
5.2 坑二:接收方只 recv 不缓存,乱序变丢包
现象:打开乱序模拟后,吞吐率骤降,重传次数成倍上升。看日志发现,接收方动不动就收到“seq 落后于 RCV.NXT”的报文。
原因:接收方代码直接把 recvfrom 的数据交给上层,没有乱序缓存。乱序段到达时,接收方误以为是重复数据直接丢弃,接收窗口永远停在空洞处;发送方就只能靠超时一遍遍重传。
解决:补上接收状态机——RCV.NXT 加一个 leftover 字典。只有 seq 严格等于 RCV.NXT 的数据才立刻交付;seq 大于它的一律先进缓存,等空洞补上后再连续上抛。
5.3 坑三:超时事件把整个窗口全量重发
现象:设置 1% 丢包率,日志里每次超时后同一个 seq 区间被连续发出十几份,网络负载暴涨,吞吐率跌到接近零。
原因:on_timer_expiry 里写了类似 for seq in outbox: send(outbox[seq]) 的逻辑。发送方把一个包的丢失放大了整个窗口的重传成本,等价于每次丢包都把链路打崩一次。
解决:超时只重传最早的未确认段,干等后续 ACK 的累计推进。如果要做选择性重传(SR)版本,改为每个段维护独立的定时器,并按段单独重传;GBN 和 SR 两种模式应该在模拟器里做成可配置选项而不是临时改代码。
5.4 坑四:序号回绕之后,比较大小全部失效
现象:实验跑到一半,发送方收到 ACK 后不仅没滑动窗口,反而报“ack 小于 SND.UNA”,随后开始疯狂重发。重启程序后前半小时一切正常,越到后来越乱。
原因:Python int 无上限,模拟器里 seq 一旦超过 0xFFFFFFFF 还继续增长,接收方根据真实 TCP 头里的 32 位序号解析时,回绕后的数字在数值上看起来变小了,被当成过期 ACK 丢弃。
解决:比较序号时要考虑回绕。常用的判据是模 2^32 差值:
def seq_after(a, b): # 返回 a 是否在 b 之后,按 TCP 序号回绕规则 return ((a - b) % (1 << 32)) < (1 << 31)只要窗口远小于 2^31 字节,这个判据就是可靠的。发送方发出的序号统一做 & 0xFFFFFFFF,比较时用 seq_after 而不是 >,窗口滑动逻辑在长时间运行下才不会错位。
5.5 坑五:链路模拟器没插进数据路径,丢包参数形同虚设
现象:loss_rate 设成 0.2,跑了一夜一个重传都没发生,日志显示所有数据一次送达。
原因:链路模拟器确实写了,但代码路径上发送端直接 sendto 给了接收端,转发逻辑根本没有被执行。或者发送端和接收端在同一进程里直接通过函数调用交换报文,网络栈完全被绕开。
解决:把发送端、接收端、链路模拟器拆成三个独立 socket 端点,发送端只和链路模拟器通信,链路模拟器再和接收端通信。只要报文必须经过转发层,丢包和延迟就一定会生效。用进程内线程模拟也要保证数据走的是 socket 收发,而不是函数调用的内存传递。
写模拟器这件事,五个坑里有四个是“协议语义理解偏差”,只有一个是环境问题。排错的顺序也应该是:先确认数据路径上有没有多余的内核机制在代劳,再确认事件处理里有没有把累计确认和全量重传搞混。
6. 设计取舍:模拟器里哪些“假”值得做
滑动窗口模拟器本质上是在一个可控环境里复刻 TCP 的发送/接收状态机,但“复刻到多真”是一个值得想清楚的设计决策。做得太真,一台机器上同时维护快重传、SACK、WSCALE 协商,实验门槛会急剧升高;做得太假,通过吞吐曲线得出的结论可能误导你对协议的理解。我倾向把取舍做成显式开关:
| 特性 | 完整实现 | 简化实现 | 对实验的影响 |
|---|---|---|---|
| 窗口单位 | 字节,支持半包窗口 | 段数 | 前者更接近真实 TCP,后者画曲线直观 |
| 快速重传 | 连续 3 个重复 ACK 触发 | 只靠超时 | 不实现时吞吐对丢包反应迟缓 |
| 乱序确认 | SACK 选项 | 累计 ACK | 累计 ACK 下头部阻塞现象更明显 |
| RTO 更新 | Karn 算法 + 自适应 | 固定 RTO | 固定值适合验证,自适应更接近真实 |
一个合理的折中方案是:默认用“字节窗口 + 固定 RTO + 累计 ACK”,把快速重传做成配置项。累计 ACK 下,一个乱序段会把接收窗口的头部挡住,后续所有按序到达的数据都无法确认,发送方只能等超时——这是理解 TCP 头部阻塞最基本的实验素材;跑通它之后再把快重传打开,对比吞吐曲线的差异,立刻就能体会到为什么要引入快重传。而 WSCALE 这种涉及连接协商的机制,我建议只在做“大窗口”专项实验时再加入。
验证模拟器有没有写坏,我的做法是固定一组参数跑回归:RTT、MSS、窗口 W,理论吞吐上界是 W / RTT。把实测吞吐除以这个上界,如果利用率稳定在 90% 以下,优先怀疑状态机逻辑而不是链路丢包——因为丢包率只有 0.1% 时,一个正确的滑动窗口实现不应该让吞吐掉这么多。这个比率能过滤掉绝大多数“看似正常但实际协议逻辑有 bug”的版本。从那以后,我每改一次窗口处理逻辑,都强制把回归脚本完整跑一遍,再手动构造一次丢包场景看日志里的 SND.UNA 有没有按预期推进。这个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取