移动端开发者应该懂的TCP协议

>>2020,微服务装逼指南

上次我们讲了UDP协议,发现它的头部非常简单,是因为它天然的相信网络的传输是可靠的,但是网络世界往往不是如此,很可能有丢包,乱序,拥塞等各种事情的发生,所以TCP协议的可靠性就需要从算法层面方面来保证。

下面我们来看看TCP包头格式:

移动端开发者应该懂的TCP协议

可以看到,它比UDP复杂的多,首先,源端口号和目标端口号是必不可少的(UDP也是),如果没有这两个端口号,数据就不知道发送给哪个应用。

接下来是包的序号,给包编号是为了解决乱序的问题。还有就是确认序号,发出去的包应该有确认,不然怎么判断对方有没有收到呢?没有收到就重新发送,直到送达。

在IP层是不保证传输的可靠性的,所以它的层面无法保证可靠性。对于TCP来说,因为IP层是它的下层,它也无能为力,但是在它自己的层面,就会努力保证可靠性。

接下来是一些状态位,SYN是发起一个链接,ACK是回复,RST是重新链接,FIN是结束连接等等。TCP是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。

还有一个重要的就是窗口大小,TCP要做流量控制,通信双方各自声明一个窗口,标识自己当前能够处理的能力,不要太快或者太慢。除了流量控制还有拥塞控制,对于真正的通路堵车不堵车,它无能为力,唯一能做的的就是控制自己,也即控制发送的速度。有些人可能对流量控制和拥塞控制容易混淆,流量控制就是自己的处理能力,类似你的车最快可以达到时速300km/h,而拥塞控制就是现在堵车了,就算你的车性能极好,能达到时速300km,也只能以调整车速每小时5km的速度行驶。

对于要掌握TCP协议,应该重点关注下面几点

1.顺序问题

2.丢包问题

3.连接维护

4.流量控制

5.拥塞控制

知道了上面的几个重点,下面我们先来讲一下连接维护 的问题,因为所有的问题,首先都要先建立一个连接开始。

TCP的三次握手

TCP的连接,我们常常称为三次握手。如下面一段对话:

A:你好,我是A。

B:你好A,我是B。

A:你好B。

我们也常称为“请求 -> 应答 -> 应答之应答”,为什么要三次呢,我觉得很简单的就是A发出去的东西A要保证能收到一次,B发出去的也要保证B能收到一次。双方的消息有去有回,基本就可以了。三次握手除了双方建立连接以外,主要还是为了沟通一件事,就是TCP包的序号的问题。

A和B都要告诉对方自己发起的包是从哪个号开始的,不能从1开始,从1开始可能发生包的序号重复的问题。每个连接都要有不同的序号,这个序号的起始序号是随着时间变化的,可以看成一个32位计数器,每4微秒加一,如果计算一下到重复,需要4个多小时(公式如下4/1000000*max/60/60=4.772小时,max=4294967296(注意区分有符号整数最大值))。那个绕路的包早就死掉了,因为我们都知道IP包头里面有个TTL,也即生存时间。

建立连接后,双方的状态变化的时序图如下。

移动端开发者应该懂的TCP协议

一开始,客户端和服务端都处于CLOSED状态。先是服务端主动监听某个端口,处于LISTEN状态。然后客户端主动发起连接SYN,之后处于SYN-SEND状态。服务端收到发起的连接,返回SYN,并且ACK客户的SYN,之后处于SYN-RCVD状态。客户端收到服务端发送的SYN和ACK之后,发送ACK的ACK,之后处于ESTABLISHED状态,因为它一发一收成功了。服务端收到ACK的ACK之后,处于ESTABLEISHED状态,因为它也一发一收了。

TCP四次挥手

四次挥手就是断开连接的过程,好说好散。如下情景:

A:B啊,我不想玩了。

B:哦,你不想玩了,我知道了。

这个时候,只是A不想玩了,A不会再发送数据,但是B不能在ACK的时候直接关闭。有可能B还没做完自己的事情,还是可以发送数据的,所以称为半关闭的状态。这个时候A可以选择不再接受数据,也可以选择最后再接收一段收据,等待B也主动关闭。

B:A啊,好吧,我也不玩了,拜拜。

A:好的,拜拜。

