2025/8/7 10:43 4.2 TCP 重传、滑动窗⼝ 、流量控制、拥塞控制 | ⼩林coding
|  |  |
| *4.2* *TCP* 重传、滑动窗⼝ 、流量控制、拥塞控制 TCP 巨复杂 ,它为了保证可靠性 ,⽤了巨多的机制来保证 ,真是个 Γ伟⼤」 的协议 ,写着写着发现 这⽔太深了。。。本⽂的全部图⽚都是⼩林绘画的 ,⾮常的⾟苦且累 ,不废话了 ,直接进⼊正⽂ ,Go!相信⼤家都知道 TCP 是⼀个可靠传输的协议 ,那它是如何保证可靠的呢?为了实现可靠性传输 ,需要考虑很多事情 ,例如数据的破坏、丢包、重复以及分⽚顺序混乱等问 题。如不能解决这些问题 ,也就⽆从谈起可靠传输。那么 ,TCP 是通过序列号、确认应答、重发控制、连接管理以及窗⼝控制等机制实现可靠性传输 的。今天 ,将重点介绍 TCP 的重传机制、滑动窗⼝ 、流量控制、拥塞控制。 | |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
[https://xiaolincoding.com/network/3_tcp/tcp_feature.html 1/39](https://xiaolincoding.com/network/3_tcp/tcp_feature.html1/39)2025/8/7 10:43 4.2 TCP 重传、滑动窗⼝ 、流量控制、拥塞控制 | ⼩林coding
|  |  |
| 重传机制TCP 实现可靠传输的⽅式之⼀ ,是通过序列号与确认应答。在 TCP 中 , 当发送端的数据到达接收主机时 ,接收端主机会返回⼀个确认应答消息 ,表⽰已收到 消息。 但在错综复杂的⽹络 ,并不⼀定能如上图那么顺利能正常的数据传输 ,万⼀数据在传输过程中丢 失了呢?所以 TCP 针对数据包丢失的情况 ,会⽤重传机制解决。 | |

 超时重传
 快速重传  SACK
 D-SACK
| ------------------------------------------------------------ | ------------------------------------------------------------ |超时重传
重传机制的其中⼀个⽅式 ,就是在发送数据时 ,设定⼀个定时器, 当超过指定的时间后 ,没有收 到对⽅的 ACK 确认应答报⽂ ,就会重发该数据 ,也就是我们常说的超时重传。
TCP 会在以下两种情况发⽣超时重传:
 数据包丢失
 确认应答丢失


超时时间应该设置为多少呢?
RTT 指的是数据发送时刻到接收到确认的时刻的差值 ,也就是包的往返时间。
超时重传时间是以 RTO ( Retransmission Timeout 超时重传时间)表⽰。
假设在重传的情况下 ,超时时间 RTO Γ较⻓或较短」 时 ,会发⽣什么事情呢?

上图中有两种超时时间不同的情况:
 当超时时间 *RTO* 较⼤时 ,重发就慢 ,丢了⽼半天才重发 ,没有效率 ,性能差;
 当超时时间 *RTO* 较⼩时 ,会导致可能并没有丢就重发 ,于是重发的就快 ,会增加⽹络拥塞 ,导 致更多的超时 ,更多的超时导致更多的重发。
精确的测量超时时间 RTO 的值是⾮常重要的 ,这可让我们的重传机制更⾼效。
根据上述的两种情况 ,我们可以得知 ,超时重传时间 *RT**O* 的值应该略⼤于报⽂往返 *RTT* 的值。

⾄此 ,可能⼤家觉得超时重传时间 RTO 的值计算 ,也不是很复杂嘛。
好像就是在发送端发包时记下 t0 ,然后接收端再把这个 ack 回来时再记⼀个 t1 ,于是 RTT = t1 – t0 。没那么简单 ,这只是⼀个采样 ,不能代表普遍情况。实际上 Γ报⽂往返 RTT 的值」是经常变化的 ,因为我们的⽹络也是时常变化的。也就因为 Γ报⽂ 往返 RTT 的值」 是经常波动变化的 ,所以 Γ超时重传时间 RTO 的值」应该是⼀个动态变化的值。
我们来看看 Linux 是如何计算 RTO 的呢?
估计往返时间 ,通常需要采样以下两个:
 需要 TCP 通过采样 RTT 的时间 ,然后进⾏加权平均 ,算出⼀个平滑 RTT 的值 ,⽽且这个值还是 要不断变化的 ,因为⽹络状况不断地变化。
 除了采样 RTT ,还要采样 RTT 的波动范围 ,这样就避免如果 RTT 有⼀个⼤的波动的话 ,很难被 发现的情况。
RFC6289 建议使⽤以下的公式计算 RTO:
其中 SRTT 是计算平滑的RTT , DevRTR 是计算平滑的RTT 与 最新 RTT 的差距。
在 Linux 下 ,***\*α\**** ***\*=\**** ***\*0.125\**** ,***\*β\**** ***\*=\**** ***\*0.25\**** , ***\*μ\**** ***\*=\**** ***\*1\**** ,***\*∂\**** ***\*=\**** ***\*4\****。别问怎么来的 ,问就是⼤量实验中调出来的。 如果超时重发的数据 ,再次超时的时候 ,⼜需要重传的时候 ,TCP 的策略是超时间隔加倍。也就是每当遇到⼀次超时重传的时候 ,都会将下⼀次超时时间间隔设为先前值的两倍。两次超 时 ,就说明⽹络环境差 ,不宜频繁反复发送。
超时触发重传存在的问题是 ,超时周期可能相对较⻓。那是不是可以有更快的⽅式呢?
于是就可以⽤ Γ快速重传」机制来解决超时重发的时间等待。
快速重传
TCP 还有另外⼀种快速重传( *Fast* *Retransmit*)机制 ,它不以时间为驱动 ,⽽是以数据驱动重传。 快速重传机制 ,是如何⼯作的呢?其实很简单 ,⼀图胜千⾔。
在上图 ,发送⽅发出了 1 ,2 ,3 ,4 ,5 份数据:
 第⼀份 Seq1 先送到了 ,于是就 Ack 回 2;
 结果 Seq2 因为某些原因没收到 ,Seq3 到达了 ,于是还是 Ack 回 2;
 后⾯的 Seq4 和 Seq5 都到了 ,但还是 Ack 回 2 ,因为 Seq2 还是没有收到;
 发送端收到了三个 *Ack* *=* *2* 的确认 ,知道了 *Seq2* 还没有收到 ,就会在定时器过期之前 ,重传 丢失的 *Seq2*。
 最后 ,收到了 Seq2 ,此时因为 Seq3 ,Seq4 ,Seq5 都收到了 ,于是 Ack 回 6 。
所以 ,快速重传的⼯作⽅式是当收到三个相同的 ACK 报⽂时 ,会在定时器过期之前 ,重传丢失的 报⽂段。
快速重传机制只解决了⼀个问题 ,就是超时时间的问题 ,但是它依然⾯临着另外⼀个问题。就是
重传的时候 ,是重传⼀个,还是重传所有的问题。
举个例⼦ ,假设发送⽅发了 6 个数据 ,编号的顺序是 Seq1 ~ Seq6 ,但是 Seq2、Seq3 都丢失 了 ,那么接收⽅在收到 Seq4、Seq5、Seq6 时 ,都是回复 ACK2 给发送⽅ ,但是发送⽅并不清楚


 如果只选择重传 Seq2 ⼀个报⽂ ,那么重传的效率很低。 因为对于丢失的 Seq3 报⽂ ,还得在后 续收到三个重复的 ACK3 才能触发重传。
 如果选择重传 Seq2 之后已发送的所有报⽂ ,虽然能同时重传已丢失的 Seq2 和 Seq3 报⽂ ,但 是 Seq4、Seq5、Seq6 的报⽂是已经被接收过了 ,对于重传 Seq4 〜Seq6 折部分数据相当于做 了⼀次⽆⽤功 ,浪费资源。
可以看到 ,不管是重传⼀个报⽂ ,还是重传已发送的报⽂ ,都存在问题。
为了解决不知道该重传哪些 TCP 报⽂ ,于是就有 SACK ⽅法。
*SACK* ⽅法
还有⼀种实现重传机制的⽅式叫: SACK ( Selective Acknowledgment), 选择性确认。
这种⽅式需要在 TCP 头部「选项」字段⾥加⼀个 SACK 的东西 ,它可以将已收到的数据的信息发 送给 Γ发送⽅」,这样发送⽅就可以知道哪些数据收到了 ,哪些数据没收到 ,知道了这些信息 ,就 可以只重传丢失的数据。
如下图 ,发送⽅收到了三次同样的 ACK 确认报⽂ ,于是就会触发快速重发机制 ,通过 SACK 信息 发现只有 200~299 这段数据丢失 ,则重发时 ,就只选择了这个 TCP 段进⾏重复。

***\*Duplicate\**** ***\*SACK\****Duplicate SACK ⼜称 D-SACK ,其主要使⽤了 *SACK* 来告诉 Γ发送⽅」有哪些数据被重复接收 了。
下⾯举例两个栗⼦ ,来说明 D-SACK 的作⽤ 。
**栗⼦⼀号:**ACK 丢包

 Γ接收⽅」 发给 Γ发送⽅」 的两个 ACK 确认应答都丢失了 ,所以发送⽅超时后 ,重传第⼀个数 据包(3000 ~ 3499)
 于是 Γ接收⽅」发现数据是重复收到的 ,于是回了⼀个 *SACK* *=* *3000~3500* ,告诉 Γ发送⽅」 3000~3500 的数据早已被接收了 ,因为ACK 都到了 4000 了 ,已经意味着 4000 之前的所有数 据都已收到 ,所以这个 SACK 就代表着 D-SACK 。
 这样 Γ发送⽅」 就知道了 ,数据没有丢 ,是 Γ接收⽅」 的 ACK 确认报⽂丢了。
栗⼦⼆号: ⽹络延时
 数据包( 1000~1499) 被⽹络延迟了 ,导致 Γ发送⽅」 没有收到 Ack 1500 的确认报⽂。
 ⽽后⾯报⽂到达的三个相同的 ACK 确认报⽂ ,就触发了快速重传机制 ,但是在重传后 ,被延迟 的数据包( 1000~1499)⼜到了 Γ接收⽅」;
 所以 Γ接收⽅」 回了⼀个 *SACK=1000~1500* ,因为 *ACK* 已经到了 *3000* ,所以这个 *SACK* 是 *D-* *SACK* ,表⽰收到了重复的包。
 这样发送⽅就知道快速重传触发的原因不是发出去的包丢了 ,也不是因为回应的ACK 包丢了, ⽽是因为⽹络延迟了。
可见 , D-SACK 有这么⼏个好处:
|  |  |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
| 3. 可以知道⽹络中是不是把 Γ发送⽅」 的数据包给复制了;在 Linux 下可以通过 net.ipv4.tcp_d sack 参数开启/关闭这个功能( Linux 2.4 后默认打开)。 | || 滑动窗⼝ | |
| | 引⼊窗⼝概念的原因 |
| 我们都知道 TCP 是每发送⼀个数据 ,都要进⾏⼀次确认应答。 当上⼀个数据包收到了应答了 , 再 发送下⼀个。这个模式就有点像我和你⾯对⾯聊天 ,你⼀句我⼀句。但这种⽅式的缺点是效率⽐较低的。如果你说完⼀句话 ,我在处理其他事情 ,没有及时回复你 ,那你不是要⼲等着我做完其他事情 后 ,我回复你 ,你才能说下⼀句话 ,很显然这不现实。 | |
所以 ,这样的传输⽅式有⼀个缺点:数据包的往返时间越⻓ ,通信的效率就越低。
为解决这个问题 ,TCP 引⼊了窗⼝这个概念。即使在往返时间较⻓的情况下 ,它也不会降低⽹络通 信的效率。
那么有了窗⼝ ,就可以指定窗⼝⼤⼩ ,窗⼝⼤⼩就是指⽆需等待确认应答 ,⽽可以继续发送数据 的最⼤值。
窗⼝的实现实际上是操作系统开辟的⼀个缓存空间 ,发送⽅主机在等到确认应答返回之前 ,必须 在缓冲区中保留已发送的数据。如果按期收到确认应答 ,此时数据就可以从缓存区清除。
假设窗⼝⼤⼩为 3 个 TCP 段 ,那么发送⽅就可以 Γ连续发送」 3 个 TCP 段 ,并且中途若有 ACK 丢失 ,可以通过 Γ下⼀个确认应答进⾏确认」。如下图:

图中的 ACK 600 确认应答报⽂丢失 ,也没关系 ,因为可以通过下⼀个确认应答进⾏确认 ,只要发 送⽅收到了ACK 700 确认应答 ,就意味着 700 之前的所有数据 Γ接收⽅」 都收到了。这个模式就 叫累计确认或者累计应答。

窗⼝⼤⼩由哪⼀⽅决定?
TCP 头⾥有⼀个字段叫 Window ,也就是窗⼝⼤⼩。
这个字段是接收端告诉发送端⾃⼰还有多少缓冲区可以接收数据。于是发送端就可以根据这个接 收端的处理能⼒来发送数据 ,⽽不会导致接收端处理不过来。
所以 ,通常窗⼝的⼤⼩是由接收⽅的窗⼝⼤⼩来决定的。
发送⽅发送的数据⼤⼩不能超过接收⽅的窗⼝⼤⼩ ,否则接收⽅就⽆法正常接收到数据。

发送⽅的滑动窗⼝
我们先来看看发送⽅的窗⼝ ,下图就是发送⽅缓存的数据 ,根据处理的情况分成四个部分 ,其中 深蓝⾊⽅框是发送窗⼝ ,紫⾊⽅框是可⽤窗⼝ :

 #1 是已发送并收到ACK确认的数据: 1~31 字节
 #2 是已发送但未收到ACK确认的数据:32~45 字节
 #3 是未发送但总⼤⼩在接收⽅处理范围内(接收⽅还有空间):46~51字节  #4 是未发送但总⼤⼩超过接收⽅处理范围(接收⽅没有空间):52字节以后
在下图 , 当发送⽅把数据 Γ全部」都⼀下发送出去后 ,可⽤窗⼝的⼤⼩就为 0 了 ,表明可⽤窗⼝ 耗尽 ,在没收到ACK 确认之前是⽆法继续发送数据了。

在下图 , 当收到之前发送的数据 32~36 字节的 ACK 确认应答后 ,如果发送窗⼝的⼤⼩没有变 化 ,则滑动窗⼝往右边移动 *5* 个字节 ,因为有 *5* 个字节的数据被应答确认 ,接下来 52~56 字节 ⼜变成了可⽤窗⼝ ,那么后续也就可以发送 52~56 这 5 个字节的数据了。


程序是如何表⽰发送⽅的四个部分的呢?



 SND.WND :表⽰发送窗⼝的⼤⼩( ⼤⼩是由接收⽅指定的);
 SND.UNA (Send Unacknoleged):是⼀个绝对指针 ,它指向的是已发送但未收到确认的第⼀个 字节的序列号 ,也就是 #2 的第⼀个字节。
 SND.NXT :也是⼀个绝对指针 ,它指向未发送但可发送范围的第⼀个字节的序列号 ,也就是 #3 的第⼀个字节。
 指向 #4 的第⼀个字节是个相对指针 ,它需要 SND.UNA 指针加上 SND.WND ⼤⼩的偏移量 ,就 可以指向 #4 的第⼀个字节了。
那么可⽤窗⼝⼤⼩的计算就可以是:

可⽤窗⼝⼤⼩ ***\*=\**** ***\*SND.WND\**** ***\*-\****(***\*SND.NXT\**** ***\*-\**** ***\*SN\*******\*D.UNA\****)接收⽅的滑动窗⼝
接下来我们看看接收⽅的窗⼝ ,接收窗⼝相对简单⼀些 ,根据处理的情况划分成三个部分:
 #1 + #2 是已成功接收并确认的数据(等待应⽤进程读取);
 #3 是未收到数据但可以接收的数据;
 #4 未收到数据并不可以接收的数据;

|  |  |
| ------------------------------------------------------------ | ------------------------------------------------------------ || | |
|  其中三个接收部分 ,使⽤两个指针进⾏划分: RCV.WND :表⽰接收窗⼝的⼤⼩ ,它会通告给发送⽅。 RCV.NXT :是⼀个指针 ,它指向期望从发送⽅发送来的下⼀个数据字节的序列号 ,也就是 #3 的第⼀个字节。 指向 #4 的第⼀个字节是个相对指针 ,它需要 RCV.NXT 指针加上 RCV.WND ⼤⼩的偏移量 ,就 可以指向 #4 的第⼀个字节了。 接收窗⼝和发送窗⼝的⼤⼩是相等的吗? 并不是完全相等 ,接收窗⼝的⼤⼩是约等于发送窗⼝的⼤⼩的。因为滑动窗⼝并不是⼀成不变的。 ⽐如 , 当接收⽅的应⽤进程读取数据的速度⾮常快的话 ,这样 的话接收窗⼝可以很快的就空缺出来。那么新的接收窗⼝⼤⼩ ,是通过 TCP 报⽂中的 Windows 字 段来告诉发送⽅ 。那么这个传输过程是存在时延的 ,所以接收窗⼝和发送窗⼝是约等于的关系。 流量控制发送⽅不能⽆脑的发数据给接收⽅ ,要考虑接收⽅处理能⼒。如果⼀直⽆脑的发数据给对⽅ ,但对⽅处理不过来 ,那么就会导致触发重发机制 ,从⽽导致⽹络 流量的⽆端的浪费。为了解决这种现象发⽣ ,*TCP* 提供⼀种机制可以让 Γ发送⽅」根据 Γ接收⽅」 的实际接收能⼒控 制发送的数据量 ,这就是所谓的流量控制。下⾯举个栗⼦ ,为了简单起见 ,假设以下场景: 客户端是接收⽅ ,服务端是发送⽅ 假设接收窗⼝和发送窗⼝相同 ,都为 200 | |
根据上图的流量控制 ,说明下每个过程:
\1. 客⼾端向服务端发送请求数据报⽂。这⾥要说明下 ,本次例⼦是把服务端作为发送⽅ ,所以没 有画出服务端的接收窗⼝ 。
\2. 服务端收到请求报⽂后 ,发送确认报⽂和 80 字节的数据 ,于是可⽤窗⼝ Usable 减少为 120 字节 ,同时 SND.NXT 指针也向右偏移 80 字节后 ,指向 321 ,这意味着下次发送数据的时候, 序列号是 *321*。
\3. 客⼾端收到 80 字节数据后 ,于是接收窗⼝往右移动 80 字节 , RCV.NXT 也就指向 321 ,这意味 着客户端期望的下⼀个报⽂的序列号是 *321* ,接着发送确认报⽂给服务端。
\4. 服务端再次发送了 120 字节数据 ,于是可⽤窗⼝耗尽为 0 ,服务端⽆法再继续发送数据。
\5. 客⼾端收到 120 字节的数据后 ,于是接收窗⼝往右移动 120 字节 , RCV.NXT 也就指向 441, 接着发送确认报⽂给服务端。
\6. 服务端收到对 80 字节数据的确认报⽂后 , SND.UNA 指针往右偏移后指向 321 ,于是可⽤窗⼝ Usable 增⼤到 80。
\7. 服务端收到对 120 字节数据的确认报⽂后 , SND.UNA 指针往右偏移后指向 441 ,于是可⽤窗⼝ Usable 增⼤到 200。
\8. 服务端可以继续发送了 ,于是发送了 160 字节的数据后 , SND.NXT 指向 601 ,于是可⽤窗⼝ Usable 减少到 40。
\9. 客⼾端收到 160 字节后 ,接收窗⼝往右移动了 160 字节 , RCV.NXT 也就是指向了 601 ,接着 发送确认报⽂给服务端。
\10. 服务端收到对 160 字节数据的确认报⽂后 ,发送窗⼝往右移动了 160 字节 ,于是 SND.UNA 指 针偏移了 160 后指向 601 ,可⽤窗⼝ Usable 也就增⼤⾄了 200。
操作系统缓冲区与滑动窗⼝的关系
前⾯的流量控制例⼦ ,我们假定了发送窗⼝和接收窗⼝是不变的 ,但是实际上 ,发送窗⼝和接收 窗⼝中所存放的字节数 ,都是放在操作系统内存缓冲区中的 ,⽽操作系统的缓冲区 ,会被操作系 统调整。
当应⽤进程没办法及时读取缓冲区的内容时 ,也会对我们的缓冲区造成影响。

那操作系统的缓冲区 ,是如何影响发送窗⼝和接收窗⼝的呢?
我们先来看看第⼀个例⼦。
当应⽤程序没有及时读取缓存时 ,发送窗⼝和接收窗⼝的变化。

  客户端作为发送⽅ ,服务端作为接收⽅ ,发送窗⼝和接收窗⼝初始⼤⼩为 360 ;  服务端⾮常的繁忙 , 当收到客户端的数据时 ,应⽤层不能及时读取数据。

\1. 客户端发送 140 字节数据后 ,可⽤窗⼝变为 220 (360 - 140)。
\2. 服务端收到 140 字节数据 ,但是服务端⾮常繁忙 ,应⽤进程只读取了****40**** 个字节,还有 *100* 字 节占⽤着缓冲区 ,于是接收窗⼝收缩到了 *260* (*360* *-* *100*),最后发送确认信息时 ,将窗⼝⼤ ⼩通告给客户端。
\3. 客户端收到确认和窗⼝通告报⽂后 ,发送窗⼝减少为 260。
\4. 客户端发送 180 字节数据 ,此时可⽤窗⼝减少到 80。
\5. 服务端收到 180 字节数据 ,但是应⽤程序没有读取任何数据 ,这 *180* 字节直接就留在了缓冲 区 ,于是接收窗⼝收缩到了 *80* (*260* *-* *180*),并在发送确认信息时 ,通过窗⼝⼤⼩给客户端。
\6. 客户端收到确认和窗⼝通告报⽂后 ,发送窗⼝减少为 80。
\7. 客户端发送 80 字节数据后 ,可⽤窗⼝耗尽。
\8. 服务端收到 80 字节数据 ,但是应⽤程序依然没有读取任何数据 ,这 *80* 字节留在了缓冲区 ,于 是接收窗⼝收缩到了 *0* ,并在发送确认信息时 ,通过窗⼝⼤⼩给客户端。
\9. 客户端收到确认和窗⼝通告报⽂后 ,发送窗⼝减少为 0。
可见最后窗⼝都收缩为 0 了 ,也就是发⽣了窗⼝关闭。 当发送⽅可⽤窗⼝变为 0 时 ,发送⽅实际 上会定时发送窗⼝探测报⽂ ,以便知道接收⽅的窗⼝是否发⽣了改变 ,这个内容后⾯会说 ,这⾥ 先简单提⼀下。
我们先来看看第⼆个例⼦。
当服务端系统资源⾮常紧张的时候 ,操作系统可能会直接减少了接收缓冲区⼤⼩ ,这时应⽤程序 ⼜⽆法及时读取缓存数据 ,那么这时候就有严重的事情发⽣了 ,会出现数据包丢失的现象。
说明下每个过程:
\1. 客户端发送 140 字节的数据 ,于是可⽤窗⼝减少到了 220。
\2. 服务端因为现在⾮常的繁忙 ,操作系统于是就把接收缓存减少了 *120* 字节 ,当收到 *1**40* 字节数 据后 ,⼜因为应⽤程序没有读取任何数据 ,所以 *140* 字节留在了缓冲区中 ,于是接收窗⼝⼤⼩ 从 *360* 收缩成了 *100* ,最后发送确认信息时 ,通告窗⼝⼤⼩给对⽅。
\3. 此时客户端因为还没有收到服务端的通告窗⼝报⽂ ,所以不知道此时接收窗⼝收缩成了 100 , 客户端只会看⾃⼰的可⽤窗⼝还有 220 ,所以客户端就发送了 180 字节数据 ,于是可⽤窗⼝减 少到 40。
4. 服务端收到了 180 字节数据时 ,发现数据⼤⼩超过了接收窗⼝的⼤⼩ ,于是就把数据包丢失 了。


所以 ,如果发⽣了先减少缓存 ,再收缩窗⼝ ,就会出现丢包的现象。
为了防⽌这种情况发⽣ ,*TCP* 规定是不允许同时减少缓存⼜收缩窗⼝的 ,⽽是采⽤先收缩窗⼝ , 过段时间再减少缓存 ,这样就可以避免了丢包情况。
窗⼝关闭
在前⾯我们都看到了 ,TCP 通过让接收⽅指明希望从发送⽅接收的数据⼤⼩( 窗⼝⼤⼩)来进⾏流 量控制。
如果窗⼝⼤⼩为 *0* 时 ,就会阻⽌发送⽅给接收⽅传递数据 ,直到窗⼝变为⾮ *0* 为⽌ ,这就是窗⼝ 关闭。

窗⼝关闭潜在的危险
接收⽅向发送⽅通告窗⼝⼤⼩时 ,是通过 ACK 报⽂来通告的。
那么 , 当发⽣窗⼝关闭时 ,接收⽅处理完数据后 ,会向发送⽅通告⼀个窗⼝⾮ 0 的 ACK 报⽂ ,如 果这个通告窗⼝的 ACK 报⽂在⽹络中丢失了 ,那⿇烦就⼤了。
这会导致发送⽅⼀直等待接收⽅的⾮ 0 窗⼝通知 ,接收⽅也⼀直等待发送⽅的数据 ,如不采取措 施 ,这种相互等待的过程 ,会造成了死锁的现象。

TCP 是如何解决窗⼝关闭时 ,潜在的死锁现象呢?
为了解决这个问题 ,TCP 为每个连接设有⼀个持续定时器,只要 *TCP* 连接⼀⽅收到对⽅的零窗⼝ 通知 ,就启动持续计时器。
如果持续计时器超时 ,就会发送窗⼝探测 *(* *Window* *probe* *)* 报⽂ ,⽽对⽅在确认这个探测报⽂ 时 ,给出⾃⼰现在的接收窗⼝⼤⼩。
 如果接收窗⼝仍然为 0 ,那么收到这个报⽂的⼀⽅就会重新启动持续计时器;
 如果接收窗⼝不是 0 ,那么死锁的局⾯就可以被打破了。
窗⼝探测的次数⼀般为 3 次 ,每次⼤约 30-60 秒(不同的实现可能会不⼀样)。如果 3 次过后接收 窗⼝还是 0 的话 ,有的 TCP 实现就会发 RST 报⽂来中断连接。
糊涂窗⼝综合症
如果接收⽅太忙了 ,来不及取⾛接收窗⼝⾥的数据 ,那么就会导致发送⽅的发送窗⼝越来越⼩。
到最后 ,如果接收⽅腾出⼏个字节并告诉发送⽅现在有⼏个字节的窗⼝ ,⽽发送⽅会义⽆反顾地 发送这⼏个字节 ,这就是糊涂窗⼝综合症。
要知道 ,我们的 TCP + IP 头有 40 个字节 ,为了传输那⼏个字节的数据 ,要搭上这么⼤的开 销 ,这太不经济了。
就好像⼀个可以承载 50 ⼈的⼤巴⻋ ,每次来了⼀两个⼈ ,就直接发⻋ 。除⾮家⾥有矿的⼤巴司
机 ,才敢这样玩 ,不然迟早破产。要解决这个问题也不难 ,⼤巴司机等乘客数量超过了 25 个 ,才 认定可以发⻋。

接收⽅的窗⼝⼤⼩是 360 字节 ,但接收⽅由于某些原因陷⼊困境 ,假设接收⽅的应⽤层读取的能 ⼒如下:
 接收⽅每接收 3 个字节 ,应⽤程序就只能从缓冲区中读取 1 个字节的数据;
 在下⼀个发送⽅的TCP 段到达之前 ,应⽤程序还从缓冲区中读取了 40 个额外的字节;

每个过程的窗⼝⼤⼩的变化 ,在图中都描述的很清楚了 ,可以发现窗⼝不断减少了 ,并且发送的 数据都是⽐较⼩的了。
所以 ,糊涂窗⼝综合症的现象是可以发⽣在发送⽅和接收⽅:
 接收⽅可以通告⼀个⼩的窗⼝  ⽽发送⽅可以发送⼩数据

 让接收⽅不通告⼩窗⼝给发送⽅
让发送⽅避免发送⼩数据

怎么让接收⽅不通告⼩窗⼝呢?
接收⽅通常的策略如下:

当 Γ窗⼝⼤⼩」⼩于 min( MSS ,缓存空间/2 ) ,也就是⼩于 MSS 与 1/2 缓存⼤⼩中的最⼩值时, 就会向发送⽅通告窗⼝为 0 ,也就阻⽌了发送⽅再发数据过来。
等到接收⽅处理了⼀些数据后 ,窗⼝⼤⼩ >= MSS ,或者接收⽅缓存空间有⼀半可以使⽤ ,就可以 把窗⼝打开让发送⽅发送数据过来。怎么让发送⽅避免发送⼩数据呢?
发送⽅通常的策略如下:
使⽤ Nagle 算法 ,该算法的思路是延时处理 ,只有满⾜下⾯两个条件中的任意⼀个条件 ,才可以 发送数据:
 条件⼀: 要等到窗⼝⼤⼩ >= MSS 并且 数据⼤⼩ >= MSS ;
 条件⼆: 收到之前发送数据的 ack 回包;
只要上⾯两个条件都不满⾜ ,发送⽅⼀直在囤积数据 ,直到满⾜上⾯的发送条件。
Nagle 伪代码如下:
c
if 有数据要发送 {
if 可用窗口大小 >= MSS and 可发送的数据 >= MSS {立刻发送MSS大小的数据
} else {
if 有未确认的数据 {将数据放入缓存等待接收ACK
} else {立刻发送数据
}
|  |  |
}
}
| ------------------------------------------------------------ | ------------------------------------------------------------ || 拼接太多的数据包 ,这种情况下依然会有⼩数据包的传输 ,⽹络总体的利⽤率依然很低。 所以 ,接收⽅得满⾜ Γ不通告⼩窗⼝给发送⽅」 *+* 发送⽅开启 *Nagle* 算法 ,才能避免糊涂窗⼝综 合症。另外 ,Nagle 算法默认是打开的 ,如果对于⼀些需要⼩数据包交互的场景的程序 ,⽐如 ,telnet 或 ssh 这样的交互性⽐较强的程序 ,则需要关闭 Nagle 算法。可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数 ,需要根 据每个应⽤⾃⼰的特点来关闭) csetsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int)); | |
| 拥塞控制 | |
| | 为什么要有拥塞控制呀 ,不是有流量控制了吗? |
| 前⾯的流量控制是避免 Γ发送⽅」 的数据填满 Γ接收⽅」 的缓存 ,但是并不知道⽹络的中发⽣了 什么。⼀般来说 ,计算机⽹络都处在⼀个共享的环境。 因此也有可能会因为其他主机之间的通信使得⽹ 络拥堵。在⽹络出现拥堵时 ,如果继续发送⼤量数据包 ,可能会导致数据包时延、丢失等 ,这时 *TCP* 就会 重传数据 ,但是⼀重传就会导致⽹络的负担更重 ,于是会导致更⼤的延迟以及更多的丢包 ,这个 情况就会进⼊恶性循环被不断地放⼤ 所以 ,TCP 不能忽略⽹络上发⽣的事 ,它被设计成⼀个⽆私的协议 , 当⽹络发送拥塞时 ,TCP 会⾃ 我牺牲 ,降低发送的数据量。于是 ,就有了拥塞控制 ,控制的⽬的就是避免 Γ发送⽅」 的数据填满整个⽹络。为了在 Γ发送⽅」 调节所要发送数据的量 ,定义了⼀个叫做 Γ拥塞窗⼝」 的概念。 | |
| | 什么是拥塞窗⼝? 和发送窗⼝有什么关系呢? |

