你有没有在深夜刷视频时遇到过这样的场景:画面突然卡住,缓冲圈转啊转,网络状态显示“良好”?这时候你可能想骂人,但更该了解一下背后的TCP协议到底发生了什么。TCP作为互联网最核心的传输协议,它不仅要保证数据不丢包,还要解决两个经典难题:发送方发太快怎么办(流量控制),以及网络太堵了怎么办(拥塞控制)。而连接这两者的桥梁,就是那个听起来有点抽象的“滑动窗口”。
今天咱们不聊教科书式的定义,而是像拆解一台老式收音机一样,把TCP的这几块核心机制拆开来看,看看它们是如何协同工作,让互联网不至于被我们每天发送的几百万条消息冲垮的。
一、流量控制:别让接收方“消化不良”
想象一下,你是一个外卖骑手(发送方),顾客是一个吃货(接收方)。骑手骑车飞快,一分钟能送十单,但吃货一顿饭只能慢慢吃,吃太快会吐。如果骑手不管不顾,把十份外卖都堆在顾客门口,顾客根本处理不过来,最后只会全部洒掉。
这就是流量控制要解决的问题:确保发送方的发送速率不会超过接收方的处理能力。
1.1 核心机制:接收方通告窗口(rwnd)
TCP通过一个叫做通告窗口(Advertised Window,简称rwnd)的字段来解决这个问题。这个字段存在于TCP报文段的头部,由接收方在发送ACK(确认应答)时告诉发送方:“我还有这么多缓冲区空闲,你最多只能发这么多数据给我。”
举个例子:
接收方缓冲区总大小:10KB
已处理数据:2KB
未处理数据:3KB(还在缓冲区里,等待应用层读取)
那么,接收方会告诉发送方:
通告窗口 rwnd = 缓冲区总大小 - 未处理数据 = 10KB - 3KB = 7KB
这意味着:发送方在收到新的ACK之前,最多只能发出7KB的未确认数据。
1.2 动态调整的过程
流量控制不是一次性的,而是动态调整的。每次接收方收到数据后,都会更新自己的缓冲区状态,并在下一个ACK中携带最新的rwnd值。
| 时间点 | 接收方缓冲区状态 | rwnd值 | 发送方行为 |
|---|---|---|---|
| T1 | 空,10KB可用 | 10KB | 可以发送10KB数据 |
| T2 | 收到3KB,剩余7KB | 7KB | 继续发送,但不超过7KB未确认数据 |
| T3 | 收到5KB,剩余2KB | 2KB | 发送速率受限,只能发2KB |
| T4 | 应用层处理了3KB,剩余5KB | 5KB | 可以恢复发送速率 |
1.3 零窗口问题及其解决
最极端的情况是零窗口:接收方缓冲区满了,rwnd=0。这时候发送方会不断收到“窗口为0”的通告,但TCP协议规定,即使窗口为0,接收方也要定期发送窗口探测报文,防止死锁。
# 模拟零窗口探测的简化逻辑
def zero_window_handler(sender, receiver):
if receiver.rwnd == 0:
# 发送方不能停止发送,否则双方可能死锁
# 接收方会每隔一段时间发送一个窗口探测ACK
sender.send_probe() # 发送1字节的探测数据
wait_for_update() # 等待接收方应用层读取数据
else:
sender.send_data(window=receiver.rwnd)
关键点:流量控制是点对点的,只关心发送方和接收方两端的匹配问题,不涉及网络中间路径的状态。
二、滑动窗口:TCP的“视觉暂留”机制
理解了流量控制,就得理解它的实现载体——滑动窗口。这是TCP可靠传输的核心机制,很多人学到这里就懵了,其实它非常直观。
2.1 什么是滑动窗口?
滑动窗口是一个逻辑概念,它表示发送方在未收到ACK之前,可以连续发送的数据范围。这个窗口可以在序列号空间上滑动,因此得名。
想象你在玩一个贪吃蛇游戏:
- 蛇头代表已发送但未确认的最大序列号
- 蛇身代表已发送但尚未确认的数据
- 蛇尾代表已确认的最小序列号
- 窗口大小决定蛇能延伸多长
2.2 窗口滑动的三种基本操作
情况一:发送数据,窗口前移
初始状态:
发送窗口:[0, 1, 2, 3, 4, 5, 6, 7] (窗口大小=8)
序列号: 0 1 2 3 4 5 6 7
发送方发送序列号0-7的数据:
发送窗口:[0, 1, 2, 3, 4, 5, 6, 7]
↓ 已发送
情况二:收到ACK,窗口滑动
接收方确认收到0-3:
发送方收到ACK 4(表示0-3已正确接收)
发送窗口滑到:[4, 5, 6, 7, 8, 9, 10, 11]
^ 新的起始位置
情况三:超时重传,窗口回退
如果发送方超时未收到ACK 4:
发送窗口回退到包含未确认数据的范围
重新发送0-3中的数据(选择性重传或整个窗口重传)
2.3 代码模拟滑动窗口
class SlidingWindow:
def __init__(self, window_size=10):
self.window_size = window_size
self.base = 0 # 窗口起始位置(已确认的下一个序列号)
self.next_seq = 0 # 下一个要发送的序列号
self.sent_packets = {} # 已发送但未确认的包
def send(self, data):
"""发送数据,返回是否成功"""
if self.next_seq - self.base < self.window_size:
seq = self.next_seq
self.sent_packets[seq] = {'data': data, 'acked': False}
self.next_seq += 1
print(f"发送序列号 {seq}: {data}")
return True
else:
print("窗口已满,等待ACK")
return False
def receive_ack(self, ack_num):
"""接收确认,滑动窗口"""
print(f"收到ACK: {ack_num},窗口滑动")
# 确认所有小于ack_num的包
while self.base < ack_num:
if self.base in self.sent_packets:
self.sent_packets[self.base]['acked'] = True
del self.sent_packets[self.base]
self.base += 1
def timeout(self):
"""超时重传,回退到base位置"""
print(f"超时!重新发送序列号 {self.base} 及之后的数据")
# 实际TCP实现中,会重传未确认的所有数据
pass
def get_window_state(self):
return {
'base': self.base,
'next_seq': self.next_seq,
'window_size': self.window_size,
'unacked_count': len(self.sent_packets)
}
# 使用示例
win = SlidingWindow(window_size=5)
win.send("数据1")
win.send("数据2")
win.send("数据3")
print(win.get_window_state()) # 窗口大小5,已发送3个
win.receive_ack(3) # 确认到序列号2
print(win.get_window_state()) # 窗口滑动,base变为3
2.4 选择性确认(SACK)的改进
早期的TCP实现,如果一个包丢失,整个窗口都要重传,效率极低。后来引入了SACK(Selective Acknowledgment),接收方可以告诉发送方:“我收到了1-3和5-7,但丢了4。”这样发送方只需重传第4个包,而不需要重传整个窗口。
传统TCP:
发送:[1, 2, 3, 4, 5, 6, 7]
收到ACK:1, 2, 3 (4丢失)
重传:[4, 5, 6, 7] ← 全部重传,浪费!
SACK TCP:
发送:[1, 2, 3, 4, 5, 6, 7]
收到SACK:1-3已收到,5-7已收到,缺4
重传:[4] ← 只重传丢失的包!
三、拥塞控制:网络太堵了怎么办?
流量控制解决的是“接收方消化不良”的问题,而拥塞控制解决的是“网络道路太堵”的问题。即使接收方有能力处理,如果网络中间的路由器、交换机都堵死了,数据也传不过去。
TCP的拥塞控制机制是一个精密的反馈系统,它通过观察丢包和延迟来推断网络拥塞程度,并动态调整发送速率。
3.1 四个核心算法
TCP拥塞控制由四个算法组成,它们协同工作,像是一个有经验的司机在复杂路况下驾驶:
1. 慢启动(Slow Start)
当连接刚开始时,TCP并不知道网络的承载能力,所以采用保守的指数增长策略。
初始拥塞窗口 cwnd = 1 MSS(MSS是最大报文段长度)
每个RTT(往返时间)后,cwnd 翻倍:
RTT 1: cwnd = 1
RTT 2: cwnd = 2
RTT 3: cwnd = 4
RTT 4: cwnd = 8
RTT 5: cwnd = 16
...
这就是为什么你下载大文件时,速度会先快速上升,然后趋于平稳。
为什么叫“慢”启动? 因为从一开始的1个MSS开始,逐步探测网络容量,避免一上来就淹没网络。
2. 拥塞避免(Congestion Avoidance)
当cwnd达到一个阈值(ssthresh,慢启动阈值)后,切换到线性增长模式。
假设 ssthresh = 16:
RTT 5: cwnd = 16 (达到阈值,切换算法)
RTT 6: cwnd = 17
RTT 7: cwnd = 18
RTT 8: cwnd = 19
...
每个RTT只增加1个MSS,避免过快增长导致拥塞。
3. 快重传(Fast Retransmit)
当发送方收到3个重复ACK时,说明后面的包丢了,但网络并没有完全堵塞(因为还有ACK在返回)。此时不需要等待超时,直接重传丢失的包。
发送序列:1, 2, 3, 4, 5, 6, 7
接收方收到:1, 2, 3, 5, 6, 7 (4丢失)
接收方发送ACK:4, 4, 4, 4, 4, 4 (重复ACK,因为4没到)
发送方收到3个重复ACK后:
立即重传序列号4,不等超时!
4. 快重恢复(Fast Recovery)
快重传后,TCP不回到慢启动,而是进入快恢复状态,避免将拥塞窗口重置为1。
发生快重传时的状态变化:
ssthresh = cwnd / 2
cwnd = ssthresh + 3 (加上3个重复ACK对应的包)
然后进入拥塞避免模式,线性增长
3.2 拥塞控制的完整状态机
class TCPCongestionControl:
def __init__(self):
self.cwnd = 1 # 拥塞窗口
self.ssthresh = 65535 # 慢启动阈值(初始很大)
self.MSS = 1460 # 最大报文段长度(字节)
def slow_start(self):
"""慢启动阶段:指数增长"""
self.cwnd *= 2
print(f"慢启动: cwnd = {self.cwnd} MSS")
if self.cwnd >= self.ssthresh:
self.cwnd = self.ssthresh
return "congestion_avoidance"
return "slow_start"
def congestion_avoidance(self):
"""拥塞避免阶段:线性增长"""
self.cwnd += 1
print(f"拥塞避免: cwnd = {self.cwnd} MSS")
return "congestion_avoidance"
def packet_loss_detected(self, loss_type="timeout"):
"""检测到丢包,调整窗口"""
if loss_type == "timeout":
# 超时:认为是严重拥塞
self.ssthresh = max(self.cwnd // 2, 2)
self.cwnd = 1
print(f"超时!慢启动重置: cwnd={self.cwnd}, ssthresh={self.ssthresh}")
return "slow_start"
elif loss_type == "fast_retransmit":
# 3个重复ACK:认为是轻度拥塞
self.ssthresh = self.cwnd // 2
self.cwnd = self.ssthresh + 3
print(f"快重传: cwnd={self.cwnd}, ssthresh={self.ssthresh}")
return "fast_recovery"
def fast_recovery(self, new_ack):
"""快恢复阶段"""
# 每收到一个新的ACK,cwnd增加1(因为之前加了3)
self.cwnd += 1
print(f"快恢复: cwnd = {self.cwnd} MSS")
if self.cwnd <= self.ssthresh:
return "congestion_avoidance"
return "fast_recovery"
def get_send_window(self):
# 实际发送窗口 = min(拥塞窗口, 通告窗口)
return self.cwnd * self.MSS
# 模拟一次完整的拥塞控制过程
cc = TCPCongestionControl()
state = "slow_start"
print("=== 连接建立,开始慢启动 ===")
for i in range(5):
if state == "slow_start":
state = cc.slow_start()
elif state == "congestion_avoidance":
state = cc.congestion_avoidance()
print(f"\n=== 第{i+1}次RTT后,发生3个重复ACK ===")
state = cc.packet_loss_detected("fast_retransmit")
print("\n=== 快恢复阶段 ===")
for i in range(3):
state = cc.fast_recovery(new_ack=True)
print(f"\n=== 进入拥塞避免阶段 ===")
print(f"最终拥塞窗口: {cc.cwnd} MSS")
3.3 现代TCP的改进:Cubic算法
传统的TCP拥塞控制(Reno版本)在高速长距离网络(如光纤)中表现不佳,因为它的窗口增长太保守。现代操作系统(Linux、Windows)默认使用Cubic算法:
- 立方函数增长:
cwnd(t) = C * (t - K)^3 + cwnd_max - 更快恢复:在检测到拥塞后,能更快速地恢复到合理的发送速率
- 更平滑的振荡:避免传统TCP的“全局同步”问题(所有连接同时减慢、同时加速)
传统TCP (Reno) 的窗口变化:
锯齿形,每次丢包后窗口减半,然后缓慢线性增长
Cubic TCP 的窗口变化:
曲线增长,丢包后回到某个点,然后以立方曲线快速回升
四、流量控制与拥塞控制的协同工作
这是最关键的部分:发送方的实际窗口大小是两者的最小值。
有效窗口 = min(rwnd, cwnd)
其中:
- rwnd(通告窗口):由接收方决定,反映接收方的缓冲区能力
- cwnd(拥塞窗口):由发送方自己维护,反映网络的承载能力
4.1 实际场景分析
场景一:接收方慢,网络快
rwnd = 10KB(接收方缓冲区小)
cwnd = 100KB(网络带宽大)
有效窗口 = min(10KB, 100KB) = 10KB
→ 发送方受限于接收方,这就是流量控制
场景二:接收方快,网络堵
rwnd = 100KB(接收方缓冲区大)
cwnd = 10KB(网络拥塞,窗口被缩小)
有效窗口 = min(100KB, 10KB) = 10KB
→ 发送方受限于网络,这就是拥塞控制
场景三:两者都合适
rwnd = 50KB
cwnd = 50KB
有效窗口 = 50KB
→ 发送方可以充分利用网络带宽和接收方处理能力
4.2 为什么需要区分两者?
有些人可能会问:既然都是限制发送速率,为什么要分成两个机制?
原因在于控制主体不同:
| 机制 | 控制主体 | 信息源 | 调整频率 |
|---|---|---|---|
| 流量控制 | 接收方 | 应用层读取速度 | 每个ACK都更新 |
| 拥塞控制 | 发送方 | 网络丢包/延迟 | 每次丢包或RTT后调整 |
接收方最了解自己处理数据的能力,所以流量控制由接收方主导。而网络状态是发送方通过观察ACK行为推断的,所以拥塞控制由发送方主导。
