趣谈网络协议11TCP下

TCP

TCP使用”缓存”来记录接收与发送,用序号来标识包,如果收到确认,把该序号的包标识为已确认,如果超时未收到包确认,主动发送一个该序号的包。

TCP为了保证顺序性,每一个包都有一个序号,然后一个个的发送,为了保证不丢包,对于发送的包都要有应答,这个应答不是一个个应答,而是应答某个之前的序号,这种模式称为累计确认或者累计应答(cumulative acknowledgment)。

缓存

为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别都有缓存来保存这些记录。

发送端的缓存

发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分。

第一部分:发送了并且已经确认的。

第二部分:发送了并且尚未确认的。

第三部分:没有发送,但是已经等待发送的。

第四部分:没有发送,并且暂时还不会发送的。

在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。

LastByteAcked:第一部分和第二部分的分界线

LastByteSent:第二部分和第三部分的分界线

LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线

接收端的缓存

第一部分:接受并且确认过的。

第二部分:还没接收,但是马上就能接收的。

第三部分:还没接收,也没法接收的。

LastByteRead:TCP缓冲区中读到的位置(LastByteRead 之后是已经接收了,但是还没被应用层读取的)

NextByteExpected:收到的连续包的最后一个位置

LastByteRcved:收到的包的最后一个位置

NextByteExpected ~ LastByteRcved区间:未到达的数据区间

LastByteRead ~ NextByteExpected区间:已收到的数据区间

MaxRcvBuffer:最大缓存的量

NextByteExpected值与LastByteRead值因为应用层的读取和接收包变化。所以AdvertisedWindow是个变化的值。

NextByteExpected 和 LastByteRead 的差其实是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,我们定义为 A。

AdvertisedWindow 其实是 MaxRcvBuffer 减去 A。也就是:AdvertisedWindow=MaxRcvBuffer-((NextByteExpected-1)-LastByteRead)。

确认与重发的机制 解决顺序与丢包问题

超时重试

每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。但是这个超时的时间如何评估呢?这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。

估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断地变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法(Adaptive Retransmission Algorithm)。

TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?

有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是它就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段。而当客户端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段。

例如,接收方发现 6 收到了,8 也收到了,但是 7 还没来,那肯定是丢了,于是发送 6 的 ACK,要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。当客户端收到 3 个重复 ACK,就会发现 7 的确丢了,不等超时,马上重发。

还有一种方式称为 Selective Acknowledgment (SACK)。这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发送给发送方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了。

流量控制问题

在对于包的确认中,同时会携带一个窗口的大小。

假设窗口大小固定为9。

就是一端在发送,一端在接收,如果接收的慢,变导致发送的窗口变为0。就是让发送端别发了。

如果应用层一直不读取接收端的数据,也会导至窗口变为0。

如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合征,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。这就是我们常说的流量控制。

拥塞控制问题

拥塞控制,也是通过窗口的大小来控制的,前面的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。

这里有一个公式 LastByteSent - LastByteAcked <= min {cwnd, rwnd} ,是拥塞窗口和滑动窗口共同控制发送的速度。

如果拥塞窗口较小,说明网络状况不好,需要发慢点。 如果滑动窗口小,你网络状况好没用,接收方处理不了你的快速度,你只能降下来速度。

TCP 的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥带宽。

于是 TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传。

一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?

一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;

当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;

当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;

当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。

可以看出这是指数性的增长。

有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。

拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。

前面我们讲过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

TCP 的拥塞控制主要来避免的两个现象都是有问题的。

第一个问题是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。

第二个问题是 TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。

为了优化这两个问题,后来有了 TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。


趣谈网络协议11TCP下
http://hanqichuan.com/2023/07/10/网络协议/趣谈网络协议12TCP下/
作者
韩启川
发布于
2023年7月10日
许可协议