TCP中的流量控制
作用
核心目标:防止发送方发送数据过快、过多,导致接收方的缓冲区溢出。
可以把它想象成一个对话:
-
发送方:一个语速很快的演讲者。
-
接收方:一个记笔记的人,但他的手边只有一个大小有限的笔记本(接收缓冲区)。
-
流量控制就是接收方不断地告诉演讲者:“慢一点,我的笔记本快写不下了!”或者“好了,现在可以继续讲了。”
如果没有流量控制,接收方的缓冲区一旦被填满,后续到达的数据包就会被丢弃,导致大量的重传,浪费网络资源。
关键点:流量控制解决的是点对点(发送方和接收方之间)的速度不匹配问题,它与解决网络路径拥堵的拥塞控制是两个不同的概念,但通常会协同工作。
实现流量控制的核心机制:滑动窗口
TCP 的流量控制是通过一个称为 “滑动窗口” 的机制来实现的。这个窗口的大小决定了发送方在未收到确认的情况下,最多能发送多少字节的数据。
核心思想:一种“管道管理”技术
想象一下,发送方要向接收方通过一根管道送水(数据)。如果每送一桶就停下来等一个回执(ACK),效率太低了。滑动窗口允许发送方连续发送多桶水,形成一个“在途的列车”,从而填满管道,提高效率。
但关键是:这个“列车”不能太长,否则会溢出接收方的“蓄水池”(接收缓冲区)。
当然理解这个机制的话,我们先需要先解几个关键字段和概念:
1.TCP 段首部相关字段:
-
序列号:发送方为每个字节数据分配的唯一编号。
-
确认号:接收方期望收到的下一个字节的序列号。意味着确认号之前的所有数据都已被正确接收。
-
窗口大小:接收方在 TCP 段首部中通告的一个字段,告诉发送方自己当前还有多少可用的接收缓冲区空间。这就是流量控制的直接指挥棒。
我们把发送方要发送的数据想象成一个长长的字节流。滑动窗口就是这个字节流上的一个“窗口”,它规定了哪些数据是允许被发送的。这个窗口由三个指针界定,并分为四个部分。
我们用一个具体的例子来图解。假设:
-
字节流序列号从 1 开始。
-
当前发送方最后未确认的字节是 SND.UNA = 1。
-
接收方通告的窗口大小 RWND = 10。
-
下一个要发送的字节是 SND.NXT = 4。
下图展示了此刻发送方的视角:
... 已发送并已确认 ... | 已发送但未确认 | 允许发送但尚未发送 | 禁止发送 ...
发送字节流: [ ... 1, 2, 3 | 4, 5, 6 | 7, 8, 9, 10 | 11, 12, 13, 14, 15, ... ]^ ^ ^ ^SND.UNA SND.NXT SND.UNA + RWND (后续数据)| | || | |窗口左边界 下一个发送位置 窗口右边界<------------------------->发送窗口 (Size = RWND = 10)<-----> | <--------------->已发送待ACK 可用窗口(Size=3) (Size=7)
我们来详细解释图中的每一个部分和指针:
- 三个关键指针
-
SND.UNA(Send Unacknowledged):窗口左边界。指向序列号最小的、已发送但还未收到确认的字节。在这个例子中是 1。所有在它左边的数据(序列号更小)都已经被对方确认接收了。
-
SND.NXT(Send Next):下一个要发送的字节。指向即将要发送的第一个字节。在这个例子中是 4。
-
SND.UNA + RWND:窗口右边界。由 SND.UNA 加上接收方通告的窗口大小 RWND 计算得出。在这个例子中是 1 + 10 = 11。这意味着序列号为 11 及以后的数据目前禁止发送。
- 窗口内的四个区域
基于以上三个指针,窗口被划分为三个区域,窗口外还有一个区域:
-
1 已发送并已确认(Sent and Acknowledged)
- SND.UNA 左边的所有数据。例如序列号 … 1, 2, 3 之前的数据。发送方可以安心地将这些数据从缓存中清除。
-
2 已发送但未确认(Sent But Not Yet Acknowledged)
- 位于 [SND.UNA, SND.NXT) 之间。在这个例子中是 [4, 5, 6]。发送方已经发出这些数据,但还在等待接收方的ACK。这些数据必须被保留在缓存中,因为可能需要重传。
-
3 允许发送但尚未发送(Available to Send)
- 位于 [SND.NXT, SND.UNA + RWND) 之间。在这个例子中是 [7, 8, 9, 10]。这是发送方立即就可以发送的数据,不需要等待任何ACK。这部分也称为 可用窗口。
-
4 禁止发送(Not Available to Send)
- SND.UNA + RWND 右边的所有数据。在这个例子中是 11 及以后。发送方必须等待窗口滑动后,才能发送这些数据。
“滑动”是如何发生的?
“滑动”的本质是窗口边界的移动,它由两个事件触发:
-
收到新的ACK(推动左边界)
-
收到新的窗口通告(更新右边界)
让我们延续上面的例子,看看接下来会发生什么。
步骤 1:收到ACK,左边界滑动
假设发送方收到了一个ACK包,其中:
-
确认号 = 7
-
新通告窗口 RWND = 8
这意味着接收方成功收到了序列号 1 到 6 的所有数据,并期望下一个收到序列号为 7 的数据。同时,接收方通知发送方,它的可用缓冲区现在只有 8 个字节了。
发送方如何处理?
-
移动左边界 SND.UNA:从 1 滑动到 7。
-
更新右边界:新的右边界 = 新的 SND.UNA + 新的 RWND = 7 + 8 = 15。
-
更新 SND.NXT:它原本指向 4,但现在数据 4,5,6 已被确认,所以它仍然指向下一个要发送的字节(可能是 10 之后,取决于期间是否发送了数据)。
滑动后的状态如下图所示:
... 已发送并已确认 ... |已发送但未确认| 允许发送但尚未发送 | 禁止发送 ...
发送字节流: [ ... 1, 2, 3, 4, 5, 6 | 7, 8, 9 | 10, 11, 12, 13, 14 | 15, 16, ... ]^ ^ ^SND.UNA SND.NXT 新窗口右边界| | || | |新窗口左边界 下一个发送位置 (SND.UNA+RWND=15)<------------------------>新发送窗口 (Size = RWND = 8)<-----> | <------------->已发送待ACK 可用窗口(Size=3) (Size=5)
你看到了什么?
-
窗口整体向右“滑动”了。
-
原本在“禁止发送”区域的数据 [11, 12, 13, 14] 现在进入了“可用窗口”,可以被发送了。
-
同时,因为接收方通告的新窗口变小了(从10变为8),窗口的宽度(大小)也收缩了。
步骤 2:发送数据,移动 SND.NXT
发送方现在可以利用新的可用窗口 [10, 11, 12, 13, 14] 来发送数据。比如,它连续发送了数据 10, 11, 12。
此时,SND.NXT 指针会从 10 移动到 13。
... 已发送并已确认 ... | 已发送但未确认 | 允许发送但尚未发送 | 禁止发送 ...
发送字节流: [ ... 1, 2, 3, 4, 5, 6 | 7, 8, 9, 10, 11, 12 | 13, 14 | 15, 16, ... ]^ ^ ^SND.UNA SND.NXT 窗口右边界<------------------------>发送窗口 (Size = 8)<------------------> | <->已发送待ACK 可用窗口(Size=6) (Size=2)
此时,可用窗口只剩下 2 个字节(13, 14)了。
小结
-
窗口的本质:一个在发送方字节流上动态变化的许可区域,规定了“能发什么”。
-
“滑动”的含义:
-
左边界滑动:由ACK驱动,表示数据被成功接收,发送方可以清理缓存并向前推进。
-
右边界滑动:由接收方的窗口通告驱动,表示接收方处理能力的变化,从而控制发送方的上限。
-
这个左右边界独立或协同移动的过程,就是“滑动”。
-
-
流量控制的实现:接收方通过减小通告窗口 RWND,来迫使发送方收缩其窗口的右边界,从而降低发送速率。当接收方缓冲区快满时,它甚至可以将 RWND 设为 0,此时发送方的可用窗口为 0,完全停止发送。
三、流量控制的工作流程
整个过程是一个动态的、持续反馈的循环:
1.连接建立:
- 在 TCP 三次握手时,双方会互相通告自己的初始接收窗口大小。
2.数据传输:
-
发送方:根据当前的 SND.WND(来自接收方的通告窗口)来发送数据。它不能发送超过 SND.UNA + SND.WND 的数据。
-
接收方:接收到数据后,将其放入接收缓冲区。然后,应用程序会从缓冲区中读取数据,释放出空间。
-
接收方发送 ACK:
-
接收方处理完数据后,会向发送方发送一个确认段(ACK)。
-
这个 ACK 包中包含两个关键信息:
-
确认号:RCV.NXT,告诉发送方我已经成功收到哪些数据。
-
窗口大小:更新后的 RCV.WND,告诉发送方我现在还有多少空闲缓冲区。
-
-
3.发送方处理 ACK 并更新窗口:
-
收到 ACK 后,发送方知道 ACK号 之前的数据都已被成功接收,于是将发送窗口的左边界 SND.UNA 向右滑动到 ACK号 的位置。
-
同时,发送方用 ACK 段中携带的新窗口大小来更新自己的 SND.WND,从而确定窗口的右边界。
-
这个“左边界向右滑动,右边界根据新窗口扩展”的过程,就是**“滑动窗口”**名字的由来。窗口像是一个在字节流上向右滑动的框,框的大小还可以动态变化。
举例说明:
假设:
-
初始序列号为 0。
-
接收方初始通告窗口 RWND = 4000 字节。
-
发送方每次发送 1000 字节的数据。
-
接收方缓冲区总大小为 5000 字节。
步骤 | 发送方动作 | 接收方状态 (缓冲区已用/剩余) | 接收方通告窗口 (RWND) | 发送方窗口变化 |
---|---|---|---|---|
1 | 发送 seq=0-999 | 1000/4000 | 4000 | 窗口 [0, 4000) |
2 | 发送 seq=1000-1999 | 2000/3000 | 3000 | 窗口 [0, 3000) 注意右边界左移 |
3 | 收到 ACK=1000 | 应用程序读取了500字节 (1500/3500) | 3500 | 窗口左边界滑到1000,窗口变为 [1000, 4500) |
4 | 发送 seq=2000-2999 | 2500/2500 | 2500 | 窗口 [1000, 3500) |
5 | 收到 ACK=2000 | 应用程序又读取了1500字节 (1000/4000) | 4000 | 窗口左边界滑到2000,窗口变为 [2000, 6000) |
关键观察:
-
在步骤2,发送方虽然还没收到ACK,但收到了一个更小的窗口通告(3000),它立即收缩了自己的发送窗口右边界,这就是流量控制的作用。
-
在步骤3,发送方收到ACK,左边界滑动,同时根据新的窗口大小(3500)更新了右边界,窗口得以“滑动”并“扩大”。
特殊情况和解决方案:零窗口与死锁
如果接收方的缓冲区满了,应用程序没有及时读取数据,会发生什么?
-
零窗口:接收方会向发送方通告一个 窗口大小 = 0。
-
发送方动作:当发送方收到零窗口通告后,它会停止发送数据。
-
潜在死锁:如果之后接收方缓冲区有空闲了(应用程序读取了数据),它会通告一个新的非零窗口。但如果这个非零窗口的 ACK 包在网络中丢失了,发送方会一直等待,接收方也在等待数据,这就形成了死锁。
解决方案:持续计时器
为了解决零窗口死锁,TCP 设计了持续计时器。
-
当发送方因为零窗口而停止发送时,它会启动一个持续计时器。
-
计时器超时后,发送方会发送一个很小的探测段(通常只有1字节数据)。
-
这个探测段有两个作用:
-
提醒接收方重新通告窗口大小。
-
本身可能被 ACK,而 ACK 中会携带当前的窗口大小。
-
-
如果窗口仍然为0,发送方会重置持续计时器,重复此过程;如果窗口打开了,数据传输就可以恢复。
流量控制与拥塞控制的区别与协作
这是一个非常重要的概念区分:
协作关系:
发送方实际能发送的数据量,受制于以下两者的最小值:
(实际发送窗口 = min(接收方通告窗口, 发送方拥塞窗口))
-
即使接收方说“你还可以发 10000 字节”,如果网络很拥堵(拥塞窗口只有 1000 字节),发送方也最多只能发 1000 字节。
-
反之,即使网络很通畅(拥塞窗口很大),如果接收方说“我只能收 1000 字节”,发送方也不能发更多。
小结
TCP 流量控制是一个精巧的端到端的反馈机制,它通过:
-
接收方在 ACK 中通告其接收窗口来指导发送方的行为。
-
发送方根据这个窗口维护一个滑动窗口,确保发送的数据不会超出接收方的处理能力。
-
通过 持续计时器 等机制处理零窗口死锁的特殊情况。
-
与拥塞控制协同工作,共同保证了 TCP 连接的可靠性和对网络资源的公平性。
正是这种细致入微的设计,使得 TCP 能够在复杂多变的网络环境中成为一个稳定可靠的传输协议。