| 拥塞窗⼝ ***\*cwnd\****是发送⽅维护的⼀个的状态变量 ,它会根据⽹络的拥塞程度动态变化的。 | |值。
拥塞窗⼝ cwnd 变化的规则:
 只要⽹络中没有出现拥塞 , cwnd 就会增⼤;
 但⽹络中出现了拥塞 , cwnd 就减少;

那么怎么知道当前⽹络是否出现了拥塞呢?
其实只要 Γ发送⽅」 没有在规定时间内接收到ACK 应答报⽂ ,也就是发⽣了超时重传 ,就会认为 ⽹络出现了拥塞。

拥塞控制有哪些控制算法?
拥塞控制主要是四个算法:
 慢启动
 拥塞避免  拥塞发⽣  快速恢复
慢启动
TCP 在刚建⽴连接完成后 ,⾸先是有个慢启动的过程 ,这个慢启动的意思就是⼀点⼀点的提⾼发送 数据包的数量 ,如果⼀上来就发⼤量的数据 ,这不是给⽹络添堵吗?
慢启动的算法记住⼀个规则就⾏: 当发送⽅每收到⼀个 *ACK* ,拥塞窗⼝ *cwnd* 的⼤⼩就会加 *1*。
这⾥假定拥塞窗⼝ cwnd 和发送窗⼝ swnd 相等 ,下⾯举个栗⼦:
 连接建⽴完成后 ,⼀开始初始化 cwnd = 1 ,表⽰可以传⼀个 MSS ⼤⼩的数据。  当收到⼀个 ACK 确认应答后 ,cwnd 增加 1 ,于是⼀次能够发送 2 个
 当收到 2 个的 ACK 确认应答后 , cwnd 增加 2 ,于是就可以⽐之前多发2 个 ,所以这⼀次能够 发送 4 个
 当这 4 个的ACK 确认到来的时候 ,每个确认 cwnd 增加 1 , 4 个确认 cwnd 增加 4 ,于是就可 以⽐之前多发 4 个 ,所以这⼀次能够发送 8 个。
