TCP协议:滑动窗口与流量控制

在前面的 TCP 学习中,我们已经知道 TCP 为什么可靠。

发送方把数据发送出去以后,并不能立刻把这份数据从自己的发送缓冲区中删除,因为数据在网络中传输的过程中,可能发生丢包。如果发送方迟迟没有收到确认,就必须依靠这份还保留的数据进行重传。

这就产生了一个问题:

如果每发送一个数据段,都必须等到对方 ACK 到达以后才能继续发送,那么 TCP 的效率会非常低。

例如:

复制代码
A                         B

发送数据1  ------------>
          <------------  ACK

发送数据2  ------------>
          <------------  ACK

发送数据3  ------------>
          <------------  ACK

发送数据4  ------------>
          <------------  ACK

每发送一次数据,都要等待一次网络往返。

如果 A 和 B 相距很远,网络往返一次需要几十毫秒甚至几百毫秒,那么大量时间都会浪费在"等待 ACK"上。

因此,TCP 实际通信时不会这么低效。

TCP 会允许发送方在没有收到前一个数据段 ACK 的情况下,继续发送后面的数据,一次可以连续发送多个数据段。

但是,发送多少并不是无限的。

一方面,接收方的接收能力有限;另一方面,发送出去但还没有得到确认的数据又必须保存在发送方,等待可能发生的重传。

因此,TCP 就引入了两个非常重要的机制:

流量控制 和滑动窗口。

流量控制解决的是:

对方现在还能接收多少?

滑动窗口解决的是:

我现在可以连续发送哪些数据,并且哪些数据还不能从发送缓冲区删除?

两者结合起来,才形成了 TCP 高效可靠的批量传输机制。


一、TCP流量控制

1. 为什么需要流量控制

假设主机 A 向主机 B 发送数据。

最简单地理解:

复制代码
主机A发送缓冲区
        |
        | 发送数据
        v
      网络
        |
        v
主机B接收缓冲区
        |
        v
   用户层程序处理

A 的发送速度可能非常快,但是 B 的应用程序处理数据的速度并不一定快。

例如 B 收到 HTTP 请求以后,还需要经过:

复制代码
接收数据
   ↓
从内核接收缓冲区取数据
   ↓
HTTP解析
   ↓
反序列化
   ↓
业务逻辑处理
   ↓
查询数据库
   ↓
文件操作
   ↓
生成响应

所以,"数据已经到达 B"并不代表"B 已经处理完这些数据"。

如果 A 不管 B 的实际处理能力,只顾着高速发送:

复制代码
A:疯狂发送
      ↓
B:接收缓冲区不断增长
      ↓
B:应用层来不及处理
      ↓
接收缓冲区越来越满
      ↓
最终没有空间

此时继续发送的数据就可能因为接收方没有足够空间而被丢弃。

这些数据虽然已经经过了网络传输,却最终无法被接收方正常保存,只能导致后续重传。

这显然是一种资源浪费。

所以 TCP 不希望等到"接收方已经撑不住了"之后再处理,而是希望:

接收方提前告诉发送方:我现在还能接收多少。

这就是 TCP 流量控制。


2. TCP窗口大小字段

TCP 报头中存在一个 16 位窗口大小字段。

这个字段用于通告:

接收方当前还能接收多少数据。

例如:

复制代码
B收到A的数据
      ↓
B的接收缓冲区
      ↓
还剩3000字节空间
      ↓
B回复ACK
      ↓
窗口大小 = 3000

那么 A 收到这个 ACK 后,就知道:

复制代码
B告诉我:

从它当前确认的位置开始,
我最多还可以继续发送3000字节。

因此,TCP 的 ACK 不只是"告诉你数据收到了"。

一个 TCP ACK 中通常还会携带当前的接收窗口信息。

也就是说:

复制代码
ACK = 确认到哪里了
窗口大小 = 还能接收多少

这两个信息共同帮助发送方决定接下来应该怎么发送。


3. 窗口越大和越小分别意味着什么

假设 B 通告:

复制代码
窗口 = 5000

说明 B 当前接收能力比较充足。

A 可以允许更多数据处于"已经发送但尚未确认"的状态。

如果 B 通告:

复制代码
窗口 = 1000

说明 B 的接收缓冲区已经比较紧张。

那么 A 就必须减少自己能够继续发送的数据量。

如果 B 通告:

复制代码
窗口 = 0

那么意思就非常明确:

我现在没有空间继续接收数据了,你先别给我发送数据。

因此:

复制代码
窗口大
  ↓
可以发送更多数据

窗口小
  ↓
减少发送数据

窗口为0
  ↓
暂停发送数据

这就是 TCP 流量控制最核心的思想。


二、TCP窗口为0以后怎么办

1. 为什么不能简单地一直等待

假设 B 的接收缓冲区满了:

复制代码
B接收缓冲区

┌──────────────────────┐
│██████████████████████│
│       已经满了        │
└──────────────────────┘

剩余空间 = 0

于是 B 回复:

复制代码
ACK = xxx
Window = 0

A 收到以后停止发送数据。

但是,接下来又出现一个问题:

B 的应用程序可能过了一会儿把数据取走了。

例如:

复制代码
B接收缓冲区
原来:

┌──────────────────────┐
│██████████████████████│
└──────────────────────┘
Window = 0


应用程序读取了一部分数据


现在:

┌──────────────────────┐
│██████████████        │
└──────────────────────┘
Window > 0

那么 A 怎么知道 B 又有空间了?

因此 TCP 需要一种机制,让发送方能够重新获得最新的窗口信息。


2. 窗口探测

当发送方发现:

复制代码
Window = 0

它不能继续正常发送数据,但并不意味着 TCP 什么都不能做。

发送方可以定期发送窗口探测。

可以简单理解为:

复制代码
A                         B

窗口探测  ------------->

          <------------- ACK + 最新窗口大小

A 通过这种方式询问:

"你现在有没有空间了?"

B 回复时携带当前最新的窗口大小。

如果 B 已经释放出了空间:

复制代码
Window = 0
        ↓
Window = 3000

那么 A 就可以恢复发送。


3. 窗口更新通知

除了 A 主动探测之外,B 也可以主动告诉 A:

"我的接收缓冲区已经有空间了。"

例如:

复制代码
B接收缓冲区满
Window = 0

        ↓
应用层取走数据

        ↓
接收缓冲区出现空间

        ↓
B发送窗口更新通知

        ↓
Window = 3000

于是 A 得知:

复制代码
现在又可以继续发送了。

因此,窗口为零以后,可以把它理解成两种策略共同保证通信不会永久卡住:

复制代码
发送方
  │
  ├── 定期进行窗口探测
  │
  └── 等待接收方主动进行窗口更新

两者并不是同一个东西,但最终目的都是让发送方重新知道:

接收方现在到底还能接收多少。


三、三次握手为什么能够提供初始窗口

1. 第一次发送数据时,怎么知道对方能接收多少

现在又出现一个问题。

TCP 三次握手完成以后,A 第一次真正发送应用数据时:

复制代码
A → B:数据

A 怎么知道 B 的接收能力?

难道还需要再询问一次?

实际上不需要。

因为 TCP 三次握手本身就已经交换过 TCP 报文。

而 TCP 报文头中本身就包含窗口大小信息。

因此:

复制代码
三次握手
    ↓
双方交换TCP报头
    ↓
双方知道对方通告的窗口
    ↓
连接建立
    ↓
第一次发送数据时
已经知道对方初始接收能力

所以:

第一次发送数据,不等于第一次发送 TCP 报文。

在真正发送应用数据之前,双方已经通过三次握手交换过报文,也就已经交换过窗口信息。


2. 三次握手除了建立连接,还有什么作用

三次握手最核心的作用当然是建立 TCP 连接、同步序列号等。

但是从流量控制角度来看,它还有一个重要作用:

让双方在连接建立阶段就能够获得对方的初始接收窗口信息。

于是 TCP 后面就可以直接进入:

复制代码
数据发送
   ↓
ACK确认
   ↓
获得新的窗口信息
   ↓
调整发送范围
   ↓
继续发送

四、滑动窗口到底是什么

流量控制解决了:

对方还能接收多少?

但是,还有一个问题没有解决。

假设 B 告诉 A:

复制代码
Window = 4000

那么 A 能不能这样:

复制代码
发送1000
等待ACK
发送2000
等待ACK
发送3000
等待ACK
发送4000
等待ACK

当然可以,但这样还是慢。

TCP 更希望:

复制代码
发送1000 ───────>
发送2000 ───────>
发送3000 ───────>
发送4000 ───────>

       等待ACK

也就是说:

允许多个数据段同时处于"已经发送但还没有收到 ACK"的状态。

那么到底哪些数据允许直接发送?

这就需要滑动窗口。


五、滑动窗口的本质:发送缓冲区中的一段区域

1. 先把发送缓冲区抽象成数组

真实的 Linux 内核中,TCP 数据并不是简单地放在一个连续的 C 数组里。

底层涉及 sk_buff、队列等复杂结构。

但是为了理解 TCP 的字节流和滑动窗口,我们可以先把发送缓冲区抽象成一个数组:

复制代码
发送缓冲区

┌────┬────┬────┬────┬────┬────┬────┬────┐
│1001│1002│1003│1004│1005│1006│1007│1008│...
└────┴────┴────┴────┴────┴────┴────┴────┘

每一个位置可以理解成对应字节流中的数据。

这样,TCP 的序号就非常容易理解。


2. 滑动窗口就是这个数组中的一段区域

假设:

复制代码
START = 1001
END   = 5001

那么:

复制代码
           滑动窗口
      <-------------->

1001                     5001
 ↓                        ↓
┌──────────────────────────┐
│      可以直接发送的数据    │
└──────────────────────────┘

这部分数据就是当前允许发送、可以暂时不用等待前一个 ACK 的数据范围。

因此:

滑动窗口本质上就是发送缓冲区中,由起始位置和结束位置界定出来的一段逻辑区域。

也可以简单理解成:

复制代码
滑动窗口 = START ~ END这一段区域

六、发送缓冲区为什么可以划分成三个区域

假设现在:

复制代码
START = 1001
END   = 5001

发送缓冲区可以逻辑上划分成三个区域。

复制代码
┌──────────────┬──────────────────────┬─────────────────────┐
│ 已确认数据    │      滑动窗口         │      待发送数据      │
│              │      可发送区域       │                     │
└──────────────┴──────────────────────┴─────────────────────┘
               ↑                      ↑
             START                  END

1. 已确认数据区域

左边的数据:

复制代码
已经发送
    ↓
已经收到ACK
    ↓
确认对方已经收到

既然已经确认,那么这部分数据就没有继续保留的必要了。

所以可以从发送缓冲区中释放。


2. 滑动窗口区域

中间这部分:

复制代码
已经发送的数据
+
允许继续发送的数据

核心特点是:

可以在没有等待前面数据 ACK 的情况下继续发送。

例如:

复制代码
窗口:

┌────────────────────────┐
│ 1001 ... 2000           │
│ 2001 ... 3000           │
│ 3001 ... 4000           │
│ 4001 ... 5000           │
└────────────────────────┘

那么这部分数据就可以批量发送。


3. 待发送区域

滑动窗口右侧的数据:

复制代码
虽然已经准备好了
但是当前窗口没有覆盖到

所以暂时不能继续发送。

必须等待前面的数据被确认,窗口向右移动以后,这部分数据才能进入滑动窗口。


七、滑动窗口为什么叫"滑动"

假设最开始:

复制代码
START = 1001
Window = 4000

END = 5001

那么:

复制代码
1001                         5001
 ↓                             ↓
┌──────────────────────────────┐
│          滑动窗口             │
└──────────────────────────────┘

现在 B 收到了前面的数据,并回复:

复制代码
ACK = 2001

ACK 2001 的意思是:

2001 之前的数据已经连续收到,下一段期待从 2001 开始。

那么 A 的窗口左边界就可以向右移动:

复制代码
原来:

1001                         5001
 ↓                             ↓
┌──────────────────────────────┐
│          Window              │
└──────────────────────────────┘


收到ACK 2001以后:

2001                         6001
 ↓                             ↓
┌──────────────────────────────┐
│          Window              │
└──────────────────────────────┘

窗口整体向右移动了。

这就是"滑动窗口"这个名字的由来。


八、滑动窗口的左边为什么只能向右移动

这是理解 TCP 滑动窗口最关键的地方之一。

假设现在:

复制代码
1001 2001 3001 4001 5001

接收方返回:

复制代码
ACK 1001
ACK 2001
ACK 3001

那么发送方可以依次确认:

复制代码
1001之前确认
        ↓
2001之前确认
        ↓
3001之前确认

所以:

复制代码
START:

1001
 ↓
2001
 ↓
3001
 ↓
4001
 ↓
5001

它只能不断向右。

为什么不能向左?

因为 TCP 的确认序号具有单调推进的特征。

已经确认过的数据,不会因为后面发生什么事情,又重新变成"未确认"。

所以:

滑动窗口的左边界不会向左移动。

它可以:

复制代码
不动
或者
向右

但不会:

复制代码
向左

九、滑动窗口大小为什么会变大、变小或者不变

这里特别容易把两个概念混在一起:

复制代码
窗口的位置

和:

复制代码
窗口的大小

不是一回事。

假设:

复制代码
START = 1001
Window = 4000

END = 5001

后来收到了:

复制代码
ACK = 2001
Window = 4000

那么:

复制代码
START:1001 → 2001
Window:4000 → 4000

窗口向右移动了,但是大小没变。


1. 窗口大小不变

例如:

复制代码
第一次:

START = 1001
Window = 4000
END = 5001


第二次:

START = 2001
Window = 4000
END = 6001

可以看到:

复制代码
START 向右移动
Window大小不变
END也向右移动

所以整个窗口向右移动,但宽度保持不变。


2. 窗口变小

假设:

复制代码
第一次:

START = 1001
Window = 4000
END = 5001

随后:

复制代码
ACK = 2001
Window = 3000

那么:

复制代码
START = 2001
END   = 5001

可以看到:

复制代码
START 向右
END   没有继续向右

于是:

复制代码
窗口宽度:

4000 → 3000

窗口变小了。

这通常意味着接收方当前剩余空间减少了。


3. 窗口变大

例如:

复制代码
START = 2001
Window = 3000

后来 B 的应用程序快速取走了一批数据,接收缓冲区释放出更多空间,于是 B 通告:

复制代码
Window = 5000

那么:

复制代码
START = 2001
Window = 5000
END = 7001

窗口就扩大了。

因此:

窗口的位置由 ACK 推动,窗口的大小由接收方通告的接收能力决定。

这句话非常重要。


十、ACK到底表示什么

TCP 的 ACK 不是简单地表示:

"我收到了这个数据包。"

它的真正意义是:

ACK=N,表示 N 之前的数据已经按序连续收到,下一段希望从 N 开始。

例如:

复制代码
发送:

1001~2000
2001~3000
3001~4000
4001~5000

如果全部按序收到:

复制代码
ACK = 5001

意思就是:

复制代码
5001之前的数据
全部已经连续收到

因此 ACK 本身具有累计确认的特性。


十一、为什么ACK丢了不一定需要重传

假设 A 连续发送:

复制代码
1001~2000
2001~3000
3001~4000
4001~5000

B 都收到了。

正常情况下可能产生:

复制代码
ACK 2001
ACK 3001
ACK 4001
ACK 5001

但是网络中可能出现:

复制代码
ACK 2001 丢失
ACK 3001 丢失
ACK 4001 到达
ACK 5001 到达

A 最后收到了:

复制代码
ACK 5001

那么 A 需要重传前面的数据吗?

不需要。

因为:

复制代码
ACK 5001

已经明确告诉 A:

复制代码
5001之前的数据已经全部按序收到。

所以:

复制代码
ACK 2001丢了
        ↓
ACK 3001丢了
        ↓
ACK 4001丢了
        ↓
ACK 5001到达
        ↓
前面的数据全部确认

这就是 TCP 累计确认的重要意义。

因此可以记住一句话:

收到更大的 ACK,就意味着更小 ACK 所对应的确认范围已经包含在其中。


十二、如果真正的数据丢了怎么办

ACK 丢失和数据丢失是两回事。

现在假设 A 发送:

复制代码
1001~2000
2001~3000
3001~4000
4001~5000
5001~6000
6001~7000

其中:

复制代码
2001~3000

丢失了。

但是:

复制代码
3001~4000
4001~5000
5001~6000
6001~7000

都到达了 B。

这时候 B 能不能直接回复:

复制代码
ACK 7001

不能。

因为 TCP 的累计 ACK 要求数据必须连续。

B 的实际情况是:

复制代码
1001~2000      已收到
2001~3000      丢失
3001~4000      已收到
4001~5000      已收到
5001~6000      已收到
6001~7000      已收到

中间出现了一个缺口:

复制代码
1001~2000
     ↓
