恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
滑动窗口协议:原理、socket模拟与重传机制解析
首页
资讯中心
/
滑动窗口协议:原理、socket模拟与重传机制解析
滑动窗口协议:原理、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否则链路上就会出现空隙。窗口大小就是「允许在途的数据量上限」。停等协议的窗口是 1BDP 再大它也只用 1 KB 去撞 1.56 MB 的管道空置率可想而知。而滑动窗口把窗口开到 W 个报文后链路利用率可以近似写成 U ≈ W × MSS / (BDP MSS)当 W × MSS 接近 BDP 时利用率逼近 100%。这解释了协议设计中最重要的直觉窗口不是随意定的参数它的下限由物理链路决定。但窗口不能无限大。窗口占用的序号空间有限接收方要靠序号去重、排序序号一旦回绕就会产生歧义。后面第 4 章会展开讲为什么窗口大小必须小于序号空间的一半这里先记住窗口的下限由链路物理参数决定上限由序号位数决定协议设计的张力就是在这个区间里产生的。2.2 累积确认与回退 NGBN 的简单和它的重传风暴最早期的窗口协议是 Go-Back-N回退 N。它的规则极其对称发送方窗口大于 1接收方窗口恒等于 1。接收方只接受「恰好是下一个期望序号」的包一旦收到乱序包就直接丢弃并重新发送一次对最后一个有序包的 ACK。发送方则采用累积确认语义——收到 ACK(n) 表示 0 到 n 号包全部确认窗口基址直接推进到 n1。超时后发送方从最早那个未确认的包开始把后面的包全部重发一遍。为什么 GBN 能成为教材第一个教的模型因为它把状态压缩到极致。发送方只需要维护一个定时器管住窗口基址那个最老的包因为一旦它超时后面所有包都跟着重传给每个包单独计时没有意义。接收方连缓存都不需要丢包后的行为简单到可以写进硬件——「不是我要的扔掉」。这套机制的代价在丢包率稍微升高时立刻暴露。假设窗口是 16第 3 号包丢了接收方会把 4 到 16 号全部当乱序包丢弃发送方超时后从 3 号重传到 16 号。一个包的失误让 13 个本来已经正确到达的包全部白传一遍。这就是重传风暴链路越挤丢包越多重传越多链路更挤。GBN 适合极低误码率的有线链路放到丢包率 1% 以上的无线环境里基本是灾难。2.3 选择重传SR 用缓存和每包定时器换吞吐Selective Repeat选择重传的出发点是既然重传风暴的根源在于「乱序即丢弃」那接收方就把乱序到达的包先缓存起来。接收窗口跟着变大能接收序号落在 [expected, expected W) 范围内的任意包并针对每个收到的包单独回 ACK。发送方为每个在途包维护独立定时器超时只重传那一个窗口内其他包正常滑动。代价是状态复杂度的翻倍。接收方要维护一个位图或缓存数组标记「哪些序号已经收到」收到重复 ACK 时发送方要能识别「这是对旧包的重申不是窗口推进」每个包独立计时意味着定时器数组的管理。更重要的是序号空间约束被收紧GBN 要求窗口 W ≤ 2^n − 1SR 直接要求 W ≤ 2^(n−1)。原因在于接收方缓存了乱序包后发送方收到的 ACK 携带的是「绝对序号」而非「累积位置」窗口上半区和下半区的歧义空间被压缩了一半。这个约束后面还会从代码层面再验证一次。一个常被忽略的事实现实中的 TCP 不是 GBN 也不是 SR而是两者的混合——累积 ACKGBN 的确认语义 接收方缓存乱序包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 250next 254窗口满了吗按无符号字节运算250 16 266溢出成 10254 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, baseWSIZE) 区间内正确处理序号回绕 */ 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往往白折腾。从那以后我每次启动模拟器都强制走一遍清空日志、三项参数先记录、小窗口跑通基线、再逐项加码。希望帮到你。本文还有配套的精品资源点击获取