慢启动算法的变化过程如下图:

可以看出慢启动算法 ,发包的个数是指数性的增⻓。

那慢启动涨到什么时候是个头呢?
有⼀个叫慢启动⻔限 ssthresh (slow start threshold)状态变量。
 当 cwnd < ssthresh 时 ,使⽤慢启动算法。
 当 cwnd >= ssthresh 时 ,就会使⽤ Γ拥塞避免算法」。
拥塞避免算法
前⾯说道 , 当拥塞窗⼝ cwnd Γ超过」慢启动⻔限 ssthresh 就会进⼊拥塞避免算法。
⼀般来说 ssthresh 的⼤⼩是 65535 字节。
那么进⼊拥塞避免算法后 ,它的规则是:每当收到⼀个 ***\*ACK\**** 时 ,***\*cwnd\**** 增加 ***\*1/cwnd\****。接上前⾯的慢启动的栗⼦ ,现假定 ssthresh 为 8 :
 当 8 个 ACK 应答确认到来时 ,每个确认增加 1/8 ,8 个 ACK 确认 cwnd ⼀共增加 1 ,于是这⼀ 次能够发送 9 个 MSS ⼤⼩的数据 ,变成了线性增⻓。
拥塞避免算法的变化过程如下图:
所以 ,我们可以发现 ,拥塞避免算法就是将原本慢启动算法的指数增⻓变成了线性增⻓ ,还是增 ⻓阶段 ,但是增⻓速度缓慢了⼀些。
就这么⼀直增⻓着后 ,⽹络就会慢慢进⼊了拥塞的状况了 ,于是就会出现丢包现象 ,这时就需要 对丢失的数据包进⾏重传。
当触发了重传机制 ,也就进⼊了 Γ拥塞发⽣算法」。
拥塞发⽣
当⽹络出现拥塞 ,也就是会发⽣数据包重传 ,重传机制主要有两种:
 超时重传  快速重传