2001~3000   ← 缺失
     ↓
3001~4000
4001~5000
5001~6000
6001~7000

所以 B 只能确认到:

复制代码
ACK = 2001

因为:

2001之前的数据已经连续收到,但从2001开始的数据没有连续收到。


十三、重复ACK与快重传

如果后面的数据不断到达:

复制代码
3001~4000
4001~5000
5001~6000
6001~7000

但是:

复制代码
2001~3000

始终没有到达。

那么接收方的累计确认点就卡在:

复制代码
ACK = 2001

于是发送方可能连续收到多个:

复制代码
ACK 2001
ACK 2001
ACK 2001
...

这些 ACK 虽然没有继续向前推进,但它们传递出了一个非常重要的信息:

后面的数据已经到了,但是前面有一个缺口。

当发送方收到足够数量的重复 ACK 后,就可以推断前面的某个数据段很可能丢失,于是提前进行重传。

这就是:

快速重传(Fast Retransmit)。

它的核心思想就是:

复制代码
发现重复ACK
      ↓
判断前面存在缺失数据
      ↓
不用等超时
      ↓
立即重传

十四、为什么还需要超时重传

有人可能会问:

既然有快速重传,为什么还需要超时重传?

因为快速重传有前提:

必须能够收到足够的重复 ACK。

假设:

复制代码
最后一个数据段丢失

后面已经没有更多数据可以到达接收方。

那么:

复制代码
ACK
ACK
ACK

可能根本凑不出来。

此时快速重传无法触发。

所以 TCP 还需要最后一道保险:

复制代码
数据发送
   ↓
等待ACK
   ↓
没有收到确认
   ↓
计时器超时
   ↓
重新发送

这就是:

超时重传。

因此可以把两者理解成:

复制代码
快速重传
    ↓
发现异常比较快
    ↓
提前补发


超时重传
    ↓
快速重传无法判断
    ↓
最终兜底

十五、为什么各种丢包最终都可以归结为"最左侧未确认数据"

这是理解滑动窗口非常重要的一步。

假设当前窗口:

复制代码
┌──────┬──────┬──────┬──────┬──────┐
│ 1001 │ 2001 │ 3001 │ 4001 │ 5001 │
└──────┴──────┴──────┴──────┴──────┘
   ↑
 START

如果最左边的:

复制代码
1001

对应的数据丢了。

那么后面的数据即使都到了:

复制代码
2001
3001
4001
5001

窗口左侧仍然不能越过去。

因为:

复制代码
1001之前

还没有被确认。

所以:

复制代码
START = 1001

一直不动。

最终:

复制代码
重复ACK
   ↓
快速重传1001
   ↓
或者等待超时
   ↓
超时重传1001

当这个数据补回来以后:

复制代码
1001
2001
3001
4001
5001

终于连续了。

于是 ACK 可以一下子跳到:

复制代码
ACK = 5001/5001之后的下一个序号

窗口也随之向右移动。


十六、中间丢包为什么最终也会变成最左侧丢包

假设:

复制代码
1001
2001
3001   ← 丢失
4001
5001

最开始:

复制代码
ACK 2001

说明:

复制代码
1001~2000已经确认

于是窗口左边界从:

复制代码
1001

移动到了:

复制代码
2001

继续接收数据。

但是:

复制代码
3001~4000

没有到达。

那么后面的:

复制代码
4001
5001

即使已经收到,也不能让 ACK 越过这个缺口。

于是:

复制代码
ACK = 3001

窗口卡在:

复制代码
3001

此时对于发送方来说:

复制代码
1001       已确认
2001       已确认
3001       ← 当前最左侧未确认数据
4001       已到达但等待前面的缺口
5001       已到达但等待前面的缺口

所以原本的"中间丢包"已经被转换成:

当前窗口最左侧的数据没有得到确认。

接下来还是:

复制代码
重复ACK
   ↓
快重传
   ↓
或者
   ↓
超时重传

十七、最右侧丢包也是同样的道理

再看:

复制代码
1001
2001
3001
4001
5001   ← 丢失

前面的数据全部正常到达:

复制代码
ACK 2001
ACK 3001
ACK 4001
ACK 5001

最终窗口左边界移动到了:

复制代码
5001

但是:

复制代码
5001

丢了。

那么后面暂时没有新的数据可以帮助产生重复 ACK。

于是可能无法触发快速重传。

最终只能依靠:

复制代码
超时重传

而一旦:

复制代码
5001

被重新发送并成功到达,ACK 就继续向右推进。

所以从滑动窗口的角度来看:

无论原始丢包发生在最左边、中间还是最右边,随着前面的数据不断被确认,最终都会转化成"当前窗口最左侧存在一个尚未确认的数据"。

这也是理解 TCP 滑动窗口丢包处理非常重要的一种抽象方式。课程材料也明确采用了"不同位置的丢包最终坍缩为最左侧丢包问题"的理解方式。


十八、未确认的数据为什么不能从发送缓冲区删除

现在就可以回答前面留下的问题:

一个数据已经发送出去了,但是还没有收到 ACK,发送方能不能直接删除?

不能。

例如:

复制代码
A发送:

1001~2000

数据已经进入网络:

复制代码
A
 |
 | 1001~2000
 v
网络
 |
 v
B

但是 A 还没有收到 ACK。

那么 A 不能认为:

复制代码
"数据应该到了,我删了吧。"

因为:

复制代码
数据可能丢失

一旦数据真的丢失:

复制代码
A发现没有ACK
      ↓
需要重传
      ↓
但是原数据已经被删除
      ↓
没有数据可以重传

所以:

已经发送但尚未确认的数据,必须暂时保存在发送缓冲区中。

这也是滑动窗口存在的重要原因之一。


十九、ACK到达以后,数据才可以释放

假设发送缓冲区:

复制代码
┌──────────┬──────────────────────┬───────────────┐
│已确认数据 │       滑动窗口        │    待发送数据  │
└──────────┴──────────────────────┴───────────────┘

如果收到 ACK:

复制代码
ACK = 3001

说明:

复制代码
3001之前的数据
已经连续确认

那么:

复制代码
1001~2000
2001~3000

这些数据就不再需要参与重传。

因此可以从发送缓冲区中释放。

窗口左边界移动:

复制代码
原来:

1001
 ↓
┌─────────────────────────┐
│       滑动窗口           │
└─────────────────────────┘


收到ACK 3001:

          3001
            ↓
┌─────────────────────────┐
│       滑动窗口           │
└─────────────────────────┘

于是释放出来的空间又可以重新用于保存新的待发送数据。

这就形成了一个不断循环的过程:

复制代码
应用层写入数据
      ↓
进入发送缓冲区
      ↓
进入滑动窗口
      ↓
发送
      ↓
等待ACK
      ↓
收到确认
      ↓
窗口向右移动
      ↓
左侧数据释放
      ↓
释放出的空间重新利用
      ↓
新的数据进入

二十、滑动窗口和流量控制到底是什么关系

到这里可以把两个概念彻底串起来。

流量控制回答:

对方现在还能接收多少?

而:

滑动窗口负责把这个"还能接收多少"具体落实到发送方当前可以发送的数据范围。

例如 B 告诉 A:

复制代码
Window = 4000

那么 A 就可以把自己的发送缓冲区抽象成:

复制代码
             当前允许发送
        <-------------------->
        START              END

┌────────┬────────────────────┬───────────┐
│已确认   │     滑动窗口        │ 待发送     │
└────────┴────────────────────┴───────────┘

所以:

复制代码
接收方剩余空间
      ↓
通告Window
      ↓
发送方调整滑动窗口大小
      ↓
决定当前最多允许发送多少
      ↓
实现流量控制

因此可以说:

滑动窗口是 TCP 流量控制的重要实现机制。


二十一、滑动窗口为什么只能向右滑动

现在再总结一下窗口的位置和大小。

窗口左边界:

复制代码
START = 最新确认位置

随着 ACK 不断向前:

复制代码
START:

1001
 ↓
2001
 ↓
3001
 ↓
4001
 ↓
5001

因此它只能:

复制代码
不动
或者向右

不能向左。

而窗口大小:

复制代码
Window = 对方当前通告的接收能力

则可能:

复制代码
变大
变小
不变

所以一定要把这两个概念分开:

复制代码
START
 ↓
决定窗口在哪里


Window Size
 ↓
决定窗口有多宽

可以记成一句话:

ACK决定窗口向哪里移动,接收窗口决定窗口有多大。


二十二、滑动窗口会不会越界

如果把发送缓冲区简单看成普通数组:

复制代码
0 1 2 3 4 5 6 7

那么 START 和 END 不断向右移动,最终当然可能超过数组末尾。

例如:

复制代码
0 1 2 3 4 5 6 7
              ↑
             END

继续滑动:

0 1 2 3 4 5 6 7
                ↑
               越界

实际的缓冲区不能真的无限向右扩张。

因此在理解 TCP 缓冲区时,可以把它抽象成一个环形结构。


二十三、用环形数组理解发送缓冲区

例如一个简单的环形缓冲区:

复制代码
             ┌───────┐
        ┌───>│   0   │───┐
        │    ├───────┤   │
        │    │   1   │   │
        │    ├───────┤   │
        │    │   2   │   │
        │    ├───────┤   │
        │    │   3   │   │
        │    ├───────┤   │
        │    │   4   │   │
        │    ├───────┤   │
        │    │   5   │   │
        │    ├───────┤   │
        │    │   6   │   │
        │    ├───────┤   │
        └────│   7   │<──┘
             └───────┘

当 END 到达最后:

复制代码
END = 7

继续移动以后:

复制代码
END = 0

物理位置回到数组开头。

逻辑上的数据序号仍然继续增长。

因此:

复制代码
逻辑位置:

1001 → 2001 → 3001 → 4001 → 5001 → ...


物理位置:

0 → 1 → 2 → 3 → 4 → 5 → ...
                       ↓
                      回绕

这就是环形缓冲区的思想。

实际 Linux 内核中的 TCP 缓冲管理比简单的环形数组复杂得多,这里的环形数组主要用于建立滑动窗口的直观模型。


二十四、把整个TCP滑动窗口过程串起来

现在假设:

复制代码
A向B发送大量数据

B 当前告诉 A:

复制代码
接收窗口 = 4000

于是 A 建立当前发送窗口:

复制代码
START = 1001
END   = 5001

发送缓冲区可以理解成:

复制代码
┌────────────┬────────────────────────┬───────────────┐
│ 已确认数据  │       滑动窗口          │    待发送数据  │
└────────────┴────────────────────────┴───────────────┘
             ↑                        ↑
           START                    END

A 可以连续发送:

复制代码
1001~2000
2001~3000
3001~4000
4001~5000

而不需要每发送一个都等待对应 ACK。


1. 正常接收

B 全部收到:

复制代码
1001~2000
2001~3000
3001~4000
4001~5000

于是:

复制代码
ACK = 5001

A 收到 ACK 后:

复制代码
START = 5001

如果 B 同时通告:

复制代码
Window = 3000

那么:

复制代码
END = 5001 + 3000

新的窗口继续向右移动。


2. ACK丢失

假设:

复制代码
ACK 2001   丢失
ACK 3001   丢失
ACK 4001   到达
ACK 5001   到达

A 最终收到:

复制代码
ACK 5001

于是直接知道:

复制代码
5001之前的数据
全部已经确认

前面的 ACK 即使丢失,也不需要重传数据。


3. 中间数据丢失

假设:

复制代码
1001~2000    到达
2001~3000    丢失
3001~4000    到达
4001~5000    到达

B 不能确认到 5001。

只能不断告诉 A:

复制代码
ACK 2001

于是 A 发现:

复制代码
窗口左侧卡住

随后根据重复 ACK 或超时机制:

复制代码
快速重传
或者
超时重传

补发:

复制代码
2001~3000

一旦这个缺口补上:

复制代码
1001~2000
2001~3000
3001~4000
4001~5000

数据重新连续。

ACK 就可以向前推进。


二十五、滑动窗口、流量控制、ACK、重传之间的完整关系

现在可以把整个 TCP 机制串成一条链:

复制代码
                TCP可靠通信
                     │
        ┌────────────┴────────────┐
        │                         │
    流量控制                   确认应答
        │                         │
        ↓                         ↓
 接收方告诉发送方            ACK告诉发送方
 还能接收多少               数据确认到哪里
        │                         │
        └────────────┬────────────┘
                     ↓
                 滑动窗口
                     │
        ┌────────────┼────────────┐
        ↓            ↓            ↓
     批量发送      保存未确认     窗口移动
        │            数据           │
        │            │              │
        ↓            ↓              ↓
     提高效率      丢包可重传      释放旧数据
                     │
                     ↓
                发现数据缺失
                     │
             ┌───────┴───────┐
             ↓               ↓
          快速重传         超时重传
             │               │
             └───────┬───────┘
                     ↓
                  数据补齐
                     ↓
                 ACK继续前进
                     ↓
                 窗口继续右移

