☰
滑动窗口协议:原理、socket模拟与重传机制解析
2026/10/18 12:06:57 网站建设 项目流程

简介:面向计算机网络课程学习者,这份代码项目通过socket编程模拟数据链路层的滑动窗口协议,完整实现了1bit滑动窗口(停-等协议)的发送与接收流程,有助于理解TCP连接建立后的可靠传输机制,以及数据帧序号、确认帧、超时重传等关键概念。资源包内共有2个C语言源文件,一个为发送端(SlideWindowSender),另一个为接收端(SlideWindowReceiver),整体压缩包仅4KB,结构极为精简,适合阅读源码、本地编译并实际运行观察协议交互过程。读者可以对照代码研究滑动窗口状态变化、ACK帧校验与错误处理逻辑,也可将此作为网络课程设计或实验报告的参考蓝本。目前已有1308人学习浏览,资源作者为xuyilai,适合有一定C语言和socket基础的初学者深入练习。整体上,这是一个小而精的实践示例,能帮助学习者把课本中的抽象协议快速转化为可运行的代码,并在此基础上进一步扩展算法或加入差错控制。

1. 滑动窗口协议:为什么只是把「等确认」改成「不等确认」,吞吐能差出几个数量级

先给一个反直觉的结论:在一条带宽 100 Mbps、往返时延 100 ms 的链路上,如果用最朴素的停等协议(发一个包、等确认、再发下一个),实际吞吐率只有可怜的 8% 左右。假设每个报文 1 KB,一个 RTT 里链路能装下 1.25 MB 数据,而停等协议只发出去 1 KB 就停手等 ACK——链路绝大部分时间是饿着的。滑动窗口协议做的事,本质上是把「发一个等一个」的串行流水线,改成「窗口内一口气发一批、边发边收 ACK 边补位」的并行流水线。窗口大小就是这条流水线的深度,它直接决定了链路利用率。为什么 TCP 要把这个机制设计得如此复杂、为什么重传策略要分好几个流派、为什么用 socket 模拟时有一堆坑等着你踩——这些问题的答案全藏在对「窗口」二字的理解里。这篇文章就从窗口的物理含义讲起,一路拆到 socket 源码级的实现细节。

2. 窗口机制:序号的推进与 GBN / SR 两种重传哲学

2.1 带宽-延迟积决定你该开多大的窗口

要理解窗口为什么存在,先得算一笔账。链路里最多能同时容纳多少数据,取决于带宽和往返时延的乘积,也就是常说的带宽-延迟积(BDP)。一条链路的 RTT 是 100 ms,带宽 100 Mbps,那么 RTT 内链路能承载 100 Mbps × 0.1 s = 12.5 Mb ≈ 1.56 MB 的数据。也就是说,发送方如果想做到不浪费链路,就必须保证「已发出但未确认」的数据量至少达到 1.56 MB,否则链路上就会出现空隙。

窗口大小就是「允许在途的数据量上限」。停等协议的窗口是 1,BDP 再大它也只用 1 KB 去撞 1.56 MB 的管道,空置率可想而知。而滑动窗口把窗口开到 W 个报文后,链路利用率可以近似写成 U ≈ W × MSS / (BDP + MSS),当 W × MSS 接近 BDP 时利用率逼近 100%。这解释了协议设计中最重要的直觉:窗口不是随意定的参数,它的下限由物理链路决定。

但窗口不能无限大。窗口占用的序号空间有限,接收方要靠序号去重、排序,序号一旦回绕就会产生歧义。后面第 4 章会展开讲为什么窗口大小必须小于序号空间的一半,这里先记住:窗口的下限由链路物理参数决定,上限由序号位数决定,协议设计的张力就是在这个区间里产生的。

2.2 累积确认与回退 N:GBN 的简单和它的重传风暴

最早期的窗口协议是 Go-Back-N(回退 N)。它的规则极其对称:发送方窗口大于 1,接收方窗口恒等于 1。接收方只接受「恰好是下一个期望序号」的包,一旦收到乱序包就直接丢弃,并重新发送一次对最后一个有序包的 ACK。发送方则采用累积确认语义——收到 ACK(n) 表示 0 到 n 号包全部确认,窗口基址直接推进到 n+1。超时后,发送方从最早那个未确认的包开始,把后面的包全部重发一遍。

为什么 GBN 能成为教材第一个教的模型?因为它把状态压缩到极致。发送方只需要维护一个定时器,管住窗口基址那个最老的包,因为一旦它超时,后面所有包都跟着重传,给每个包单独计时没有意义。接收方连缓存都不需要,丢包后的行为简单到可以写进硬件——「不是我要的,扔掉」。

这套机制的代价在丢包率稍微升高时立刻暴露。假设窗口是 16,第 3 号包丢了,接收方会把 4 到 16 号全部当乱序包丢弃,发送方超时后从 3 号重传到 16 号。一个包的失误,让 13 个本来已经正确到达的包全部白传一遍。这就是重传风暴:链路越挤,丢包越多,重传越多,链路更挤。GBN 适合极低误码率的有线链路,放到丢包率 1% 以上的无线环境里基本是灾难。

2.3 选择重传:SR 用缓存和每包定时器换吞吐

Selective Repeat(选择重传)的出发点是:既然重传风暴的根源在于「乱序即丢弃」,那接收方就把乱序到达的包先缓存起来。接收窗口跟着变大,能接收序号落在 [expected, expected + W) 范围内的任意包,并针对每个收到的包单独回 ACK。发送方为每个在途包维护独立定时器,超时只重传那一个,窗口内其他包正常滑动。

代价是状态复杂度的翻倍。接收方要维护一个位图或缓存数组标记「哪些序号已经收到」,收到重复 ACK 时发送方要能识别「这是对旧包的重申,不是窗口推进」;每个包独立计时意味着定时器数组的管理。更重要的是序号空间约束被收紧:GBN 要求窗口 W ≤ 2^n − 1,SR 直接要求 W ≤ 2^(n−1)。原因在于接收方缓存了乱序包后,发送方收到的 ACK 携带的是「绝对序号」而非「累积位置」,窗口上半区和下半区的歧义空间被压缩了一半。这个约束后面还会从代码层面再验证一次。

一个常被忽略的事实:现实中的 TCP 不是 GBN 也不是 SR,而是两者的混合——累积 ACK(GBN 的确认语义) + 接收方缓存乱序包(SR 的接收行为) + 选择重传(现代 TCP 的 SACK 选项)。教材把它们拆成两个纯模型,是为了把「确认机制」和「重传粒度」两个维度拆开演示。你自己用 socket 模拟时,最好先按纯 GBN 或纯 SR 实现一个,再往 TCP 的混合形态靠拢。

3. socket 模拟的架构:为什么用 UDP 承载、状态机怎么搭

3.1 为什么不用 TCP 自模拟:可靠层上叠可靠层会变成玄学

很多第一次做滑动窗口模拟的人第一反应是:用 TCP socket 连接,然后往发送缓冲区里「慢慢塞数据」来模拟窗口。这是最常见的误区。TCP 协议栈本身就是一个庞大的滑动窗口实现——它有自己的窗口管理、超时重传、乱序缓存和拥塞控制。你在 TCP 之上再模拟一层滑动窗口,等于在两个可靠层之间较劲:你故意不发数据,但不知道是哪一层的重传在起作用;你观察到的 RTT 和丢包,混合了内核协议栈的算法和你自己的算法,根本无法归因。这种实验做出来的结果,基本靠玄学解释。

正确姿势是用 UDP socket 承载数据,UDP 只负责「尽力而为地收发报文」,不保证顺序、不保证可靠、没有窗口、没有重传。它是一张白纸,刚好扮演物理信道的角色。你要做的就是在 UDP 之上,用自己的代码把滑动窗口协议完整实现一遍——发送窗口怎么滑、接收窗口怎么收、超时怎么判、重传怎么发,全部由你掌控。这样每条日志都能对应到你写的某一行判断逻辑,出问题才查得清。

模拟丢包和乱序也很干净:UDP 收到的每个包,在你自己的代码里过一道「损坏注入器」,按概率丢弃或延迟重排。协议栈层面的东西完全绕开,你面对的就是一个原始信道加一套自己的可靠传输实现。

3.2 两端状态机:发送窗口、接收窗口与事件循环

整个模拟器的主体是两套状态机加一个事件循环。发送端状态机维护三个量:窗口基址 base、下一个待发序号 next、窗口上限 base + W。每个事件要么是「发送事件」(next 还在窗口内,把包发出去),要么是「ACK 事件」(收到有效 ACK,推进 base),要么是「超时事件」(窗口内某个包超时,触发重传)。接收端状态机则维护期望序号 expected 和一张乱序缓存表。

事件循环用 select 一个函数就能统管三类事件:把 socket 文件描述符放进读集合,超时时间设为「最近要超时的那包还剩多久」。select 返回正数说明收到了 ACK,返回 0 说明有包超时。这个结构的简洁性值得反复体会:

while (total_sent + window_inflight < total_pkts || base < total_pkts) { /* 1. 填充发送窗口:在窗口内且未发送的序号全部发出 */ while (next < base + win_size && next < total_pkts) { send_pkt(next); /* 组包、塞序号、算校验、sendto */ if (timer[next % MAX_SEQ] == 0) /* 首次发送才记时间,重传不重置 */ timer[next % MAX_SEQ] = now_ms(); next++; } /* 2. 等待事件:socket 可读或超时 */ struct timeval tv = {0, timeout_ms(next, base, timer)}; FD_ZERO(&rset); FD_SET(sockfd, &rset); int ret = select(sockfd + 1, &rset, NULL, NULL, &tv); if (ret > 0) { /* 有 ACK 到达 */ recv_ack(&ack); if (ack_inside_window(ack.seq, base, next)) { base = ack.seq + 1; /* 累积确认:推进基址 */ clear_timer_between(base, ack.seq); } } else if (ret == 0) { /* 超时,找出最老的逾期包重传 */ p = oldest_expired(base, next, timer); if (p >= 0) { send_pkt(p); timer[p % MAX_SEQ] = now_ms(); /* 注意:是重置不是清空 */ } } }

这套结构里最揪细节的地方有两处。一是重传后定时器要重新计时而不是清空,否则这个包永远不会有下一次超时;二是 select 的超时值不能随手填个固定值,应该动态计算窗口中所有在途包的最小剩余超时时间,否则每个循环周期都固定等 200 ms,整个发送节奏会被定时器粒度拖慢。

3.3 模拟丢包与乱序的参数设计

链路模拟器夹在收发双方之间,常见的实现是把它做在接收方一侧:收到 UDP 包后,先按概率判定是否丢包,再决定是否把它放入延时队列。丢包概率 p 是影响实验最明显的旋钮,p = 0.01 时 GBN 还能勉强运行,p = 0.1 时 GBN 的重传风暴会让吞吐率塌方,而 SR 还能稳住。乱序模拟则靠「随机延迟」实现:每个到达的包被赋予一个随机延迟,延迟值小的先发,延迟值大的后发,天然产生乱序。

参数建议从以下区间起步:

参数建议初值说明
丢包率 p0.01 ~ 0.05超过 0.1 后 GBN 基本不可用,适合演示重传风暴
基础延迟10 ~ 100 ms控制 RTT 量级,影响带宽-延迟积和窗口需求
延迟抖动0 ~ 20 ms抖动量超过基础延迟时会出现严重乱序
是否丢 ACK是 / 否丢 ACK 时观察重复 ACK 与超时互救
窗口大小4 ~ 16 包对比不同窗口下吞吐率和利用率曲线

还有一类容易出问题的场景是「只丢数据不丢 ACK」。你如果只在数据接收端抛硬币决定丢不丢包,而 ACK 永远无损到达,那么发送方几乎总能靠累积确认把丢掉的包「带过去」,重传机制永远不被触发,实验看起来一切正常,实际上你这个模型缺了半条腿。正确做法是数据包和 ACK 分别走两条不同的丢弃逻辑,各自独立按概率丢。

4. 源码级细节:窗口结构体、定时器与序号回绕

4.1 发送窗口的三指针模型

滑动窗口落到代码上,就是三个序号变量加一个缓冲区:

#define MAX_SEQ 256 /* 序号空间:0 ~ 255 */ #define WSIZE 16 /* 窗口大小 W,必须小于 MAX_SEQ/2 */ typedef struct { unsigned char base; /* 窗口基址:最老的未确认序号 */ unsigned char next; /* 下一个待发送序号 */ unsigned char size; /* 窗口大小,扩窗时参与边界判断 */ unsigned char timer[MAX_SEQ]; unsigned char acked[MAX_SEQ]; /* 每个序号的确认标记 */ } SndWindow;

滑动规则用三个表达式就能写全:next < base + WSIZE 表示还能发新包(窗口没满),base ≤ ack_seq < base + WSIZE 表示这个 ACK 合法且能推进窗口,next 落后于 base 时窗口内已无在途包。而窗口滑动本质上就是更新 base 并清理 base 之前序号的定时器和确认标记。教科书上「窗口向前滑动」的动画,代码里就是base = ack_seq + 1然后对旧序号做一次回收。

