头图

综述

UDP 报文是不可靠的,而 TCP 报文提供可靠交付,有拥塞控制,是有状态的连接。而这些特性都是一些复杂的结构保证的,非常明显的一点就是,UDP 头部简单,而 TCP 头部复杂。虽然 TCP 有所谓的可靠性保证,但是其网络环境不见得良好。作为 IP 层,如果网络条件差,那么发包就没有任何保证,作为上层的 TCP 也没有办法,只能不断重传,通过算法保证可靠性。所以,本文由以下几点组成:

  • TCP 头部格式
  • 三次握手,其中包含了 TCP 报文序号是怎么生成的
  • 四次挥手
  • 流量控制和拥塞控制使用到的数据结构
  • 流量控制
  • 拥塞控制
  • 补充:顺序问题和丢包问题 —— 超时重传、快速重传
TCP 的重点问题是:顺序问题丢包问题连接维护流量控制拥塞控制

TCP 头部格式

TCP 头部的组成部分如下图所示:
image.png

  • 源端口号目的端口号。有了这两个端口,才知道 TCP 中的数据应该发送给哪个应用
  • 序号。给包编号,是为了解决乱序的问题,如此 TCP 才能保证所有的包都是按序到达的
  • 确认序号。对发出去的包的回复,用来确认对方是否已经收到,如果没有收到那么就要重新发送,直到送达为止。即便接收方收不到数据包,发送方也知道哪个包没有被收到,需要重传。这个结构就解决了丢包的问题

    为了保证顺序性,所以每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但是应答不是收一个应答一个,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答。例如发送的确认号是 20,表示前面 19 个包都已经收到了,需要第 20 个包
  • 首部长度字段。由于后面 TCP 选项字段的原因,TCP 首部的长度是可变的
  • 状态位。SYN 是发起一个连接;ACK 是一个回复;RST 是重新连接;FIN 是结束连接;PSH 被置位时,指示接收方应立即将数据交给上层;URG 用来指示报文段里存在着被发送端的上层实体设置为 "紧急" 的数据,紧急数据的最后一个字节有后面紧急指针字段指出。因为 TCP 是面向连接的,所以这些状态位就会改变连接的状态。
在实践中,PSH、URG 和紧急数据指针并没有使用,只是为了完整性,所以才提到这些字段
  • 窗口大小。TCP 有流量控制的功能,通信的双方都声明一个窗口大小,表明各自的处理能力。除了流量控制,TCP 还会进行拥塞控制,控制自己发包的速度,来适应网络环境

    连接管理

    接下来是 TCP 的连接管理,分为三次握手和四次挥手。而拥塞控制和流量控制的部分,在最后描述

    三次握手

    两台主机之间建立 TCP 连接,需要进行 "请求 -> 应答 -> 应答之应答" 的三个步骤。三次握手的过程中,除了建立连接以外,TCP 头部中的序号也会在握手过程中确认下来。

有一个很明显的问题是,为什么 TCP 的握手次数是 3 次,而不是两次或者是 4 次?
答:假设主机 A 和服务器 B 之间的通路是不可靠的,所以当 A 要发起一个连接时,会给 B 发送一个包,但是中间的通路是不可靠的,所以可能这个包丢了,也可能超时了,也可能 B 确实收到了,但是不想和 A 连接。所以 A 和 B 之间只进行一次交流是不够的

而如果因为通路原因,需要不断由接收方确认已经收到发送方发出的包,那么就算是四次,也是不够的,因为通路原因,发出的包总会发生丢失的情况。如此便停留在了建立连接时,后面的数据也就不能发送了。

握手三次,对于 A 和 B 都是发出请求,而且收到了回复,大概率已经没问题了,连接已经建立好了。而且,建立连接就是为了发送数据的,即便是有一个确认包丢失,有数据传送,问题自然就解决了

也就是,三次握手,保证了连接能够建立,而且额外消耗的资源最少

  1. 客户端先向服务器端发送一个 TCP 报文。该报文中不包含应用层数据,但是报文首部的一个标志位 (SYN) 被置为 1。此外客户端还会随机选择一个初始序号 "client\_isn",这个序号会随着第一次握手,发往服务器
  2. 服务器收到包后,会为这个 TCP 连接分配缓存和变量,并向客户端发送允许连接的报文。服务器发送的确认信息的报文里,也不包含应用层的数据,但是其首部包含有其他信息:SYN 状态位值为 1;首部的确认号字段设置为 "client\_isn + 1";服务器设置了自己的初始序号 "server\_isn",并将其放置到报文首部的序号字段中
  3. 收到服务器发来的确认信息后,客户端也要给这个连接分配缓存和变量。然后客户端向服务器发送 "应答之应答" 的报文,在这个报文中,TCP 头部的确认号的值是: "server\_isn + 1"。而且此时连接已经建立,所以 SYN 的值是 0.而且,可以在这个报文中的数据部分,添加客户端要发送给服务器的数据

以下是使用图示,表示在三次握手的过程中,客户端和服务器状态的变化:
image.png

  1. 最开始二者都处于关闭状态。然后服务器开启,主动监听某个端口,变成 LISTEN 状态
  2. 客户端发起连接,之后便处于 SYN\_SENT 状态
  3. 服务端收到客户端发来的连接,返回 SYN 并且确认 (ACK) 客户端的 SYN。之后就处于 SYN\_RCVD 状态
  4. 客户端收到服务器发来的 SYN 和 ACK 之后,再发送 ACK 的 ACK,之后就处于 ESTABLISHED 状态,因为站在客户端的角度,发出去的连接,有了回复,就认为连接已经建立好了
  5. 服务器收到 ACK 的 ACK,也处于 ESTABLISHED 状态,因为站在服务器的角度看,它也达到了发送的连接有了回复的状态。二者都处于 ESTABLISHED 状态后,就可以互相发送数据了
序号生成的规则

上述描述中,各个报文的序号使用了单词来进行描述,实际情况中则是数字。而使用的数字,也有一定的规则:

首先,序号不能从 1 开始,因为会发生冲突。假设 A 连上 B 之后,发送了 1、2、3 三个包,但是发送 3 的时候,由于各种原因 3 没有到达。后来 A 掉线了,重新与 B 建立连接后,序号又从 1 开始,然后发送 2,并且没有 3,但是上次绕路的那个 3 又回来了,发给了 B,B 自然认为,这就是下一个包,于是发生了错误
每个连接都要有不同的序号,序号的起始值是变化的,可以看作是一个 32 位的计数器,每 4 微秒加 1,如果要发生重复的话,那么得需要 "2^32 / 1000 / 1000 / 3600" 个小时 (大约是 4 个多小时),绕路的包早已被丢弃 (下一层里的 IP 头部有 TTL 表明了生存时间)

四次挥手

TCP 连接管理的部分,除了三次握手以建立连接外,还有四次挥手从而断开连接

理想情况下断开连接的过程

客户端和服务器彼此断开连接的过程如下图所示:
image.png

  1. 断开连接之前,双方都处于 ESTABLISHED 互相发送数据的状态,然后客户端发送一个 FIN 标志位为 1 的报文给服务器,进入 FIN\_WAIT\_1 的状态,然后等待服务器发送一个确认报文
  2. 处于 FIN\_WAIT\_1 状态的客户端收到来自服务器的确认时,进入 FIN\_WAIT\_2 状态,等待来自服务器断开连接的报文 (这个报文中,FIN 状态位为 1,表示服务器也准备断开连接了)
  3. 处于 FIN\_WAIT\_2 状态的客户端,收到来自服务器的报文后,会发送一个确认报文,并进入 TIME\_WAIT 状态。实际情况中,发出的 ACK 报文可能会丢失,所以客户端会处在 TIME\_WAIT 状态一段时间,实际运用中一般是 30 秒或 1 分钟、2 分钟。等待过后,连接正式关闭,客户端释放所有资源 (包括端口号)
实际情况中的断开连接

上述断开连接的过程,属于网络条件良好的情况,每个数据包都能到达目的地,不会发生丢失的情况。但实际中,会发生许多意外情况。

当 A 准备断开连接的时候,发送 FIN 为 1 的包之后,就进入 FIN\_WAIT\_1 的状态,B 收到 A 发送的报文之后,就发送一个确认信息,进入 CLOSE\_WAIT 的状态

A 收到 B 发来的确认报文后,就进入 FIN\_WAIT\_2 的状态,如果此时 B 直接释放资源,那么 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp\_fin\_timeout 这个参数,设置一个超时时间。

如果 B 没有释放资源,而是向 A 发送了 FIN 为 1 的包时,A 发送 ACK 后,就从 FIN\_WAIT\_2 状态结束。理论上 A 可以释放资源了,但是如果最后的 ACK 丢失,B 就会重新发一个 FIN 为 1 的包,此时若 A 已经释放资源的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间 TIME\_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,B 准备断开连接的包会重发,A 会重新发一个 ACK 并且足够时间到达 B

A 要等到足够长的时间的原因是,端口占用问题,以及持续等待问题:

A 直接释放资源时,会一并将原先占用的端口释放,但是 B 不知道对应的端口上已经不是 A 了,而B 原来发过的很多包很可能还在路上,如果 A 的端口被一个新的应用占用了,这个新的应用会收到上个连接中 B 发过来的包,虽然序列号是重新生成的,但是还是得上一个双保险,防止产生混乱,因而也需要等足够长的时间,等到原来 B 发送的所有的包都被丢弃,再空出端口来

此处的等待时间设置为 2 MSL (Maximum Segment Lifetime,报文最大生存时间),1 MSL是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 值,是 IP 数据报可以经过的最大路由数,每经过一个处理该报文的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等

另一个异常问题就是,B 超过了 2MSL 的时间,依然没有收到它发的 FIN 的 ACK,B 就会重发 FIN,这个时候 A 再收到这个包之后,A 已经等了 2MSL 的时间了,已经仁至义尽了,之后的包就都不认了,于是就直接发送 RST 而不再发送 ACK,B 就知道 A 已经释放资源了

控制原理

TCP 建立连接和断开连接的过程描述完毕后,就是连接过程中的一些控制。客户端和服务器之间发送数据时,TCP 有流量控制拥塞控制两种机制

流量控制是为了消除发送方使接收方缓存溢出的可能性,也就是说,流量控制是一个速度匹配的服务,发送方的发送速率要和接收方应用程序的读取速率相匹配

拥塞控制指的是,发送方因为 IP 网络的拥堵而被抑制,这种形式的控制才叫拥塞控制。虽然二者的动作相似,但是它们是针对不同的原因而采取的措施

因为流量控制和拥塞控制的动作相似,所以在进行控制时,它们都会使用到窗口,下面就是窗口的结构和相关的概念,窗口中的数字,是发包时的序号,此处为了简便假定序号从 1 开始

发送端的缓存里,有一系列的包,根据处理情况可以分为以下四种:

  • 已经发送完毕,而且已经收到确认的包
  • 已经发送但是还没有收到确认信息的包
  • 还没有发送,但是已经等待发送的包
  • 没有发送,而且暂时也不准备发送的包

于是,接收方会告诉发送方一个窗口的大小,叫做 Advertised window. 这个窗口的大小等于上面第二部分与第三部分的和,也就是已经发送并等待确认的,以及准备发送的。超过这个大小接收方的缓存可能会溢出,于是发送方需要维护下面的数据结构:

image.png

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

接收方的缓存中,记录的内容如下

  • 接受并且确认过的
  • 还没接收,但是马上就能接收的。也就是接收方能够接受的最多报文数量
  • 还没接收,也没法接收的。也就是会使得缓存溢出的部分

对应的数据结构如下所示:
image.png

  • MaxRcvBuffer:最大缓存的量
  • LastByteRead: 是图中最左侧的箭头。之前的是已经接收了而且已经被应用层读取了;之后是已经接收了,但是还没被应用层读取的
  • NextByteExpected: 这个箭头左侧是已经确认接收的,右侧是等待接收的

NextByteExpected 和 LastByteRead 的差是还没被应用层读取的部分占用掉的 MaxRcvBuffer 的量,用 A 表示。前面提到的,接收方要传递给发送方的窗口的大小 AdvertisedWindow 是 MaxRcvBuffer 减去 A。也就是: AdvertisedWindow = MaxRcvBuffer - (NextByteExpected - LastByteRead)。

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

介绍完具体的数据结构,下面就是具体在流量控制和拥塞控制里的应用

流量控制

TCP 通过让发送方维护一个叫做接收窗口 (receive window,也就是上面提到的 Advertised window) 的变量来提供流量控制的服务。这个变量用来通知发送方,接收方还有多少可用的缓存空间。而 TCP 是全双工通信,所以客户端和服务器都有接收窗口

接下来通过一个案例,来说明流量控制中的原理。还是使用上述数据结构中的图:

image.png

图中红色框表示的就是窗口,先假设这个窗口是不变的,始终是 9. 当收到 4 号包的 ACK 后,这个窗口会右移一格,所以第 13 号包也就可以发送了。而假设发送方一下子将 10 ~ 13 号包全部发送后,那么就会停止发送,未发送可发送的部分就变成了 0:

image.png

当接收到 5 号包的 ACK 后,窗口又会向右移动一格,所以第 14 号包也可以发送了:

image.png

如此窗口一直向右移动,就是正常发包的情况。如果接收方处理的很慢,导致缓存中没有空间可以接收发送方发过来的包了,就可以通过 ACK 信息修改发送方窗口的大小,甚至可以设置为 0 以便让发送方暂停发送。以下就是窗口可以更改的情况:

假设接收方的应用一直不读取缓存中的数据,所以当发送方收到 6 号包的 ACK 后,窗口需要缩小变成 8。所以这个长度为 8 的窗口,在 6 号包的 ACK 到达的时候,并没有整体向右移动一格,而是仅左侧移动,使得窗口缩小:

image.png

如果此时接收方还是一直不处理数据,那么窗口会一直缩小一直缩小直到缩小为 0 为止。如下图所示,当发送方收到 14 号包的 ACK 时,整个窗口会被调整为 0 从而停止发送

image.png

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

以上就是 TCP 流量控制的机制

上面提到的低能窗口综合征,是这样的 (A 是发送方,B 是接收方)。这个现象发生的条件是 B 的接收缓存已经满了,而且 B 没有数据要发送给 A 了。在这两个条件下,当 B 的应用将缓存中的数据读入,然后清空缓存后,TCP 并不会向 A 发送 rwnd 已经不为 0 的报文,因为此时 B 没有数据要发送给 A。如此,A 会一直以为 B 的接收缓存是满的,也就是 A 被阻塞而不能再发送新数据了。

解决这个问题的方法是,TCP 规定了,当 B 的接收窗口变为 0 是,主机 A 继续发送只有一个字节数据的报文段,这些报文段将会被接收方确认,最终缓存将开始清空,并且 ACK 里将包含一个非 0 的 rwnd 值

拥塞控制

拥塞控制和流量控制一样,也使用了窗口机制。流量控制使用的滑动窗口 rwnd 是怕发送方把接收方缓存塞满,而拥塞控制的拥塞窗口 cwnd,是怕把网络塞满。有一个公式:LastByteSent - LastByteAcked <= min {cwnd, rwnd}, 这个公式表示拥塞窗口和滑动窗口共同控制发送的速度。网络上通道的容量 = 带宽 * 往返延迟。

下面是一个 TCP 发包的例子,正常情况下,设置发送窗口 (就是已经发送但是还没有收到确认的部分) 为通道的容量,就能塞满整个通道。但是若此时增大窗口,使得单位时间内发送更多的包,容易造成丢包;而如果此时增加通路上路由器的缓存,那么会增加时延,容易发生超时重传的现象。

image.png

如图所示,假设往返时间为 8s,来回各 4 秒,每秒发送一个包,每个包 1024byte。已经过去了 8s,则 8 个包都发出去了,其中前 4 个包已经到达接收端,但是 ACK 还没有返回,不能算发送成功。5-8 后四个包还在路上,还没被接收。这个时候,整个管道正好撑满,在发送端,已发送未确认的为 8 个包,正好等于带宽,也即每秒发送 1 个包,乘以来回时间 8s。

此时增大发送方的窗口,在单位时间内发送更多的包。原来发送一个包,从一端到达另一端,假设一共经过四个设备,每个设备处理一个包时间耗费 1s,所以到达另一端需要耗费 4s,如果发得更快,则单位时间内,会有更多的包到达这些中间设备,这些设备还是只能每秒处理一个包的话,多出来的包就会被丢弃

此时若增加中间设备的缓存,使得处理不过来的包先放在队列里面,这样包就不会丢失,但是缺点是会增加时延,导致这些缓存的包花 4s 也到达不了接收端,而且如果时延达到一定程度,就会超时重传,

于是 TCP 的拥塞控制主要来避免两种现象,包丢失超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。而最开始的时候,发送小包,然后包的大小再一点点增大,这个过程叫做慢启动

TCP 刚建立连接的时候,会把 cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数性的增长。

但是这种指数增长也有尽头,有一个值 ssthresh 为 65535 个字节,当超过这个值的时候,就不能再指数增长了,因为可能整个通路都快满了。所以,在超过 ssthresh 之后,每收到一个确认,cwnd 增加 1/cwnd,从上述 rwnd 的值为 8 ,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长。

但是线性增长也还在增长,增长到开始出现拥塞,此时就必须降低发包的速度了。

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

新型的方式是,采用下面很快会提到的 "快速重传算法"。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,所以 cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是稍微降低了一些速度,留有线性增长的空间。下面这张图片,就是采用这两种方法时,发送速度的变化:

image.png

拥塞控制 - TCP BBR 拥塞算法

TCP 的拥塞控制,使得在时延很重要的情况下,反而降低了速度。但是拥塞控制主要来避免的两个现象都是有问题的:

  1. 丢包的问题。发生丢包并不一定就表示,网络通道已经被填满了,即便是带宽不满的时候,也可能发生丢包
  2. TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满网络通路就可以了,不应该继续填充直到连缓存也被填满。

为了解决上面两个问题,就有了 TCP BBR 算法。这个算法试图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不填满中间设备的缓存,从而避免增加时延。在这个平衡点上,就可以很好的达到高带宽和低时延的平衡。

顺序问题和丢包问题

接下来描述的是,TCP 要保证顺序性时遇到的问题。简单来说就是,由于发生了丢包,导致接收时顺序发生了变化,所以需要重新发送没有到达的包。而确定发送的时机,有两种方法,一种是超时重传,另一种是快速重传

在这一部分,还是使用上文中介绍数据结构时的图片,上面一张是发送方的图片,下面一张是接收方的图片

此刻在发送方,1、2、3 已经发出并且已经收到了确认;4 ~ 9 是已经发送出去但是没有得到确认的的包;10、11、12 是准备发送但还没有发送的包;后面几个包是接收方没有足够的空间,目前还不能发送的包。在接收端来看,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。

image.png

image.png
假设 4 的 ACK 到了,而 5 的 ACK 丢了,6、7 的数据包丢了,采取的办法如下:

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

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

如果过一段时间,5、6、7 都超时了,就会重新发送。接收方发现 5 原来接收过,于是丢弃 5;6 收到了,接收方发送 ACK,要求下一个是 7,但 7 不幸又丢了。当 7 再次超时的时候,就是需要重传的时候,TCP 的策略是超时间隔加倍。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题是,超时周期可能相对较长。而且需要统计 RTT 及其波动范围,无疑增加了工作量。所以就有第二种方法可以选择:快速重传

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

例如,6 号报文和 8 号报文接收方都收到了,就是没有收到 7 号报文,所以很大可能 7 号报文丢了。于是发送 6 的 ACK,要求下一个是 7。然后收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。当客户端收到 3 个重复 ACK,就会发现 7 的确丢了,不等超时,马上重发

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


半岛酒店
1 声望0 粉丝

文章都是学习极客时间上的专栏的笔记,还有啃书时候书上的内容,侵删