网络协议八之TCP协议
上次说了“性本善”的 UDP 协议,这哥们秉承“网之初,性本善,不丢包,不乱序”的原则,徜徉在网络世界中。
与之相对应的,TCP 就像是老大哥一样,了解了社会的残酷,变得复杂而成熟,秉承“性恶论”。它认为网络环境是恶劣的,丢包、乱序、重传、拥塞都是常有的事儿,一言不合可能就会丢包,送达不了,所以从算法层面来保证可靠性。
TCP 包头格式
老规矩,咱们先来看看 TCP 头的格式。
从上面这个图可以看出,它比 UDP 要复杂的多。而复杂的地方,也正是它为了解决 UDP 存在的问题所必需的字段。
首先,源端口号和目标端口号是两者都有,不可缺少的字段。
接下来是包的序号。给包编号就是为了解决乱序的问题。老大哥做事,稳重为主,一件件来,面临再复杂的情况,也临危不乱。
除了发送端需要给包编号外,接收方也会回复确认序号。做事靠谱,答应了就要做到,暂时做不到也要给个回复。
这里要注意的是,TCP 是个老大哥没错,但不能说他一定会保证传输准确无误的完成。从 IP 层面来讲,如果网络的确那么差,是没有任何可靠性保证的,即使 TCP 老大哥再稳,他也管不了 IP 层丢包,他只能尽可能的保证在他的层面上的可靠性。
然后是一些状态位。有以下常见状态位:
- SYN(Synchronize Sequence Numbers,同步序列编号):发起一个连接
- ACK(Acknowledgement,确认字符):回复
- RST(Connection reset):重新连接
- FIN:结束连接
从这些状态位就可以看出,TCP 基于“性恶论”,警觉性就很高,不像 UDP 和小朋友似的,随便一个不认识的小朋友都能玩到一起,他与别人的信任要经过多次交互才能建立。
还有一个窗口大小。这个是 TCP 用来进行流量控制的。通信双方各声明一个窗口,标识自己当前的处理能力,让发送端别发送的太快,要不然撑死接收端。也不能发送的太慢,要不然就饿死接收端了。
根据上述对 TCP 头的分析,我们知道对于 TCP 协议要重点关注以下几个问题:
- 顺序问题,稳重不乱;
- 丢包问题,承诺靠谱;
- 连接伟豪,有始有终;
- 流量控制,把握分寸;
- 拥塞控制,知进知退。
TCP 的三次握手
了解完 TCP 头,我们就来看下 TCP 建立连接的过程,这就是著名的“三次握手”。
三次握手,过程是这样子的:
- A:你好,我是 A(SYN)。
- B:你好 A,我是 B(SYN,ACK)。
- A:你好 B(ACK 的 ACK)。
着重记忆上述过程,后续很多分析都是基于这个过程来的。
记得刚接触三次握手的时候,就一直很纳闷,为啥一定要三次?两次不行吗?四次不行吗?然后很多人就解释,如果是两次,就怎样怎样,四次,又怎样怎样?但这其实都是从结果推原因,没有说明本质。
我们应该知道,握手是为了建立稳定的连接,这个是最终目的。而要达到这个目的,就要通信双方的交互形成一个确认的闭环。
拿上述 A、B 通信的例子来看,A 给 B 发信息,B 要告诉 A 他收到信息了。这时候,算是一个确认闭环吗?明显不是,因为 B 没有收到来自 A 的确认信息。
所以,要达到我们上述的目标,还要 A 给 B 一个确认信息,这样就形成了一个确认闭环。
A 给 B 的确认信息发出后,遇到网络不好的情况,也会出现丢包的情况。按理来说,还应该有个回应,但是,我们发现,好像这样下去就没玩没了啦。
所以,我们说,只要通信双方形成一个确认闭环后,就认为连接已建立。一旦连接建立,A 会马上发送数据,而 A 发送数据,后续的很多问题都得到了解决。
例如 A 发给 B 的确认消息丢了,当 A 后续发送的数据到达的时候,B 可以认为这个连接已经建立。如果 B 直接挂了,A 发送的数据就会报错,说 B 不可达,这样,A 也知道 B 出事情了。
三次握手除了通信双方建立连接外,主要还是为了沟通 TCP 包的序号问题。
A 要告诉 B,我发起的包的序号起始是从哪个号开始的,B 也要告诉 A,B 发起的包的序号的起始号。
TCP 包的序号是会随时间变化的,可以看成一个 32 位的计数器,每 4ms 加一。计算一下,这样到出现重复号,需要 4 个多小时。但是,4 个小时后,还没到达目的地的包早就死翘翘了。这是因为 IP 包头里的 TTL(生存时间)。
为什么序号不能从 1 开始呢?因为这样会很容易出现冲突。
例如,A 连上 B 之后,发送了 1、2、3 三个包,但是发送 3 的时候,中间丢了,或者绕路了,于是重新发送,后来 A 掉线了,重新连上 B 后,序号又从 1 开始,然后发送 2,但是压根没想发送 3,而如果上次绕路的那个 3 刚好又回来了,发给了 B ,B 自然就认为,这就是下一包,于是发生了错误。
就这样,双方历经千辛万苦,终于建立了连接。前面也说过,为了维护这个连接,双方都要维护一个状态机,在连接建立的过程中,双方的状态变化时序图就像下面这样:
整体过程是:
- 客户端和服务端都处于 CLOSED 状态;
- 服务端主动监听某个端口,处于 LISTEN 状态;
- 客户端主动发起连接 SYN,处于 SYN-SENT 状态。
- 服务端收到客户端发起的连接,返回 SYN,并且 ACK 客户端的 SYN,处于 SYN-RCVD 状态;
- 客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,处于 ESTABLISHED 状态;
- 服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态。
TCP 的四次挥手
说完了连接,接下来就来了解下 TCP 的“再见模式”。这也常被称为四次挥手。
还拿 A 和 B 举例,挥手过程:
- A:B 啊,我不想和你玩了。
- B:哦,你不想玩了啊,我知道了。这个时候,还只是 A 不想玩了,就是说 A 不会再发送数据,但是 B 此时还没做完自己的事情,还是可以发送数据的,所以此时的 B 处于半关闭状态。
- B:A啊,好吧,我也不想和你玩了,拜拜。
- A:好的,拜拜。
这样这个连接就关闭了。看起来过程很顺利,是的,这是通信双方“和平分手”的场面。
A 开始说“不玩了”,B 说“知道了”,这个回合,是没什么问题的,因为在此之前,双方还处于合作的状态。
如果 A 说“不玩了”,没有收到回复,那么 A 会重新发送“不玩了”。但是这个回合结束之后,就很可能出现异常情况了,因为有一方率先撕破脸。这种撕破脸有两种情况。
一种情况是,A 说完“不玩了”之后,A 直接跑路,这是会有问题的,因为 B 还没有发起结束,而如果 A 直接跑路,B 就算发起结束,也得不到回答,B 就就不知道该怎么办了。
另一种情况是,A 说完“不玩了”,B 直接跑路。这样也是有问题的,因为 A 不知道 B 是还有事情要处理,还是过一会发送结束。
为了解决这些问题,TCP 专门设计了几个状态来处理这些问题。接下来,我们就来看看断开连接时的状态时序图。
整体过程是:
- A 说“不玩了”,就进入 FIN_WAIT_1 状态;
- B 收到 “A 不玩”的消息后,回复“知道了”,就进入 CLOSE_WAIT 状态;
- A 收到“B 说知道了”,进入 FIN_WAIT_2 状态。这时候,如果 B 直接跑路,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间;
- B 没有跑路,发送了“B 也不玩了”的消息,处于 LAST_ACK 状态;
- A 收到“B 说不玩了”的消息,回复“A 知道 B 也不玩了”的消息后,从 FINE_WAIT_2 状态结束。
最后一个步骤里,如果 A 直接跑路了,也会出现问题。因为 A 的最后一个回复,B 如果没有收到的话就会重复第 4 步,但是因为 A 已经跑路了,所以 B 会一直重复第 4 步。
因此,TCP 协议要求 A 最后要等待一段时间,这个等待时间是 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 A 的回复,B 重发给 A,A 的回复要有足够时间到达 B。
A 直接跑路还有一个问题是,A 的端口就空出来了,但是 B 不知道,B 原来发过的很多包可能还在路上,如果 A 的端口被新的应用占用了,这个新的应用会受到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是这里会有一个双保险,防止产生混乱。因此也需要 A 等待足够长的时间,等到 B 发送的所有未到的包都“死翘翘”,再空出端口。
这个等待的时间设为 2MSL,MSL 是 Maximum Segment Lifetime,即报文最大生存时间。它是任何报文再网络上存在的最长时间,超过这个时间的报文就会被丢弃。
因为 TCP 报文基于 IP 协议,而 IP 头中有一个 TTL 域,是 IP 数据报可以经过的最大路有数,每经过一个处理他的路由器,此值就减 1,当此值为 0 时,数据报就被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒、1分钟和 2 分钟等。
还有一种异常情况,B 超过了 2MS 的时间,依然没有收到它发的 FIN 的 ACK。按照 TCP 的原理,B 当然还会重发 FIN,这个时候 A 再收到这个包之后,就表示,我已经等你这么久,算是仁至义尽了,再来的数据包我就不认了,于是直接发送 RST,这样 B 就知道 A 跑路了。
TCP 状态机
将连接建立和连接断开的两个时序状态图综合起来,就是著名的 TCP 状态机。我们可以将这个状态机和时序状态机对照看,就会更加明了。
图中加黑加粗部分,是上面说到的主要流程,相关说明:
- 阿拉伯数字序号:建立连接顺序;
- 大写中文数字序号:断开连接顺序;
- 加粗实线:客户端 A 的状态变迁;
- 加粗虚线:服务端 B 的状态变迁;
总结
- TCP 包头很复杂,主要关注 5 个问题。顺序问题、丢包问题、连接维护、流量控制、拥塞控制;
- 建立连接三次握手,断开连接四次挥手,状态图要牢记。
上次了解了 TCP 建立连接与断开连接的过程,我们发现,TCP 会通过各种“套路”来保证传输数据的安全。除此之外,我们还大概了解了 TCP 包头格式所对应解决的五个问题:顺序问题、丢包问题、连接维护、流量控制、拥塞控制。今天,我们就来看下 TCP 又是用怎样的套路去解决这五个问题的。
在解决问题之前,咱们先来看看 TCP 是怎么成为一个“靠谱”的协议的。
“靠谱”协议 TCP
TCP 为了保证顺序性,每个包都有一个 ID。这建立连接的时候,会商定起始 ID 的值,然后按照 ID一个个发送。
为了保证不丢包,对于发送的包都要进行应答。但是这个应答不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认和累计应答。
为了记录所有发送的包和接收的包,TCP 也需要发送端和接收端分别用缓存来保存这些记录。发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分:
- 第一部分:发送且已经确认的;
- 第二部分:发送尚未确认的;
- 第三部分:没有发送,但是已经等待发送的;
- 第四部分:没有发送,并且暂时还不会发送的。
于是,发送端需要保持这样的数据结构:
- LastByteAcked:第一部分和第二部分的分界线
- LastByteSent:第二部分和第三部分的分界线
- LastByteAcked:第三部分和第四部分的分界线
对于接收端来讲,它缓存记录的内容要简单一些,分为以下三个部分:
- 第一部分:接收且确认过的;
- 第二部分:还没接收,但是马上就能接收的;
- 第三部分:还没接收,也没空间接收的。
对应的数据结构就像这样:
- MaxRcvBuffer:最大缓存量;
- LastByteRead:这个值之后是已经接收,但是还没被应用层读取的;
- NextByteExpected:第一部分和第二部分的分界线,下一个期待的包 ID。
第二部分的窗口有多大呢?
NextByteExpected 和 LastByteRead 的差起始是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,我们定义为 A,即:A = NextByteExpected - LastByteRead - 1。
那么,窗口大小,AdvertisedWindow = MaxRcvBuffer - A。
也就是:AdvertisedWindow = MaxRcvBuffer - (NextByteExpected - LastByteRead - 1)
而第二部分和三部分的分界线 = NextByteExpected + AdvertisedWindow - 1 = MaxRcvBuffer + LastByteRead。
顺序与丢包问题
接下来,我们结合上述图例,用一个例子来看下 TCP 如何处理顺序与丢包问题的。
还是刚才的图,在发送端看来:
- 1、2、3 是已经发送并确认的;
- 4、5、6、7、8、9 都是发送未确认的;
- 10、11、12 是还没发出的;
- 13、14、15 是接收方没有空间,不准备发送的。
而在接收端看来:
- 1、2、3、4、5 是已经完成 ACK,但还没读取的;
- 6、7 是等待接收的;
- 8、9 是已经接收,但是没有 ACK 的。
发送端和接收端当前的状态如下:
- 1、2、3 没有问题,双方达成了一致;
- 4、5 接收方发送 ACK 了,但是发送方还没收到,有可能丢了,有可能还在路上;
- 6、7、8、9 肯定都发了,但是 8、9 已经到了,6、7还没打,出现了乱序,于是在缓存中存储,但是没有返回 ACK。
根据这个例子,我们可以知道,顺序问题和丢包问题都有了能发送,所以我们先来看确认与重发的机制。
假设 4 的确认到了,不幸的是,5 的 ACK 丢了,并且 6、7 的数据包也丢了,这时候会怎么处理呢?
一种方法是超时重试,也就是对每一个发送了,但是没有 ACK 的包,都有设一个定时器,一旦超过了一定的时间,就重新尝试。这个超时时间不宜过短,时间必须大于往返时间 RTT,否则就会引起不必要的重传也不宜过长,这样超时时间变长,访问就变慢了。
估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断的变化。
除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法(Adaptive Retransmission Algorithm)。
如果过一段时间,5、6、7 都超时了,就会重新发送。接收方发现 5 原来接收过,于是就丢弃5。收到了6,发送 ACK,要求下一个是 7,7 不幸又丢了。
当 7 再次超时的时候,如果有需要重传,TCP 的策略就是超时间隔加倍。每当遇到一次超时重传的实时,都会将下一次超时时间间隔设置为先前值的两倍。两次超时,就说明网络环境差,不宜频繁发送。
可以看出,超时重发存在的问题是,超时周期可能较长。那是不是可以有更快的方式呢?
有一个可以快速重传的机制。当接收方收到一个序号大于下一个所期望的报文段时,就检测到了数据流中的一个间格,于是发送三个冗余的ACK,客户端收到后,就在定时器过期之前,重传丢失的报文段。
例如,接收方发现 6、8、9 都已经接收了,但是 7 没来。于是发送三个 6 的 ACK,要求下一个是 7。客户端收到三个,就会发现 7 的确丢了,不等超时,就马上重发。
除此之外,还有一种方式称为 Selective Acknowledgment(SACK)。这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发格发送方。例如发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了,然后快速重发。
流量控制问题
接下来,我们再来看看流量控制机制。在对于包的确认中,会同时携带一个窗口大小的字段。
我们先假设窗口不变的情况,发送端窗口始终为 9。4 的确认来的时候,LastByteAcked 会右移一个,这个时候,第 13 个包就可以发送了。
这个时候,假设发送端发送过猛,将第三部分中的 10、11、12、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 连接开始
- cwnd 设置为一个报文段,一次只能发送 1 个;
- 当收到这一个确认的时候,cwnd 加 1,于是一次能够发送 2 个;
- 当这两个包的确认到来的时候,每个确认的 cwnd 加 1,两个确认 cwnd 加 2,于是一次能够发送 4 个;
- 当这四个的确认到来的时候,每个确认 cwnd 加 1,四个确认 cwnd 加 4,于是一次能够发送 8 个。
从上面这个过程可以看出,这是指数性的增长。
但是涨到什么时候是个头呢?一个值 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 的对比:
小结
- 顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的。这就相当于领导和员工的备忘录,布置过的工作要有编号,干完了有反馈,活不能派太多,也不能太少;
- 拥塞控制是通过拥塞窗口来解决的,相当于往管道里面倒水,快了容易溢出,慢了浪费带宽,要摸着石头过河,找到最优值。