TCP滑动窗口全解析:原理、流量控制与拥塞控制
TCP 滑动窗口这个概念很多人学的时候觉得不难但一到实际调优就翻车。面试被问到滑动窗口怎么实现流量控制能说出控制发送速率的人不少再往下问一句它和拥塞控制的窗口有什么区别很多人就开始含糊。我在调一个基于 TCP 的自研协议时遇到过吞吐上不去的瓶颈回头把滑动窗口机制完整补了一遍才发现很多排障思路都建立在把窗口机制理解透的基础上。这篇文章我把 TCP 滑动窗口从原理到代码完整讲一遍为什么需要窗口、窗口里的序号和确认号怎么滑动、流量控制和拥塞控制怎么区分最后用 Python 做一个可以直接运行的滑动窗口模拟器再附上实际抓包和 Socket 编程的经验。适合已经了解 TCP 基本流程、想彻底弄懂传输效率问题的开发者和运维。1. 为什么 TCP 需要一个滑动窗口从停等协议说起1.1 停等协议的致命账本如果从零设计一个可靠传输最简单可靠的做法是发一个等一个发送方发出一个报文段然后停下来等待确认接收方收到数据后回一个 ACK发送方拿到 ACK 后再发下一个。这套逻辑完全正确但效率极其低下。原因在于链路在等待 ACK 的整个 RTT 内都是空闲的除了 ACK 在返程路上占用的一点带宽外正向链路几乎没有任何数据在跑。用数字算一笔账。假设链路带宽是 1Gbps一个报文段的 MSS 是 1KB大约是 8KbitRTT 是 50ms。那么发送 1KB 数据本身只需要约 8 微秒而等待 ACK 要 50 毫秒。链路利用率是 8 微秒除以 50 毫秒加 8 微秒约等于 0.016%。也就是说1Gbps 的链路在这种方式下实际吞吐不到 0.16Mbps。这条链路上 99.98% 的时间都在空转。算完这笔账就会明白要想提高传输效率发一个等一个必须被打破。1.2 滑动窗口的本质不等 ACK先发一堆滑动窗口的思路就是把原来串行的发送变成流水线只要还有窗口余量发送方就可以连续发送多个报文段不必等待每个段的 ACK 都回来。所谓窗口本质上是允许发送方在没有收到确认时最多还能发出去的数据量上限。这里的数据量以字节为单位因为 TCP 面向字节流窗口是字节窗口报文段只是按 MSS 把字节流切开。一个容易理解但更贴切的类比是食堂打饭窗口停等协议适合只有一个窗口的食堂每个人都必须等前面的人打完饭才轮到自己队伍既长又慢滑动窗口则像一次开了多个窗口只要窗口数够多人群可以同时被服务。链路的带宽时延积就是食堂里同时在排队取餐的人数上限窗口大小只有覆盖这个乘积才能让链路不间歇。这里引出一个非常关键的式子窗口大小 RTT × 带宽 / MSS。RTT 乘以带宽就是BDPBandwidth-Delay Product表示一个字节从发出到确认回来之间网络链路能够容纳的在途字节数。如果窗口小于 BDP哪怕数据发得再快也会因为等待确认而出现空隙只有窗口足够大链路在每一个瞬间都有数据可传吞吐才上得去。这也是为什么后面调优所有参数本质上都在围绕让在途数据量填满 BDP这一件事。2. 滑动窗口的核心机制发送窗口、接收窗口与序号确认的咬合2.1 发送方内部三个游标决定一切发送方窗口的实现可以用三个游标描述清楚这也是理解 GBN 和选择重传协议的地基。SND.UNA是窗口左边界指向最早一个还没被确认的字节序号SND.NXT是下一个即将分配的字节序号窗口大小记为SND.WND窗口右边界是 SND.UNA SND.WND。发送方每次要发新数据必须先检查 SND.NXT 是否还在窗口内也就是 SND.NXT - SND.UNA 是否小于窗口大小。举个例子窗口大小为 5 字节当前 SND.UNA 10SND.NXT 12那么 12、13、14、15 都可以立即发送因为 SND.NXT 离 SND.UNA 的差是 2还剩 3 个字节的余量当 SND.NXT 推进到 15差值变成 5可发送余量归零新数据就不能再发要等 ACK 把 SND.UNA 往前推。收到确认后 SND.UNA 向前移动窗口右边界同步扩展新序号重新进入可发送区。这个左边界推进、右边界失去的过程就是窗口的滑动。2.2 接收方的窗口怎么配合接收方也有自己的窗口用RCV.NXT和RCV.WND描述。RCV.NXT 表示期望收到的下一个字节序号落在 [RCV.NXT, RCV.NXT RCV.WND) 窗口内的数据才会被接受区间外的数据会被丢弃。接收方收到数据后把已连续收到的字节序号放进 ACK 的确认号里返回表示这个序号之前的所有字节都到了。这就是累积确认。注意一点接收方窗口允许乱序到达。TCP 的接收缓冲区会把提前到的字节段先缓存起来等中间空缺补齐后再交给应用层。这一点让 TCP 在丢包和乱序时不必立即重传所有数据只重传确实缺失的部分对吞吐帮助很大。如果任何乱序包都被丢掉窗口虽然还能滑动但重传成本会高到无法接受。整个机制的闭环是这样的发送方根据 ACK 里返回的确认号判断哪些字节已被接收根据窗口字段判断还能继续发多少。接收方的窗口大小不是固定不变的它会随着应用层从缓冲区读走数据而扩大随着缓冲区积压而缩小。这个告诉发送方还剩多少空间的动作就是流量控制的起点。2.3 窗口大小字段的位数陷阱TCP 头里表示窗口大小的字段只有 16 位最大 65535 字节。这个数值在现代宽带链路下严重不够用。假设 RTT 是 30ms、单流带宽要跑满 500MbpsBDP 是 1.875MB而窗口字段上限只有 64KB单条 TCP 连接根本不可能把链路填满。于是 RFC 1323 定义了窗口缩放选项三次握手时双方协商一个缩放因子窗口真实大小等于头部字段乘以 2 的缩放因子次方。抓包时Wireshark 在 SYN 包里会显示 Window size 和 Window scale缩放因子后续Window列则是原始字段值[Calculated window size]是乘完缩放因子的真实值。调试高带宽连接时如果只看原始 Window 字段会把 64MB 的真实窗口误读成 64KB反之如果没开窗口缩放再大的接收缓冲区也表达不出来。这两件事不搞清楚窗口调优就无从谈起。3. 流量控制在实战中的表现通告窗口、零窗口与 Nagle 算法3.1 流量控制到底控制什么流量控制处理的问题非常具体发送方发送太快接收方缓冲区装不下多余的包只能丢弃如果接收方丢弃了数据发送方又要重传不仅浪费带宽还会让整体效率更差。TCP 的解法是接收方在每条 ACK 或数据包里携带当前还能接收多少字节的信息这个值叫通告窗口。发送方据此调节速率相当于每个数据包都带了一张还剩几张票的通告。实际可发送窗口不是由发送方一厢情愿决定的而是min(本端拥塞窗口, 对端通告窗口)。通告窗口代表接收端的接受能力由应用层消费速度决定拥塞窗口代表网络路径的可用容量由丢包和 RTT 情况决定。这两个窗口是不同维度的约束不能互相替代。我在实际服务里见过最典型的流量控制场景是接收方因为消费线程池处理不过来socket 接收缓冲区逐渐被填满抓包看到对端通告窗口从 32KB 降到 8KB 再到 0。问题根因不是协议配置而是应用消费速度太慢。3.2 零窗口死锁与持久计时器当通告窗口降到 0发送方必须停发。问题在于接收方之后腾出了空间是靠新的 ACK 来通知发送方的如果这个 ACK 在网络中丢了发送方会一直等待接收方也以为发送方还在正常等通知双方互等连接就形成了死锁状态。TCP 不能允许这种死锁所以设计了持久计时器Persist Timer发送方收到零窗口后并不彻底休眠而是每隔一段时间主动发一个 1 字节的窗口探测包逼接收方回一次最新的窗口大小。探测间隔按指数退避增长从 1.5 秒开始逐渐翻倍最大到 60 秒左右。这个机制对排查连接卡顿很有帮助。用ss -tn观察时如果 Send-Q 长期堆积而 Recv-Q 为 0说明数据发出去了但对方没取走再抓包看到[TCP ZeroWindow]标记和连续的[TCP ZeroWindowProbe]基本可以判定是接收端应用没有及时调用 recv而不是网络故障。方向明确修复动作就简单提高消费并发度或者把大块小消息先聚合再处理。3.3 Nagle 算法和延迟 ACK 的互相伤害Nagle 算法是为了减少小包泛滥而生的如果连接上还存在未确认的小包新到的零碎数据会被临时缓存等收到 ACK 后合并成一个大包再发送。这个优化在大数据传输场景收益明显但它和接收方的延迟 ACK 叠加时会造成一种非常隐蔽的延迟。接收方为了合并 ACK、减少包数量会尽量在收到数据后等一小段时间通常 40ms再回 ACK发送方因为 Nagle 要等 ACK 才发缓存的小包。两边都在等小请求发不出去ACK 也不回来交互延迟被拉满。我调过的一个即时通讯长连接就遇到过这问题登录包只有几十字节但每次登录要卡 1 到 2 秒。排查确认是 Nagle 与延迟 ACK 叠加打开TCP_NODELAY之后登录延迟降到 50ms 以内。提示TCP_NODELAY关闭的是 Nagle 的小包合并策略并不是关闭滑动窗口本身的确认和重传机制请放心在交互型应用中使用。判断要不要开TCP_NODELAY的标准也很简单如果你的应用是请求-响应式的比如 RPC、数据库连接、IM 消息默认开如果做的是长时间大块数据传输比如文件上传、日志同步Nagle 的合并反而能减少报文头开销默认关掉即可。实时性要求越高越要在 socket 选项上尽早做决定线上再改往往已经影响了用户体验。4. 滑动窗口和拥塞控制两个窗口不能混为一谈4.1 rwnd 与 cwnd 的管理者不一样滑动窗口和拥塞控制是 TCP 可靠性和效率的两大支柱但因为都用窗口这个词经常被搞混。其实只要分清它们各自在约束谁就不会乱。接收窗口 rwnd由接收端维护反映的是接收方缓冲区的剩余空间目标是不让发送方把接收端打爆拥塞窗口 cwnd由发送端根据网络反馈动态维护反映的是发送端对网络路径容量的估算目标是不让数据把网络打爆。UDP 之所以不需要这两个窗口是因为 UDP 不提供可靠性保证丢了就丢应用层自求多福。维度rwnd接收窗口cwnd拥塞窗口由谁控制接收方发送方反映什么接收缓冲区剩余网络路径可用容量解决的问题流量控制避免接收方溢出拥塞控制避免网络拥塞变化依据应用层读走速度丢包、RTT、ACK 过程实际发送窗口min(rwnd, cwnd)min(rwnd, cwnd)发送方的实际发送窗口是min(rwnd, cwnd)。所以抓包时看到对端 ACK 的窗口字段很大不代表就能发很多如果 cwnd 因为丢包被降下来吞吐照样上不去。反过来也一样如果 rwnd 很小cwnd 再大也没用。排查吞吐问题时判断逻辑非常清晰先看对端窗口是否小于 BDP小于则是对端消费能力或缓冲区配置问题如果窗口足够大再看抓包里有没有大量 DUP ACK 和快速重传有则是网络导致的 cwnd 收缩。4.2 cwnd 的演化方式和 rwnd 完全不同rwnd 的变化是被动跟随型的应用层每读走一批数据接收方就把窗口恢复一部分缓冲积压了窗口就降下来。cwnd 则是一个主动探测过程连接刚开始时从慢启动的指数增长出发到达阈值后转入线性增长遇到丢包马上成倍缩小之后再重新探测。这种激进的动态变化让 cwnd 对网络质量非常敏感也让它在短时间内的波形远比 rwnd 复杂。理解了这一点就不会在排查时把窗口小和流量控制画等号。有些场景里对端的 rwnd 一直是 64KB 甚至更大但抓包里重传频率很高实际发送窗口频繁被 cwnd 限制。这种问题调大缓冲区没用应该去查网络质量、中间设备队列和丢包率。两个窗口一起看才能把传输瓶颈定位准。4.3 用 Wireshark 验证窗口行为窗口机制虽然在协议栈内部运转但抓包能把它的每一步都暴露出来。第一次抓包建议直接抓 TCP 三次握手SYN 包里能看到双方的 Window scale 协商结果这个值决定后续窗口字段怎么解读。然后传输阶段数据包里的 Window 字段表示发这个包的一方还有多少接收空间ACK 包里的 Window 字段则是对方还能收多少。两边合起来可以还原出一个完整的窗口动态图。常用的筛选命令有这几个tcp.stream eq 0只看某一条 TCP 流。tcp.flags.syn 1 tcp.flags.ack 0只看第一次握手的 SYN。tcp.window_size 0直接定位零窗口。tcp.analysis.zero_windowWireshark 对零窗口事件的标记。我一般会先看[Calculated window size]列因为它是乘完窗口缩放后的真实窗口。如果这个值偏低而应用层处理又不慢再去排查系统内核参数如果这个值始终很高但吞吐很低重点就转向网络和 cwnd。这个判断顺序能少走很多弯路。5. 代码实战用 Python 写一个滑动窗口发送器5.1 实战目标与环境准备概念再清楚不写一遍代码还是隔着一层。这里我用 Python 3 实现一个最小但结构完整的滑动窗口发送端支持窗口大小可配、累积确认、超时重传并且可以模拟接收方通告窗口收缩时的流量控制行为。环境只需要 Python 3不需要任何第三方库代码可以直接复制运行观察输出。代码里的序号我用数据块编号来代表而不是真实字节偏移这样更容易看清单个段的生命周期。实际 TCP 里序号就是字节流中的偏移量实现思路完全一致。需要理解的核心数据结构只有三个in_flight 字典保存已发送未确认的段base是窗口左边界next_seq是下一个要分配的序号。5.2 最小可用的发送端实现import time class WindowSender: def __init__(self, window_size, timeout0.3): self.window_size window_size self.base 0 # 最早未确认的序号 self.next_seq 0 # 下一个待分配序号 self.in_flight {} # seq - (payload, send_time) self.timeout timeout def can_send(self): return self.next_seq - self.base self.window_size def try_send(self, blocks, now): while self.can_send() and self.next_seq len(blocks): seq self.next_seq self.in_flight[seq] (blocks[seq], now) print(f[SEND] seq{seq}, data{blocks[seq]}, fbase{self.base}, in_flight{len(self.in_flight)}) self.next_seq 1 def on_ack(self, ack): if ack self.base: for seq in list(self.in_flight): if seq ack: del self.in_flight[seq] self.base ack print(f[ACK ] ack{ack}, base推进到{self.base}, fin_flight剩余{len(self.in_flight)}) def retransmit_timeout(self, now): for seq, (payload, ts) in list(self.in_flight.items()): if now - ts self.timeout: print(f[RETX] seq{seq} 超时重传) self.in_flight[seq] (payload, now)这个类里最关键的是can_send()和on_ack()。can_send()判断当前在途段数是否小于窗口大小是则允许继续发送on_ack(ack)实现累积确认把所有序号小于 ack 的段从 in_flight 里删除并把 base 推进到 ack。retransmit_timeout()则模拟超时重传把所有停留在 in_flight 超过 timeout 的段重新记录发送时间由于模拟环境里时间由外部控制所以重传逻辑非常直观。5.3 模拟器观察不同窗口大小下的发送行为import random def simulate(window_size, loss_rate0.0): blocks [fpkt-{i} for i in range(12)] sender WindowSender(window_size) now 0.0 random.seed(1) while sender.next_seq len(blocks) or sender.in_flight: sender.try_send(blocks, now) if sender.in_flight: oldest min(sender.in_flight) if random.random() loss_rate: sender.on_ack(oldest 1) else: print(f[LOSS] seq{oldest} 丢失等待超时) now 0.1 sender.retransmit_timeout(now)运行simulate(2)时输出会呈现出非常明显的发两个等 ACK再发两个的节奏运行simulate(6)时前 6 个包几乎全部在第一批发出去之后 ACK 只是把窗口右边界一步步向前推。这种差异完全来自窗口大小对在途数据量的限制也就是第一章里链路利用率的直接体现。如果窗口远大于数据块总数第一批就发完了后面所有时间都是在等 ACK链路利用率不会因为窗口无限大而继续提高。5.4 加入接收方通告窗口流量控制的动态演示流量控制本质上就是把can_send()的条件从单纯的自己的窗口大小改成min(自己的窗口大小, 对端通告窗口)。class FlowControlSender(WindowSender): def __init__(self, window_size, rwnd10, timeout0.3): super().__init__(window_size, timeout) self.rwnd rwnd def can_send(self): return self.next_seq - self.base min(self.window_size, self.rwnd) def update_rwnd(self, new_rwnd): self.rwnd new_rwnd print(f[RWND] 接收方通告窗口变为 {self.rwnd}) def simulate_flow_control(): blocks [fpkt-{i} for i in range(10)] sender FlowControlSender(window_size10, rwnd5) now 0.0 shrink_done False while sender.next_seq len(blocks) or sender.in_flight: if not shrink_done and sender.next_seq 5: sender.update_rwnd(1) # 模拟接收缓冲区快满收缩通告窗口 shrink_done True sender.try_send(blocks, now) if sender.in_flight: oldest min(sender.in_flight) sender.on_ack(oldest 1) # 假设网络不丢包只观察窗口约束 now 0.1 sender.retransmit_timeout(now)当模拟器把对端通告窗口从 5 收窄到 1 之后发送端的可发送余量立刻被压制每个 ACK 只允许一个新段进入窗口。这个输出就是真实 TCP 中接收方缓冲区不足时发送速率自动下降的微观过程。你可以把 rwnd 改到 0 试一下连接会进入零窗口状态等待接收方恢复空间这正是上一章讲到的持久计时器所在的状态。5.5 真实 Socket 程序里怎么感知窗口真实 TCP Socket 中滑动窗口默认是内核自动打理的应用程序一般不需要手动控制每一字节但可以通过 socket 选项感知和调整缓冲区。import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) # 获取默认发送/接收缓冲区大小内核实际通常会翻倍 snd_buf s.getsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF) rcv_buf s.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF) print(fSO_SNDBUF{snd_buf}, SO_RCVBUF{rcv_buf}) # 对流式 TCP 关闭 Nagle s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)Linux 上设置SO_RCVBUF之后内核分配的接收缓冲区往往是设置值的两倍左右这是为了给窗口管理预留额外空间所以不要用getsockopt的返回值去直接当作窗口大小。还要注意 sysctl 参数net.ipv4.tcp_rmem和net.ipv4.tcp_wmem会进一步影响自动调节范围不能只改应用层就指望窗口变大。跑真实服务时我把SO_SNDBUF和SO_RCVBUF调大过但吞吐没有提升抓包才发现瓶颈不在窗口而是应用层消费线程太少接收缓冲区一直有积压rwnd 一直被压得很低。调优的顺序应该是先保证应用消费速度跟得上再调内核缓冲区参数最后才谈流量控制和拥塞控制的细节。顺序错了参数再大也只是白调。6. 从一次线上卡死说起窗口排查的完整链路6.1 现象连接正常吞吐却趋零有一次我负责的消息系统在高峰期吞吐掉到 0从ss -tn看连接状态全是 ESTABLISHED端口一切正常进程也没挂但 Send-Q 涨到几百 MBRecv-Q 一直是 0。第一反应是怀疑网络中断可 ping 对端完全正常。这种连接活着但数据不动的状态恰恰是窗口机制最容易暴露问题的地方。6.2 三层排查先估目标再看队列最后抓包估算 BDP确立目标窗口假设 RTT 是 0.2ms带宽是 10GbpsBDP 只有约 250KB目标窗口远小于默认接收缓冲区可以先把窗口不够大这条线排除。观察队列ss -tn的 Send-Q 持续堆积、Recv-Q 为 0说明数据发得出去但对端不取。抓包确认对端 ACK 里的 Window 字段从 32KB 一路降到 0Wireshark 标记连续的[TCP ZeroWindow]和[TCP ZeroWindowProbe]最终定位是接收端应用没有及时读缓冲区。这三步的执行顺序很重要。先算 BDP 是为了设定一个合理的预期窗口避免拿 64KB 当标准再看 Send-Q 和 Recv-Q 能快速区分发送端积压和接收端积压最后抓包才是确认机制细节。很多人跳过前两步直接开抓包容易被一大堆重传和乱序干扰判断。6.3 修复与验证定位到根因后修复动作并不复杂把接收端单线程消费改成线程池并行消费并把SO_RCVBUF从 64KB 调到 1MB。重启之后再现测抓包里能看到 rwnd 从 0 恢复Send-Q 逐渐回落吞吐恢复到正常水平。这个案例里没有改任何网络层参数问题完全出在应用消费速度与窗口恢复速度不匹配。6.4 常见误区窗口知识最容易翻车的地方常见说法实际情况正确理解调大 SO_SNDBUF 就一定能提升吞吐发送缓冲只是上限的一部分吞吐受 min(rwnd, cwnd) 约束窗口越大越好窗口超过 BDP 无额外收益窗口目标是填满带宽时延积rwnd 小表示网络拥塞rwnd 反映接收方缓冲区状态网络拥塞应看 cwnd 和重传关掉 Nagle 等于禁用滑动窗口Nagle 只控制小包合并窗口机制照常工作最后补充一点个人感受滑动窗口是一个机理清晰、但链条很长的机制。代码一看就懂真正难的是把 rwnd、cwnd、BDP、应用消费速度、内核参数这些变量放回一个整体里判断。遇到 TCP 传输慢我建议永远按同样的顺序排查先估 BDP再看接收窗口最后看拥塞和丢包。把顺序固定下来窗口相关的坑基本都能避开。