上次了解了 TCP 创建链接与断开链接的过程,咱们发现,TCP 会经过各类“套路”来保证传输数据的安全。除此以外,咱们还大概了解了 TCP 包头格式所对应解决的五个问题:顺序问题、丢包问题、链接维护、流量控制、拥塞控制。今天,咱们就来看下 TCP 又是用怎样的套路去解决这五个问题的。html
在解决问题以前,我们先来看看 TCP 是怎么成为一个“靠谱”的协议的。算法
TCP 为了保证顺序性,每一个包都有一个 ID。这创建链接的时候,会商定起始 ID 的值,而后按照 ID一个个发送。缓存
为了保证不丢包,对于发送的包都要进行应答。可是这个应答不是一个一个来的,而是会应答某个以前的 ID,表示都收到了,这种模式称为累计确认和累计应答。安全
为了记录全部发送的包和接收的包,TCP 也须要发送端和接收端分别用缓存来保存这些记录。发送端的缓存里是按照包的 ID 一个个排列,根据处理的状况分红四个部分:网络
因而,发送端须要保持这样的数据结构:数据结构
对于接收端来说,它缓存记录的内容要简单一些,分为如下三个部分:ssh
对应的数据结构就像这样:tcp
第二部分的窗口有多大呢?ide
NextByteExpected 和 LastByteRead 的差起始是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,咱们定义为 A,即:A = NextByteExpected - LastByteRead - 1。优化
那么,窗口大小,AdvertisedWindow = MaxRcvBuffer - A。
也就是:AdvertisedWindow = MaxRcvBuffer - (NextByteExpected - LastByteRead - 1)
而第二部分和三部分的分界线 = NextByteExpected + AdvertisedWindow - 1 = MaxRcvBuffer + LastByteRead。
接下来,咱们结合上述图例,用一个例子来看下 TCP 如何处理顺序与丢包问题的。
仍是刚才的图,在发送端看来:
而在接收端看来:
发送端和接收端当前的状态以下:
根据这个例子,咱们能够知道,顺序问题和丢包问题都有了能发送,因此咱们先来看确认与重发的机制。
假设 4 的确认到了,不幸的是,5 的 ACK 丢了,而且 六、7 的数据包也丢了,这时候会怎么处理呢?
一种方法是超时重试,也就是对每个发送了,可是没有 ACK 的包,都有设一个定时器,一旦超过了必定的时间,就从新尝试。这个超时时间不宜太短,时间必须大于往返时间 RTT,不然就会引发没必要要的重传也不宜过长,这样超时时间变长,访问就变慢了。
估计往返时间,须要 TCP 经过采样 RTT 的时间,而后进行加权平均,算出一个值,并且这个值仍是要不断变化的,由于网络情况不断的变化。
除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。因为重传时间是不断变化的,咱们称为自适应重传算法(Adaptive Retransmission Algorithm)。
若是过一段时间,五、六、7 都超时了,就会从新发送。接收方发现 5 原来接收过,因而就丢弃5。收到了6,发送 ACK,要求下一个是 7,7 不幸又丢了。
当 7 再次超时的时候,若是有须要重传,TCP 的策略就是超时间隔加倍。每当遇到一次超时重传的实时,都会将下一次超时时间间隔设置为先前值的两倍。两次超时,就说明网络环境差,不宜频繁发送。
能够看出,超时重发存在的问题是,超时周期可能较长。那是否是能够有更快的方式呢?
有一个能够快速重传的机制。当接收方收到一个序号大于下一个所指望的报文段时,就检测到了数据流中的一个间格,因而发送三个冗余的ACK,客户端收到后,就在定时器过时以前,重传丢失的报文段。
例如,接收方发现 六、八、9 都已经接收了,可是 7 没来。因而发送三个 6 的 ACK,要求下一个是 7。客户端收到三个,就会发现 7 的确丢了,不等超时,就立刻重发。
除此以外,还有一种方式称为 Selective Acknowledgment(SACK)。这种方式须要在 TCP 头里加一个 SACK 的东西,能够将缓存的地图发格发送方。例如发送 ACK六、SACK八、SACK9,有了地图,发送方一会儿就能看出来是 7 丢了,而后快速重发。
接下来,咱们再来看看流量控制机制。在对于包的确认中,会同时携带一个窗口大小的字段。
咱们先假设窗口不变的状况,发送端窗口始终为 9。4 的确认来的时候,LastByteAcked 会右移一个,这个时候,第 13 个包就能够发送了。
这个时候,假设发送端发送过猛,将第三部分中的 十、十一、十二、13 所有发送,以后就中止发送,则此时未发送可发送部分为 0。
当对于包 5 的确认到达的时候,在客户端至关于窗口再滑动了一格,这个时候,才能够有更多的包能够发送了,例如第 14 个包才能够发送。
若是接收方处理的太慢,致使缓存中没有空间了,能够经过确认信息修改窗口的大小,甚至能够设置为 0,则发送方将暂时中止发送。
咱们能够假设一个极端状况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不会再是 9,而是减小一个变为了 8。
为何会变为 8?你看,下图中,当 6 的确认消息到达发送端的时候,左边的 LastByteAcked 右移一位,而右边的未发送可发送区域由于已经变为 0,所以左边的 LastByteSend 没有移动,所以,窗口大小就从 9 变成了 8。
而若是接收端一直不处理数据,则随着确认的包愈来愈多,窗口愈来愈小,直到为 0。
当这个窗口大小经过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,因而,发送端中止发送。
当发生这样的状况时,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。对于接收方来讲,当接收比较慢的时候,要防止低能窗口综合征,别空出一个字节就赶忙告诉发送方,结果又被填满了。能够在窗口过小的时候,不更新窗口大小,直到达到必定大小,或者缓冲区一半为空,才更新窗口大小。
这就是咱们常说的流量控制。
最后,咱们来看一下拥塞控制的问题。
这个问题,也是靠窗口来解决的。前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。
这里有一个公式:
LastByteSent - LastByteAcked <= min{cwnd, rwnd}
能够看出,是拥塞窗口和滑动窗口共同控制发送的速度。
那发送方怎么判断网络是否是满呢?这实际上是个挺难的事情。由于对于 TCP 协议来说,它压根不知道整个网络路径都会经历什么。TCP 发送包常被比喻为往一个水管里灌水,而 TCP 的拥塞控制就是在不堵塞、不丢包的状况下,尽可能发挥带宽。
水管有粗细,网络有带宽,也就是每秒钟可以发送多少数据;
水管有长度,端到端有时延。在理想状况下:
水管里的水量 = 水管粗细 x 水管长度
而对于网络来说:
通道的容量 = 带宽 x 往返延迟
若是咱们设置发送窗口,使得发送但未确认的包的数量为通道的容量,就可以撑满整个管道。
如上图所示:
假设往返时间为 8s,去 4s,回 4s,每秒发送一个包,每一个包 1024 byte。
那么在 8s 后,就发出去了 8 个包。其中前 4 个包已经到达接收端,可是 ACK 尚未返回,不能算发送成功。而 5-8 后四个包还在路上,没被接收。
这个时候,整个管道正好撑满。在发送端,已发送未确认的为 8 个包,也就是:
带宽 = 1024byte/s x 8s(来回时间)
若是咱们在这个基础上再调大窗口,使得单位时间内更多的包能够发送,会出现什么现象呢?
原来发送一个包,从一端到另外一端,假设一共通过四个设备,每一个设备处理一个包耗时 1s,因此到达另外一端须要耗费 4s。若是发送的更加快速,则单位时间内,会有更多的包到达这些中间设备,这些设备仍是只能每秒处理一个包的话,多出来的包就会被丢弃,这不是咱们但愿看到的。
这个时候,咱们能够想其余的办法。例如,这四个设备原本每秒处理一个包,可是咱们在这些设备上加缓存,处理不过来的就在队列里面排着,这样包就不会丢失,可是缺点也是显而易见的,增长了时延。这个缓存的包,4s 确定到达不了接收端,若是时延达到必定程度,就会超时,这也不是咱们但愿看到的。
针对上述两种现象:包丢失和超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。可是一开始,发送端怎么知道速度多快呢?怎么知道把窗口调整到合适大小呢?
若是咱们经过漏斗往瓶子里灌水,咱们就知道,不能一桶水一会儿全倒进去,确定会溢出来。一开始要慢慢的倒,而后发现都可以倒进去,就加快速度。这叫作慢启动。
一个 TCP 链接开始
从上面这个过程能够看出,这是指数性的增加。
可是涨到何时是个头呢?一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就会将将增加速度降下来。
此时,每收到一个确认后,cwnd 增长 1/cwnd。一次发送 8 个,当 8 个确认到来的时候,每一个确认增长 1/8,8个确认一共增长 1,因而一次就可以发送 9 个,变成了线性增加。
即便增加变成了线性增加,仍是会出现“溢出”的状况,出现拥塞。这时候通常就会直接下降倒水的速度,等待溢出的水慢慢渗透下去。
拥塞的一种变现形式是丢包,须要超时重传。这个时候,将 ssthresh 设为 cwnd/2,将 cwnd 设为 1,从新开始慢启动。也就是,一旦超时重传,立刻“从零开始”。
很明显,这种方式太激进了,将一个高速的传输速度一会儿停了下来,会形成网络卡顿。
前面有提过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,告诉发送端要赶忙给我发下一个包,别等超时再重传。TCP 认为这种状况不严重,由于大部分没丢,只丢了一小部分,cwnd 变为 cwnd/2,而后 sshthresh = cwnd。当三个包返回的时候,cwnd = sshthresh + 3。
能够看出这种状况降低速没有那么激进,cwnd 仍是在一个比较高的值,呈线性增加。下图是二者的对比。
就像前面说的同样,正是这种知进退,使得时延在很重要的状况下,反而下降了速度。可是,咱们仔细想想,TCP 的拥塞控制主要用来避免的两个现象都是有问题的。
第一个问题是丢包。丢包并不必定表示通道满了,也多是管子原本就”漏水”。就像公网上带宽不满也会丢包,这个时候就认为拥塞,而下降发送速度实际上是不对的。
第二个问题是 TCP 的拥塞控制要等到将中间设备都填满了,才发送丢包,从而下降速度。但其实,这时候下降速度已经晚了,在将管道填满后,不该该接着填,直到发生丢包才降速。
为了优化这两个问题,后来就有了 TCP BBR 拥塞算法。它企图找到一个平衡点,就是经过不断的加快发送速度,将管道填满,可是不要填满中间设备的缓存,由于这样时延会增长,在这个平衡点能够很好的达到高带宽和低时延的平衡。
下图是 BBR 算法与普通 TCP 的对比:
参考: