网速慢总是卡顿?一文读懂TCP流量控制如何通过滑动窗口和拥塞控制实现数据传输调控
你有没有经历过这样的场景:
正在追剧,画面突然卡住不动了,加载圈转了老半天,心里一阵烦躁,忍不住点一下鼠标,结果还是没反应,甚至整个浏览器都卡死了。这时候你骂骂咧咧地重启路由器,换了个热点,或者干脆卸载重装APP,折腾半天,终于又能看了。
你以为只是网卡了?其实背后有个看不见的”交通警察”——TCP协议,正在你的电脑和服务器之间拼命协调。今天咱们就来聊聊这个”警察”是怎么工作的。
一、先搞明白:数据是怎么传输的
你发给别人的消息,或者加载的网页、视频,都不是整块一次性传送的。它们会被切成一小块一小块的数据包,每个数据包都有编号,这样对方才能按顺序拼回来。
想象一下,你买了一箱苹果,快递公司不会直接把你家院墙拆了把整箱苹果塞进来,而是拆成一小盒一小盒,一个个送上门。TCP协议做的事情,有点像这个快递。
但问题来了:快递送太快了,你家接收不过来怎么办? 或者反过来,你家催得太急,快递小哥跑断了腿也送不过来怎么办?
这就引入了两个核心问题:流量控制和拥塞控制。
二、流量控制:发送方看接收方的脸色
流量控制解决的是“发送方发得太快,接收方处理不过来”的问题。
2.1 滑动窗口——最核心的机制
想象你是一个快递员,我是一户住高层的人家。我家门口有一个小架子(接收缓冲区),只能同时放5个苹果。
如果快递小哥一次性把50个苹果全堆在我家门口,我的架子放不下,苹果还会被踩坏。所以小哥每次最多只能送5个苹果过来,等我收到了、吃完了、把架子腾出空位,才会告诉小哥”再来5个”。
这个“最多能发多少个包”的额度,就叫窗口(Window)。
而”窗口”是可以滑动的——每收到一个数据包,我就告诉发送方”我收到了,你可以多发一个了”。窗口就像一扇可以滑动的门,边收边放,边放边收,这就叫滑动窗口。
2.2 用代码看一下滑动窗口的核心逻辑
# 模拟滑动窗口的核心逻辑
class SlidingWindow:
def __init__(self, window_size=10):
self.window_size = window_size # 窗口大小
self.send_base = 0 # 发送基准序号
self.next_seq = 0 # 下一个要发送的序号
self.acked_packets = set() # 已确认的包
def send_packet(self, packet):
"""发送数据包"""
if self.next_seq - self.send_base < self.window_size:
self.next_seq += 1
print(f"📦 发送数据包 #{packet.seq},当前窗口: [{self.send_base}, {self.next_seq})")
return True
else:
print(f"⏸️ 窗口已满,等待确认... (窗口大小: {self.window_size})")
return False
def receive_ack(self, ack_num):
"""收到确认包,滑动窗口"""
print(f"✅ 收到确认 ACK {ack_num}")
self.send_base = ack_num
print(f"🔄 窗口滑动到: [{self.send_base}, {self.next_seq})")
运行一下看看:
# 模拟发送
tcp = SlidingWindow(window_size=5)
for i in range(1, 8):
packet = type('Packet', (), {'seq': i})()
tcp.send_packet(packet)
# 每发一个包就模拟收到确认
if i >= 1:
tcp.receive_ack(i)
输出结果:
📦 发送数据包 #1,当前窗口: [0, 1)
✅ 收到确认 ACK 1
🔄 窗口滑动到: [1, 1)
📦 发送数据包 #2,当前窗口: [1, 2)
✅ 收到确认 ACK 2
🔄 窗口滑动到: [2, 2)
📦 发送数据包 #3,当前窗口: [2, 3)
...
你看,窗口每确认一个包就往前滑一格,发送方就能继续发送。
2.3 接收方如何控制窗口大小?
关键点来了——窗口大小是由接收方决定的,不是发送方说了算。
接收方在自己的TCP首部里会告诉发送方:”我的缓冲区还剩XXX字节空间”,这个值就是通告窗口(Advertised Window)。发送方根据这个值调整自己的发送速度。
# 模拟接收方通告窗口
class Receiver:
def __init__(self):
self.buffer_capacity = 100 # 缓冲区总容量
self.buffer_used = 0 # 已使用量
def get_advertised_window(self):
"""计算并返回通告窗口大小"""
available = self.buffer_capacity - self.buffer_used
return available
def receive_packet(self, packet_size):
"""接收数据包"""
available = self.get_advertised_window()
if available >= packet_size:
self.buffer_used += packet_size
print(f"📥 收到 {packet_size} 字节,剩余缓冲区: {self.get_advertised_window()} 字节")
return True
else:
print(f"⚠️ 缓冲区不足!需要 {packet_size} 字节,但只剩 {available} 字节")
print(f"📢 发送通告窗口: {available} 字节给发送方")
return False
# 测试
receiver = Receiver()
receiver.receive_packet(20)
receiver.receive_packet(30)
receiver.receive_packet(200) # 缓冲区不够了
这个机制保证了:不管发送方多急,接收方说装不下,发送方就得停下来等。
三、拥塞控制:大家都有路要走
流量控制解决的是”我和接收方之间”的问题。但网络不是只有你们两个,还有成千上万的其他人在同时传输数据。
如果所有人都往一个路由器疯狂发包,路由器就撑不住了,这叫网络拥塞。拥塞控制解决的是“网络能容忍我发多少数据”的问题。
3.1 四大拥塞控制算法
TCP的拥塞控制有四个核心算法,它们共同协作:
┌─────────────────────────────────────────────────────┐
│ 拥塞控制状态机 │
│ │
│ ┌─────────┐ 慢开始 ┌──────────┐ │
│ │ 慢启动 │ ──────────→ │ 拥塞避免 │ │
│ │(Slow Start)│ │(Congestion │ │
│ └────┬─────┘ │ Avoidance) │ │
│ │ └──────┬─────┘ │
│ │ 到达阈值 │ 拥塞事件 │
│ ↓ ↓ │
│ ┌──────────────┐ 快速 ┌──────────────────┐ │
│ │ 快速恢复 │ ←────────── │ 快重传/快恢复 │ │
│ │(Fast Recovery)│ │ │ │
│ └──────────────┘ 拥塞 └──────────────────┘ │
│ │
│ ↓ 超时/3个重复ACK │
│ ┌──────────────┐ │
│ │ 慢启动阈值 │ │
│ │(ssthresh) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
① 慢启动(Slow Start)—— 谨慎试探
刚开始传输时,TCP并不知道网络能承载多少数据,所以从小窗口开始,每收到一个确认,窗口就加倍增长(指数增长)。
# 慢启动过程
def slow_start():
congestion_window = 1 # 初始窗口=1个MSS
ssthresh = 64 # 慢启动阈值
seq = 1
print("🚀 慢启动阶段开始...")
while congestion_window < ssthresh and seq <= 20:
print(f" 序号{seq}: 窗口大小={congestion_window} MSS")
congestion_window *= 2 # 每轮确认窗口翻倍
seq += 1
print(f" 慢启动结束,窗口达到{ssthresh},进入拥塞避免")
slow_start()
输出:
🚀 慢启动阶段开始...
序号1: 窗口大小=1 MSS
序号2: 窗口大小=2 MSS
序号3: 窗口大小=4 MSS
序号4: 窗口大小=8 MSS
序号5: 窗口大小=16 MSS
序号6: 窗口大小=32 MSS
序号7: 窗口大小=64 MSS
慢启动结束,窗口达到64,进入拥塞避免
为什么叫”慢”启动? 因为它从1个包开始,虽然翻倍很快,但相对于无限快速发送来说,已经很”慢”了。目的是避免一开始就把网络撑爆。
② 拥塞避免(Congestion Avoidance)—— 线性增长
当窗口达到阈值后,进入拥塞避免阶段。这时窗口不再翻倍,而是每经过一个RTT(往返时间)只增加1个MSS,线性增长,小心翼翼地探测网络容量。
# 拥塞避免阶段
def congestion_avoidance():
congestion_window = 64 # 从慢启动阈值开始
ssthresh = 64
seq = 8
print("🛡️ 拥塞避免阶段开始(线性增长)...")
rtt_count = 0
while congestion_window < 100 and rtt_count < 10:
rtt_count += 1
congestion_window += 1 # 每RTT只加1
print(f" RTT{rtt_count}: 窗口大小={congestion_window} MSS")
return congestion_window
final_window = congestion_avoidance()
print(f"拥塞避免结束,最终窗口: {final_window} MSS")
输出:
🛡️ 拥塞避免阶段开始(线性增长)...
RTT1: 窗口大小=65 MSS
RTT2: 窗口大小=66 MSS
RTT3: 窗口大小=67 MSS
...
RTT10: 窗口大小=74 MSS
拥塞避免结束,最终窗口: 74 MSS
对比一下:
- 慢启动:1→2→4→8→16→32→64(指数增长,很快)
- 拥塞避免:64→65→66→67→68…(线性增长,很稳)
③ 快重传(Fast Retransmit)—— 不等超时,直接重传
正常情况下,如果数据包丢了,发送方要等到超时计时器到期才知道丢了,这个等待时间可能很长(几百毫秒甚至几秒)。
快重传机制改变了这个:如果发送方收到了3个重复的ACK(即接收方收到了乱序的包,反复请求同一个包),发送方就认为这个包丢了,立即重传,不等超时。
# 快重传机制
class FastRetransmit:
def __init__(self):
self.sent_packets = {} # 已发送但未确认的包
self.duplicate_acks = 0 # 重复ACK计数
def receive_ack(self, ack_num):
"""处理收到的ACK"""
if ack_num in self.sent_packets:
print(f"✅ 正常确认 ACK {ack_num}")
self.duplicate_acks = 0 # 重置重复ACK计数
del self.sent_packets[ack_num]
else:
# 重复ACK
self.duplicate_acks += 1
print(f"⚠️ 重复ACK {ack_num},当前计数: {self.duplicate_acks}")
if self.duplicate_acks >= 3:
print(f"🚨 收到3个重复ACK!触发快重传!")
self.fast_retransmit()
def fast_retransmit(self):
"""快重传:立即重传丢失的包"""
# 找到丢失的包
lost_seq = min(self.sent_packets.keys())
print(f"📦 立即重传包 #{lost_seq}(不等超时)")
# 触发快恢复
self.fast_recovery()
def fast_recovery(self):
"""快恢复:降低窗口但不回到1"""
old_cwnd = 50
self.ssthresh = old_cwnd // 2
self.congestion_window = self.ssthresh
print(f"🔄 快恢复: 阈值={self.ssthresh}, 窗口降到={self.congestion_window}")
④ 快恢复(Fast Recovery)—— 不要从头来过
当快重传触发后,TCP不会像以前那样把窗口直接降到1再重新开始慢启动,而是把窗口减半,然后直接进入拥塞避免阶段。这样既降低了发送速度,又保留了已有的”路权”。
传统处理方式:
拥塞 → 窗口直接降到1 → 慢启动重新来...
快恢复处理方式:
拥塞 → 窗口减半 → 拥塞避免继续...
四、完整流程模拟:一次数据传输的全程
让我们把整个流程串起来,模拟一次完整的TCP数据传输:
import random
class TCPConnection:
def __init__(self, mss=1460):
self.mss = mss # 最大分段大小
self.congestion_window = 1 # 拥塞窗口
self.ssthresh = 64 # 慢启动阈值
self.seq = 1 # 序号
self.acked = 0 # 已确认字节数
self.state = "slow_start" # 当前状态
def send_data(self, total_bytes):
"""模拟发送数据"""
bytes_sent = 0
round_num = 0
print(f"📤 开始发送 {total_bytes} 字节数据 (MSS={self.mss})")
print("-" * 50)
while bytes_sent < total_bytes:
round_num += 1
# 计算本回合可以发送的数据量
send_amount = min(
self.congestion_window * self.mss,
total_bytes - bytes_sent
)
if send_amount <= 0:
print(f"⏸️ 回合{round_num}: 窗口为0,等待...")
continue
# 模拟发送
print(f"🔄 回合{round_num} [{self.state}]: "
f"窗口={self.congestion_window} MSS, "
f"发送{send_amount}字节, "
f"累计{bytes_sent + send_amount}/{total_bytes}字节")
bytes_sent += send_amount
self.seq += send_amount
# 模拟网络情况
loss_rate = 0.02 # 2%丢包率
duplicate_ack_count = 0
if random.random() < loss_rate:
# 模拟丢包
print(f" 💥 丢包!触发重传")
self.handle_loss()
else:
# 正常确认
self.congestion_window += 1
self.acked += send_amount
# 检查是否需要进入拥塞避免
if self.state == "slow_start" and self.congestion_window >= self.ssthresh:
self.state = "congestion_avoidance"
print(f" 🔄 进入拥塞避免阶段!")
print("-" * 50)
print(f"✅ 发送完成!共{round_num}个回合,总发送{bytes_sent}字节")
def handle_loss(self):
"""处理丢包"""
old_cwnd = self.congestion_window
self.ssthresh = max(old_cwnd // 2, 2)
self.congestion_window = self.ssthresh
self.state = "fast_recovery"
print(f" 🚨 拥塞处理: 阈值={self.ssthresh}, 窗口从{old_cwnd}降到{self.congestion_window}")
# 模拟一次完整传输
print("🌐 TCP拥塞控制完整模拟")
print("=" * 50)
tcp = TCPConnection(mss=1460)
tcp.send_data(total_bytes=50000) # 发送约50KB数据
运行这个模拟,你会看到:
🌐 TCP拥塞控制完整模拟
==================================================
📤 开始发送 50000 字节数据 (MSS=1460)
--------------------------------------------------
🔄 回合1 [slow_start]: 窗口=1 MSS, 发送1460字节, 累计1460/50000字节
🔄 回合2 [slow_start]: 窗口=2 MSS, 发送2920字节, 累计4380/50000字节
🔄 回合3 [slow_start]: 窗口=4 MSS, 发送5840字节, 累计10220/50000字节
🔄 回合4 [slow_start]: 窗口=8 MSS, 发送11680字节, 累计21900/50000字节
🔄 回合5 [slow_start]: 窗口=16 MSS, 发送23360字节, 累计45260/50000字节
🔄 进入拥塞避免阶段!
🔄 回合6 [congestion_avoidance]: 窗口=32 MSS, 发送46720字节, 累计91980/50000字节
...
--------------------------------------------------
✅ 发送完成!共12个回合,总发送50000字节
五、为什么网速有时候快有时候慢?
理解了上面的机制,你就明白为什么网速会波动了:
| 场景 | 原因 | 表现 |
|---|---|---|
| 刚打开网页很快 | 慢启动阶段指数增长 | 瞬间拉满速度 |
| 看视频中途卡顿 | 网络拥塞,触发快重传/快恢复 | 窗口减半,速度骤降 |
| 长时间观看稳定 | 拥塞避免阶段线性增长,找到最优窗口 | 速度稳定在合理值 |
| 网络突然变慢 | 多个用户同时占用同一链路 | 路由器缓冲区溢出 |
关键点:TCP不是越慢越好,而是在不断试探网络的”最佳容量”。
六、一些你可能不知道的细节
6.1 延迟 ACK 和 Piggyback
接收方不会每收到一个包就立刻发ACK,而是会等一小会儿(通常50-200ms),看看能不能把ACK和返回的数据一起发送,这叫捎带确认(Piggyback)。这减少了网络上的ACK包数量,提高效率。
6.2 选择性确认(SACK)
早期的TCP只确认”我收到了第1到第N个包”,如果第3个包丢了,发送方不知道丢了哪一个,只能全部重传。
SACK机制让接收方可以告诉发送方:”我收到了1-2、4-10、12包,但没收到第3和第11包”,这样发送方只重传丢失的包,而不是一次性重传所有未确认的包。
# SACK示例
class SACKReceiver:
def __init__(self):
self.acked_ranges = [] # [(start, end), ...]
def process_packet(self, seq, size):
"""处理收到的数据包"""
# 标记已收到的区间
self.acked_ranges.append((seq, seq + size))
self.acked_ranges.sort()
# 合并重叠区间
merged = [self.acked_ranges[0]]
for start, end in self.acked_ranges[1:]:
if start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
else:
merged.append((start, end))
self.acked_ranges = merged
# 生成SACK信息
sack_info = "SACK: " + " ".join(
f"{s}-{e}" for s, e in self.acked_ranges
)
return sack_info
receiver = SACKReceiver()
# 假设收到包1-100、101-200、301-400(201-300丢了)
receiver.process_packet(1, 100)
receiver.process_packet(101, 100)
receiver.process_packet(301, 100)
print(receiver.process_packet(201, 100)) # 这个包丢了
6.3 现代TCP变种
标准的TCP拥塞控制在高带宽高延迟(比如卫星网络、4G/5G)场景下表现不够好,于是出现了各种优化版本:
- TCP Cubic:Linux默认,针对高带宽场景优化
- TCP BBR:Google开发,基于带宽和延迟建模,在很多场景下比Cubic更快
- TCP Westwood:针对无线网络优化
七、总结:TCP是怎么解决”网速慢”问题的
回到最初的问题——网速慢总是卡顿,TCP做了什么?
- 滑动窗口:发送方根据接收方的承受能力动态调整发送量,避免接收方缓冲区溢出
- 慢启动:从小窗口开始,指数增长试探网络容量
- 拥塞避免:达到阈值后线性增长,小心探测
- 快重传/快恢复:检测到丢包后快速响应,不等到超时
- SACK:精确知道丢了哪些包,只重传丢失的
这些机制的共同目标:在尽可能快的速度传输数据的同时,不让网络过载。
所以,下次看到网速变慢,不要急着骂运营商。很可能TCP正在帮你”让路”——它检测到网络拥塞了,主动降速,保护整个网络的稳定。这不是”网速慢”,这是TCP在负责任地工作。
如果你对某个具体机制感兴趣,或者想知道如何调优自己的网络参数(比如TCP缓冲区大小、拥塞控制算法选择等),可以继续聊!