这两种使⽤的拥塞发送算法是不同的 ,接下来分别来说说。


当发⽣了 Γ超时重传」,则就会使⽤拥塞发⽣算法。
这个时候 ,ssthresh 和 cwnd 的值会发⽣变化:
 ssthresh 设为 cwnd/2 ,
 cwnd 重置为 1 (是恢复为 cwnd 初始化值 ,我这⾥假定 cwnd 初始化值 1)

怎么查看系统的 cwnd 初始化值?
Linux 针对每⼀个 TCP 连接的 cwnd 初始化值是 10 ,也就是 10 个 MSS ,我们可以⽤ ss -nli 命令 查看每⼀个 TCP 连接的 cwnd 初始化值 ,如下图

拥塞发⽣算法的变化如下图:
接着 ,就重新开始慢启动 ,慢启动是会突然减少数据流的。这真是⼀旦 Γ超时重传」,⻢上回到解 放前。但是这种⽅式太激进了 ,反应也很强烈 ,会造成⽹络卡顿。
就好像本来在秋名⼭⾼速漂移着 ,突然来个紧急刹⻋ ,轮胎受得了吗。。。

发⽣快速重传的拥塞发⽣算法
还有更好的⽅式 ,前⾯我们讲过 Γ快速重传算法」。当接收⽅发现丢了⼀个中间包的时候 ,发送三 次前⼀个包的 ACK ,于是发送端就会快速地重传 ,不必等待超时再重传。
TCP 认为这种情况不严重 ,因为⼤部分没丢 ,只丢了⼀⼩部分 ,则 ssthresh 和 cwnd 变化如 下:
 cwnd = cwnd/2 ,也就是设置为原来的⼀半;
 ssthresh = cwnd ;  进⼊快速恢复算法