下面来看断开连接时候的状态时序图

移动端开发者应该懂的TCP协议

主要解释一下为什么等待时间设置为2MSL,MSL是Maximum Segment Lifetime,报文最大的生存时间。等待2MSL,是为了最后一次如果B没收到ACK的话,“B说不玩了”会重发的,A会重新发一个ACK并且足够时间到达B;还有就是A直接跑路的话A的端口就直接空出来了,但是B不知道,B原来发的很多包很可能都在路上,如果A的端口被一个应用重新占用了,新的应用汇收到上个连接中B发过来的包,虽然序列号是重新生成的,但是这里上了一个双保险,保证原来B发送的所有的包都死翘翘,再空出端口来。

还有一个情况是,B超过了2MSL的时间,依然没有收到它发的FIN的ACK,按照TCP的原理,B当然还会重发FIN,这个时候A再收到这个包之后,A就表示我已经在这里等待了那么长时间了,已经仁至义尽了,之后的我就不认了,于是就直接发送RST,B早就知道A早就跑了。

因为TCP报文是基于IP协议的,而IP头中有个TTL域,是IP数据包可以经过的最大路由数,每经过一个处理他的路由器此值就减1,当此值为0时数据报将被丢弃,同时发送ICMP报文通知源主机。协议规定MSL为2分钟,实际应用中常用的是30秒,1分钟和两分钟。

TCP 状态机

移动端开发者应该懂的TCP协议

将连接建立和连接断开的两个时序图综合起来。建议将这个状态机和时序状态机对照着看,加黑加粗是上面说到的主要流程,其中阿拉伯数字的序号,表示连接过程中的顺序,而大写中文数字的序号,是连接断开过程中的顺序。加粗的实线是客户端A的状态变迁,加粗的虚线是服务端B的状态变迁。

TCP的连接状态可以用netstat 或者 lsof 命令 grep 一下 establish listen close_wait查看。


TCP协议为了保证可靠性,需要利用各种重传的策略和大量的算法来保证。为了保证顺序性,每一个包都有一个ID,为了保证不丢包,对于发送的包都要进行应答,但是应答也不是一个一个来的,而是会应答某个之前的ID,表示都收到了,这种模式成为累计确认累计应答

为了记录所有发送的包和接受的包,TCP也需要发送端和接收端分别都有缓存来保存这些记录。发送端的缓存里是按照包的ID一个个排列,根据处理的情况分成四个部分。

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

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

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

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

       为什么要区分第三部分和第四部分呢,因为为了流量控制。类似作为一个项目管理人员应该根据以往的工作情况和这个员工反馈的能力,抗压力等去估测员工的工作能力,然后给他分配合适的任务。多了做不完,少了就不饱和。使劲逼迫的话就离职了。

在TCP里,接收端会给发送端报一个窗口的大小,叫Advertised window,这个窗口的大小应该等于第二部分加第三部分。超过这个窗口的,接收端接收不过来,就不能发送了。于是,发送端需要保持下面的数据结构:

移动端开发者应该懂的TCP协议

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

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

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

对于接收端来讲,它的缓存里记录的内容要简单一些:

  • 第一部分:接收了并且确认过的。

  • 第二部分:还没接收,但是马上就能接收的。也就是自己能接收的最大工作量。

  • 第三部分:还没接收,也没法接收的。也即超过工作量的部分,实在做不完。

对应的数据结构就像这样

移动端开发者应该懂的TCP协议

  • MaxRcvBuffer:最大缓存的量;

  • LastByteRead之后是已经接受了,但是还没有被应用层读取的;

  • NextByteExpected 是第一部分和第二部分的分界线。

第二部分的窗口有多大呢?

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

AdvertisedWindow其实是MaxRcvBuffer减去A。

第二部分和第三部分的分界线在哪里,NextByteExpected加AdvertisedWindow就是第二部分和第三部分的分界线,其实也就是LastByteRead+MaxRcvBuffer。其中第二部分里面,由于受到的包可能不是顺序的,会出现空挡,只有和第一部分连续的,可以马上进行回复,中间空着的部分需要等待,哪怕后面的已经来了。

顺序问题和丢包问题

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

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

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