所以 TCP 的这些知识点并不是互相独立的。

它们实际上是一整套机制。


二十六、最后总结:真正理解滑动窗口只需要抓住几个核心点

第一,流量控制解决的是接收方来不及接收的问题。

接收方通过 TCP 报头中的窗口信息告诉发送方:

复制代码
我现在还能接收多少。

第二,滑动窗口解决的是 TCP 如何批量发送数据的问题。

它允许:

复制代码
前一个数据还没有收到ACK
        ↓
后面的数据也可以继续发送

第三,发送出去但没有收到 ACK 的数据不能立即删除。

必须保存在发送缓冲区中:

复制代码
未确认
 ↓
继续保留
 ↓
必要时重传

收到 ACK 后:

复制代码
确认
 ↓
窗口向右移动
 ↓
旧数据释放
 ↓
空间重新利用

第四,ACK 是累计确认。

复制代码
ACK = N

表示:

复制代码
N之前的数据已经连续收到

所以即使中间几个 ACK 丢失,只要后面收到了更大的 ACK,也不需要因此重传前面的数据。

第五,如果数据中间出现缺口:

复制代码
1001
2001  ← 丢失
3001
4001
5001

后面的数据即使已经到达,也不能让累计 ACK 越过缺口。

窗口左边界最终会卡在:

复制代码
2001

这就是滑动窗口处理中最核心的异常状态。

第六,快速重传和超时重传共同负责解决丢包:

复制代码
重复ACK足够
    ↓
快速重传

重复ACK不足
    ↓
等待超时
    ↓
超时重传

第七,窗口有两个非常重要的属性:

复制代码
窗口位置
    ↓
由ACK确认位置推动
    ↓
只能向右移动


窗口大小
    ↓
由接收方通告的接收能力决定
    ↓
可以变大、变小或者不变

最后,把 TCP 滑动窗口浓缩成一句话:

TCP通过接收方通告的窗口大小限制发送方能够同时发送的数据量,再利用ACK不断确认已经连续收到的数据,使发送窗口向右移动;未确认的数据始终保留在窗口中,发生丢包时通过快速重传或超时重传补齐缺口,从而同时实现流量控制、高效批量传输和可靠传输。

而从整个 TCP 学习链路来看,可以把它进一步记成:

复制代码
接收方有多少空间
        ↓
通告接收窗口
        ↓
发送方确定滑动窗口
        ↓
一次批量发送多个数据段
        ↓
ACK累计确认
        ↓
窗口向右滑动
        ↓
已确认数据释放
        ↓
新的数据进入窗口
        ↓
继续批量发送

如果发生丢包
        ↓
ACK卡住
        ↓
快速重传 / 超时重传
        ↓
补齐缺口
        ↓
ACK继续向右
        ↓
滑动窗口继续向右

这就是 TCP"滑动窗口 + 流量控制 + 确认应答 + 重传机制"之间真正的完整关系。

相关推荐
156082072191 小时前
使用JFMRFVU3P多通道同步相位测试
网络·人工智能
2401_868534782 小时前
网络冗余设计
网络
well06122 小时前
Linux 文件相关底层知识
linux·运维·服务器
wangbing11253 小时前
开发指南148-WebSocket-前端查报文
网络·websocket·网络协议
ESDWAN3 小时前
企业跨境网络合规建设指南:从线路选择到数据出境的全流程方案
网络·架构
库拉镜像AI牛牛3 小时前
漫剧工作室量产方案:依托知漫剧 AI 短剧降本增效
大数据·服务器·前端·人工智能·语音识别
wuyk5553 小时前
《WiFi 嵌入式物联网开发全套实战》| 第 30 章 WiFi 抗干扰、抗闪断、网络抖动过滤量产策略
网络
不会就选b3 小时前
Linux之TCP<2>
linux·网络·tcp/ip
沫璃染墨4 小时前
《从零入门Linux系统篇(五十八):线程篇·十一——线程安全与死锁详解:从可重入到多锁管理》
linux·服务器·开发语言·c++·驱动开发·安全·架构