本文作为学习笔记,文章内容来自“极客时间”专栏《趣谈网络协议》,如有侵权,请告知,必即时删除。
1、TCP包头
来看 TCP 头的格式。从这个图上可以看出,它比 UDP 复杂得多。 TCP1.jpg- 源端口号和目标端口号,这一点和 UDP 是一样的。如果没有这两个端口号。数据就不知道应该发给哪个应用。
- 包的序号,为了解决乱序的问题。
- 确认序号,发出去的包应该有确认,如果没有收到就应该重新发送,直到送达。
- 状态位,例如 SYN 是发起一个连接,ACK 是回复,RST 是重新连接,FIN 是结束连接等。TCP 是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。
- 窗口大小,TCP 要做流量控制,通信双方各声明一个窗口,标识自己当前能够的处理能力,别发送的太快,也别发的太慢。所谓的流量控制就是让发送方的发送速率不要太快,让接收方来得及接受。针对的是建立TCP连接的对端。
除了做流量控制以外,TCP 还会做拥塞控制,对于真正的通路堵车不堵车,它无能为力,唯一能做的就是控制自己,也即控制发送的速度。拥塞控制针对的是网络。
2、三次握手
TCP 的连接建立,我们常常称为三次握手。我们也常称为“请求 -> 应答 -> 应答之应答”的三个回合。
为什么要三次,而不是两次?按说两个人打招呼,一来一回就可以了啊?为了可靠,为什么不是四次?
假设两次握手的情况,A要发起请求,和B建立连接,请求到达B之后,B返回应答包,双方建立连接。如果A发送请求包之后挂了,B收到请求包,返回应答包,建立连接,这个时候A已经挂了,自然不会再发送数据给B,因此B这个连接只能一直保持着。因而两次握手肯定不行。
B 发送的应答到达 A,A 就认为连接已经建立了,因为对于 A 来讲,他的消息有去有回。A 会给 B 发送应答之应答,而 B 也在等这个消息,才能确认连接的建立,只有等到了这个消息,对于 B 来讲,才算它的消息有去有回。当然 A 发给 B 的应答之应答也会丢,也会绕路,甚至 B 挂了。按理来说,还应该有个应答之应答之应答,这样下去就没底了。所以四次握手是可以的,四十次都可以,关键四百次也不能保证就真的可靠了。只要双方的消息都有去有回,就基本可以了。
我们在程序设计的时候,可以要求开启 keepalive 机制,即使没有真实的数据包,也有探活包。另外,你作为服务端 B 的程序设计者,对于 A 这种长时间不发包的客户端,可以主动关闭,从而空出资源来给其他客户端使用。
三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是 TCP 包的序号的问题。
A 要告诉 B,我这面发起的包的序号起始是从哪个号开始的,B 同样也要告诉 A,B 发起的包的序号起始是从哪个号开始的。
每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的,可以看成一个 32 位的计数器,每 4 微秒加一,如果计算一下,如果到重复,需要 4 个多小时,那个绕路的包早就死翘翘了,因为我们都知道 IP 包头里面有个 TTL,也即生存时间。在连接建立的过程中,双方的状态变化时序图就像这样。
TCP2.jpg
一开始,客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态。然后客户端主动发起连接 SYN,之后处于 SYN-SENT 状态。服务端收到发起的连接,返回 SYN,并且 ACK 客户端的 SYN,之后处于 SYN-RCVD 状态。客户端收到服务端发送的 SYN 和 ACK 之后,发送 ACK 的 ACK,之后处于 ESTABLISHED 状态,因为它一发一收成功了。服务端收到 ACK 的 ACK 之后,处于 ESTABLISHED 状态,因为它也一发一收了。
3、四次挥手
断开连接的时候的状态时序图。
TCP3.jpg
断开的时候,我们可以看到,当 A 说“不玩了”,就进入 FIN_WAIT_1 的状态,B 收到“A 不玩”的消息后,发送知道了,就进入 CLOSE_WAIT 的状态。A 收到“B 说知道了”,就进入 FIN_WAIT_2 的状态,A 发送“知道 B 也不玩了”的 ACK 后,从 FIN_WAIT_2 状态结束。
按说 A 可以跑路了,但是最后的这个 ACK 万一 B 收不到呢?则 B 会重新发一个“B 不玩了”,这个时候 A 已经跑路了的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,“B 说不玩了”会重发的,A 会重新发一个 ACK 并且足够时间到达 B。
等待的时间设为 2MSL,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
4、TCP的滑动窗口
为了记录所有发送的包和接收的包,发送端的缓存里是按照包的 ID 一个个排列,根据处理的情况分成四个部分。
- 第一部分:发送了并且已经确认的。
- 第二部分:发送了并且尚未确认的。
- 第三部分:没有发送,但是已经等待发送的。
- 第四部分:没有发送,并且暂时还不会发送的。
在 TCP 里,接收端会给发送端报一个窗口的大小,叫 Advertised window。这个窗口的大小应该等于上面的第二部分加上第三部分,就是已经交代了没做完的加上马上要交代的。超过这个窗口的,接收端做不过来,就不能发送了。于是,发送端需要保持下面的数据结构。
- LastByteAcked:第一部分和第二部分的分界线
- LastByteSent:第二部分和第三部分的分界线
- LastByteAcked + AdvertisedWindow:第三部分和第四部分的分界线
对于接收端来讲,它的缓存里记录的内容要简单一些。
- 第一部分:接受并且确认过的。
- 第二部分:还没接收,但是马上就能接收的。
- 第三部分:还没接收,也没法接收的。
- LastByteRead 之后是已经接收了,但是还没被应用层读取的;
- NextByteExpected 是第一部分和第二部分的分界线。
- MaxRcvBuffer:最大缓存的量;
5、顺序问题与丢包问题
还是刚才的图,在发送端来看,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。
根据这个例子,我们可以知道,顺序问题和丢包问题都有可能发生,所以我们先来看确认与重发的机制。一种方法就是超时重试,也即对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。
有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是它就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段。还有一种方式称为 Selective Acknowledgment (SACK)。(TODO:这两种我没看太明白,后面再补吧)
6、流量控制问题
在对于包的确认中,同时会携带一个窗口的大小。
对于发送端,我们先假设窗口不变的情况,窗口始终为 9。4 的确认来的时候,会右移一个,这个时候第 13 个包也可以发送了。
TCP6.jpg
我们假设一个极端情况,接收端的应用一直不读取缓存中的数据,当数据包 6 确认后,窗口大小就不能再是 9 了,就要缩小一个变为 8。
TCP7.jpg
这个新的窗口 8 通过 6 的确认消息到达发送端的时候,你会发现窗口没有平行右移,而是仅仅左面的边右移了,窗口的大小从 9 改成了 8。
TCP8.jpg
如果接收端还是一直不处理数据,则随着确认的包越来越多,窗口越来越小,直到为 0。当这个窗口通过包 14 的确认到达发送端的时候,发送端的窗口也调整为 0,停止发送。这就是我们常说的流量控制。
7、拥塞控制问题
拥塞控制的问题,也是通过窗口的大小来控制的,前面的滑动窗口 rwnd是怕发送方把接收方缓存塞满,而拥塞窗口 cwnd,是怕把网络塞满。
这里有一个公式 LastByteSent - LastByteAcked <= min {cwnd, rwnd} ,是拥塞窗口和滑动窗口共同控制发送的速度。就是发送方的第二部分(已发送但未确认的)<= 滑动窗口和拥塞窗口的最小值。
TCP 的拥塞控制主要来避免两种现象,包丢失和超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?如果我们通过漏斗往瓶子里灌水,我们就知道,不能一桶水一下子倒进去,肯定会溅出来,要一开始慢慢的倒,然后发现总能够倒进去,就可以越倒越快。这叫作慢启动。
一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数性的增长。
涨到什么时候是个头呢?有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。
后来有了 TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。
网友评论