超时触发重传存在的问题是超时的周期可能相对较长。有一个可以快速重传的机制,当接收方收到一个序号大于要下一个所期望的报文段时,就检测到了数据流中的一个间隔,于是发送三个冗余的ACK,客户端收到后,就在定时器过期之前,重传丢失的报文段。

例如,接收方发现6,8,9都已经接收了,就是7没来,那肯定是丢了,于是发送三个6的ACK,要求下一个是7。客户端收到3个,就会发现7确实又丢了,不等超时,马上重复。

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

流量控制问题

我们再来看看流量控制机制,在对于包的确认中,同时携带一个窗口的大小。我们先假设窗口不变的情况,窗口始终为9。4的确认来的时候,就会右移,这个时候第13个包也可以发送了。

移动端开发者应该懂的TCP协议

这个时候,假设发送端发送过猛,会将第三部分的10,11,12,13全部发送完毕,之后就停止发送,未发送可发送部分为0。

移动端开发者应该懂的TCP协议

对于包5的确认到达的时候,在客户端相遇窗口再移动一格,这个时候才会更多的包可以发送了,例如第14个包才可以发送。

移动端开发者应该懂的TCP协议

如果接收方实在处理的太慢,导致缓存中没有空间,可以通过确认信息修改窗口的大小。甚至可以设置为0,则发送方将暂时停止发送。

我们假设一个极端情况,接收端的应用一直不读缓存中的数据,当数据包6确认,窗口大小就不能再试9了,就要缩小一个变为8.

移动端开发者应该懂的TCP协议

这个新的窗口8通过6的确认消息到达发送端的时候,你会发现窗口没有平行右移,而是仅仅左面的边右移的,窗口的大小从9改成了8。

移动端开发者应该懂的TCP协议

如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到0。

移动端开发者应该懂的TCP协议

当这个串口通过包14的确认到达发送端的时候,发送端的窗口也调整为0,停止发送。

移动端开发者应该懂的TCP协议

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

拥塞控制问题

最后,我们看一下拥塞控制的问题,也是通过窗口的大小来控制的,前面的滑动窗口rwnd是怕发送方把接收方的缓存塞满,而拥塞窗口cwnd,是怕把网络塞满。

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

那么发送方是怎么判断网络是不是满呢?这其实是个挺难的事情,因为对于TCP协议来讲,他压根不知道整个网络路径都会经历什么,对他来讲就是一个黑盒。TCP发送包常被比喻为玩一个水管里面灌水,而TCP的拥塞控制就是在不堵塞,不丢包的情况下,尽量发挥其带宽。

TCP的拥塞控制主要来避免两种现象,包丢失超时重传。一旦出现了这些现象就说明发送速度太快,但是一开始并不知道窗口应该调整到多大,所以有个词叫做慢启动,一条TCP连接开始,cwnd设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd加一,于是一次能够发送两个;当两个确认到来的时候,每个确认cwnd加一,两个确认cwnd加二,于是一次能够发送四个;继续,接下来一次能够发送八个。可以看出这是指数型增长

涨到ssthresh值为65535个字节的时候,超过这个值就要小心,可能要满了,就要慢下来。每收到一个确认后cwnd增加1/cwnd,比如一次发送8个,八个确认到来的时候,才一共cwnd增加1,于是一次能够发送九个,变成了线性增加。线性增加总有一天会满的,出现了拥塞,这个时候就降低速度,等溢出的水慢慢渗下去。

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

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

移动端开发者应该懂的TCP协议

但是上面可能存在两个问题,第一个问题是丢包并不代表着通道就满了,也可能管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞,退缩了,其实是不对的。第二个问题 是TCP拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实TCP只要填满管道就可以了,不应该接着填,知道连缓存也填满。

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

移动端开发者应该懂的TCP协议

总结

顺序问题,丢包问题,流量控制都是通过滑动窗口来解决的,这其实就相当于你领导和你的工作备忘录,布置过的要有编号,干完了要有反馈,活不能太多或太少。

拥塞控制是通过拥塞窗口来解决的,相当于往管道里面倒水,快了容易溢出,慢了浪费带宽,要摸着石头过河,找到最优值。


欢迎关注我的微信公众号~

移动端开发者应该懂的TCP协议

原文始发于微信公众号(九局下半大逆转):移动端开发者应该懂的TCP协议