这里要特别小心「窗口满」的判定。如果 base = 250,next = 254,窗口满了吗?按无符号字节运算,250 + 16 = 266,溢出成 10,254 < 10为假,窗口被误判为已满。这正是下一节要解决的序号回绕问题——用字节算术直接比较就会在这里翻车。

4.2 超时重传:RTO 估算与定时器选择

定时器有两个设计维度:粗粒度还是细粒度,单定时器还是每包定时器。GBN 用单定时器就够了——最早的包超时,后面全部重传;SR 必须每包一个定时器。模拟器里通常用每包一个定时器的方式,因为它能同时模拟两种协议的调度逻辑,只需在重传遍历时决定「重传所有」还是「重传一个」。

RTO(重传超时时间)的取值是模拟器里最容易翻车的参数。固定 RTO 看起来省事,但网络延迟稍有抖动就会出问题:RTO 设小了,ACK 还在路上就被判定超时,触发一堆不必要的重传;RTO 设大了,真丢包后链路白白空等。模拟器里推荐的实现是让它随 RTT 动态走:

static double rtt, rtt_dev, rto; double sample_rtt(double measured) { double alpha = 0.125, beta = 0.25; rtt = (1 - alpha) * rtt + alpha * measured; /* 平滑 RTT */ rtt_dev = (1 - beta) * rtt_dev + beta * fabs(measured - rtt); rto = rtt + 4 * rtt_dev; /* 近似内核对 RTO 的估法 */ return rto; }

这段逻辑来源于 TCP 协议里经典的 RTT 估算——平滑均值加 4 倍平均偏差,目的是把网络抖动折算进超时值里。第一次给 RTO 赋初值时,建议取一个明显偏大的值(比如当前基础延迟的 3 倍),让连接先跑起来,再靠采样收敛。还有一条容易被忽略的原则:重传后不要立刻把这个包的下一次超时窗口设成完整 RTO,而要按指数退避放大(比如 ×2)——否则网络拥塞时你会把超时重传变成发疯式轰炸。这个细节模拟器不实现的话,高丢包率下会观察到吞吐率的剧烈抖动。

4.3 序号回绕:为什么要做减法而不是比较

序号回绕是模拟器里最隐蔽的坑。MAX_SEQ = 256 时,base 从 255 推进到 0,序号重新轮回。如果你写if (seq > base && seq < base + WSIZE)来判断 ACK 是否在窗口内,一旦 base 接近 255,这个比较就会彻底失效。协议实现里的标准解法是「做减法而非比大小」:

/* 判断 ack 是否落在 [base, base+WSIZE) 区间内,正确处理序号回绕 */ int seq_lt(unsigned char a, unsigned char b) { return (int)(signed char)(a - b) < 0; } int ack_in_window(unsigned char ack, unsigned char base, int win) { return seq_lt(base, ack) && seq_lt(ack, base + win); }

核心思路:把两个序号相减的结果强制转成 signed char,再判断正负。因为序号回绕的本质是模 256 运算,两个序号的差映射到 [-128, 127] 的有符号区间,符号就代表了前后的相对次序。这也解释了为什么窗口大小必须满足 W ≤ 2^(n−1):只有当窗口不超过序号空间一半时,「窗口内任意两个序号的差」都小于 128,才能保证减法判定的唯一性。如果 W = 200,序号 10 和 150 的差 140 超出了 127 的表示范围,判定结果直接歧义——接收方无法分辨 150 到底在 10 的前面还是后面。这个数学约束不是课本上闲着没事推的,是你写代码时 128 这个魔法数字的来历。

5. 模拟器避坑:丢包参数、缓冲区上限与常见翻车现场

5.1 丢包率调太高:实验跑成了拥塞崩溃

现象:把丢包率调到 0.3 以上,SR 的吞吐率也会断崖式下跌,曲线剧烈抖动,协议看起来「怎么改都不对」。原因:丢包率超过一定阈值后,网络已经进入拥塞崩溃区——大量重传抢占通道,新包和重传包互相挤压,任何窗口协议都救不回来。解决:模拟器主要验证的是窗口机制本身,丢包率控制在 0.01~0.1 之间更有意义。想演示「高丢包下协议失效」也可以,但那属于拥塞控制的研究范畴,别拿它来调试窗口逻辑,否则所有 bug 都会被噪声掩盖。

5.2 系统缓冲区耗尽:真实的丢包混进模拟数据

现象:窗口开到 64,发送速度上去了,但接收端日志里出现大量「校验失败」或「莫名丢失」的包,且这些包跟你的丢包概率对不上。原因:UDP 收发两端的内核缓冲区有默认上限(通常几十 KB),发送太快时 UDP 层直接静默丢包,这种丢包发生在你的模拟逻辑之前。

解决:用 setsockopt 把收发缓冲区调大:

int rcvbuf = 1 << 20; /* 1 MB */ setsockopt(recv_fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(int));

同时让接收端及时从 socket 读走数据,别让数据积压在内核里。这个坑的阴险之处在于:它制造的丢包模式是「成串的、非随机的」,和模拟器里按概率注入的均匀丢包完全不同,很容易让你误判成协议逻辑错误,白查半天。

5.3 固定 RTO 在延迟抖动下的重传风暴

现象:基础延迟 50 ms,抖动 40 ms,固定 RTO = 100 ms,明明没有丢包,日志里却满是重传记录。原因:RTT 上下波动范围超过固定 RTO 的裕量,延迟大的包被判超时,重传包和原包先后到达,接收方收到重复包后回重复 ACK,发送方又误以为丢包,产生雪崩。解决:RTO 改成上一章的动态估算,初值取大,观察稳定后再缩小。模拟器里固定 RTO 只适合延迟完全不变的环境;只要加抖动参数,就用rtt + 4 × rtt_dev。

5.4 只丢数据不丢 ACK:重传机制永远不触发

现象:把丢包率设为 5%,日志里却几乎没有超时事件,吞吐率还接近无损状态,实验「顺利」得可疑。原因:ACK 没有被注入丢包。数据包丢了,但接收方对后续数据包回的累积 ACK 会把丢掉的序号「顺带确认」掉,发送方永远察觉不到那个包的损失,窗口照滑。解决:丢包逻辑要同时在两条路径上生效——数据包按概率丢,ACK 按另一个概率丢(建议 ACK 丢包率设为数据包的一半)。这样才能看到完整的协议行为:丢 ACK 触发重复 ACK,进而触发快速重传或超时。

5.5 重复 ACK 推进基址时的边界错误

现象:接收方偶尔收到乱序包并缓存(SR 模式),发送方收到指向旧序号的 ACK 后,窗口基址错误地回退,日志里出现「ACK 推进负值」之类的异常。原因:实现中出现base = ack_seq + 1时没有先判断ack_seq是否大于等于base。重复 ACK 的序号小于当前 base,被无条件套进累积确认的推进逻辑里,基址就被「拉回去」了。解决:所有 ACK 处理前先做窗口包含性判定,ack < base一律忽略,只管刷新对应包的确认标记,绝不修改 base。这条边界逻辑在 GBN 里几乎不会触发,但一旦加上 SR 缓存就会频繁露馅,是两种重传模型切换时最常见的翻车点。

6. 设计取舍:模拟器的窗口开多大,真实 TCP 为什么「半滑半等」

把模拟器调到能满带宽跑之后,你会面临两个牵扯着玩的参数:窗口大小和序号空间。窗口开满 BDP 时链路利用率逼近 100%,但代价是序号回绕风险、接收缓存成本和整套计时复杂度倍增。模拟器里你可以自由地开 64、开 128,因为信道是你模拟的、干净得像理想世界;但真实链路不是这样——带宽波动、多流竞争、路由器缓存溢出,这些因素让「发满 BDP」变成一种危险行为。

真实 TCP 为此做了三处设计上的妥协,你在模拟器里可以逐一去延伸验证。第一,拥塞窗口与接收窗口取最小值作为发送窗口:前者管「网络受不受得了」,后者管「接收方来不来得及收」,相当于在理想窗口外面又套了一层「限流阀」。第二,用重复 ACK 触发快速重传替代傻等超时:连续收到 3 个相同 ACK 就认为是丢包,不用等 RTO 过期,这是对超时机制的一次优化——但注意,它本质上还是 GBN 的累积确认加上 SR 的缓存能力,验证了教材模型的混合价值。第三,SACK 选项把「哪些段丢了」显式报告给发送方,实现真正的选择性重传——这一步把模拟器里手写的乱序缓存表,直接搬进了协议栈。

我自己的习惯是,每改一次参数就把「丢包率、RTO 初值、缓冲区大小」三个值写进日志文件名,再对照吞吐率曲线判断归属。否则实验数据出现异常时,你分不清是参数调得离谱、还是窗口逻辑有 bug,往往白折腾。从那以后我每次启动模拟器都强制走一遍:清空日志、三项参数先记录、小窗口跑通基线、再逐项加码。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询