快速恢复


正如前⾯所说 ,进⼊快速恢复之前 , cwnd 和 ssthresh 已被更新了:
 cwnd = cwnd/2 ,也就是设置为原来的⼀半;
 ssthresh = cwnd ;
然后 ,进⼊快速恢复算法如下:
 拥塞窗⼝ cwnd = ssthresh + 3 ( 3 的意思是确认有 3 个数据包被收到了);
 重传丢失的数据包;
 如果再收到重复的 ACK ,那么 cwnd 增加 1;
 如果收到新数据的ACK 后 ,把 cwnd 设置为第⼀步中的 ssthresh 的值 ,原因是该 ACK 确认了 新的数据 ,说明从 duplicated ACK 时的数据都已收到 ,该恢复过程已经结束 ,可以回到恢复之 前的状态了 ,也即再次进⼊拥塞避免状态;
快速恢复算法的变化过程如下图:

也就是没有像 Γ超时重传」⼀夜回到解放前 ,⽽是还在⽐较⾼的值 ,后续呈线性增⻓。

*TIP*
很多⼈问题 ,快速恢复算法过程中 ,为什么收到新的数据后 ,cwnd 设置回了 ssthresh ?

| |  | |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ---- |
| 1. 在快速恢复的过程中 ,⾸先 ssthresh = cwnd/2 ,然后 cwnd = ssthresh + 3 ,表⽰⽹络 可能出现了阻塞 ,所以需要减⼩ cwnd 以避免 ,加 3 代表快速重传时已经确认接收到了 3 个重复的数据包;2. 随后继续重传丢失的数据包 ,如果再收到重复的 ACK ,那么 cwnd 增加 1。加 1 代表每 个收到的重复的ACK 包 ,都已经离开了⽹络。这个过程的⽬的是尽快将丢失的数据包 发给⽬标。3. 如果收到新数据的ACK 后 ,把 cwnd 设置为第⼀步中的 ssthresh 的值 ,恢复过程结 束。⾸先 ,快速恢复是拥塞发⽣后慢启动的优化 ,其⾸要⽬的仍然是降低 ***\*cwnd\**** 来减缓拥塞, 所以必然会出现 ***\*cwnd\**** 从⼤到⼩的改变。其次 ,过程***\*2\****(***\*cwnd\****逐渐加***\*1\****) 的存在是为了尽快将丢失的数据包发给⽬标 ,从⽽解决拥塞 的根本问题(三次相同的 ***\*ACK\**** 导致的快速重传),所以这⼀过程中 ***\*cwnd\**** 反⽽是逐渐增⼤ 的。 参考资料:[1] 趣谈⽹络协议专栏.刘超.极客时间[2] Web协议详解与抓包实战专栏.陶辉.极客时间[3] TCP/IP详解 卷1:协议.范建华 译.机械⼯业出版社[4] 图解TCP/IP.⽵下隆史.⼈⺠邮电出版社 [5] The TCP/IP Guide.Charles M. Kozierok. [6] TCP那些事(上) .陈皓.酷壳博客. https://coolshell.cn/articles/11564.html[7] TCP那些事(下) .陈皓.酷壳博客.https://coolshell.cn/articles/11609.html 读者问答 | | || | 读者问:“整个看完收获很⼤ ,下⾯是我的⼀些疑问(稍后 会去确认): 1.拥塞避免这⼀段, 蓝⾊字体:每当收到⼀个 ACK时 ,cwnd增加1/cwnd。是否应该是 1/ssthresh?否则不符合线 | |
| |  |  |
| 什么 意义。 3.对ssthresh的变化介绍的⽐较含糊。” 1. 是 1/cwnd ,你可以在 RFC2581 第 3 页找到答案2. 没有写反 ,同样你可以在 RFC2581 第 5 页找到答案3. ssthresh 就是慢启动⻔限 ,我觉得 ssthresh 我已经说的很清楚了 , 当然你可以找其他资料补充 你的疑惑 是吧? TCP 巨复杂吧 | | |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |