导言

相对于 UDP 这样的无连接 (connection-less) 协议,TCP 是一种连接导向 (connection-oriented) 的可靠性协议,其目标是在不可靠的网络环境中实现可靠的数据传输。我们知道网络环境是复杂多变的,网络通信本质上是一种不可靠通信,要在不可靠的网络中实现 TCP 这样的可靠通信协议需要做很多工作:基于 TCP 协议的双端通信,在数据传输前需要建立一条连接,然后基于该连接来进行可靠的通信;在传输数据的过程中还要利用校验和、序列号、确认应答 (ACK)、重发控制、连接管理以及流量窗口控制等机制来实现可靠性传输;最后在终止连接的过程中也要处理一些异常情况以保证顺利关闭连接。

本文将深入浅出地介绍 TCP 中基本的连接管理和数据传输的可靠性保障,贯穿 TCP 协议从建立连接到传输数据再到终止连接这整个过程,期间会详略得当地分析一些 TCP 协议中的精华和重点。

TCP 连接从建立到终止

TCP 建立连接的阶段

三次握手

TCP 连接的建立需要经历三次握手 (three-way handshake, 3WHS),当这三次握手完成之后,一条用于后续数据传输的连接就此建立,同时确定了两端所用的初始发送序列号 (initial send sequence number, ISS) 和接收序列号 (initial receive sequence number, IRS),这两个序列号在 TCP 协议中非常重要,TCP 使用序列号和确认应答机制来保证数据传输的可靠性。

我们先来看看一个 TCP 报文的组成1 2

TCP 位于 OSI 七层网络模型3中的第四层,TCP 构建于 IP 协议之上,因此也常常用 TCP/IP 来表述。TCP 自身的报文存储与 IP 包之中,根据需要划分成了多个区域 (字段),这里只介绍几个和本文相关的字段,其余的字段请自行查阅:

  • Source Port:源端口,16 位。

  • Destination Port:目的端口,16 位。

  • Sequence Number:当前数据段(报文)中的第一个字节的序列号 (Data 区域中的每一个字节都有一个自己的序列号),会按照已发送的数据字节数不断累加并作为下一次发送报文的新序列号,32 位。

  • Acknowledgment Number:确认号,确认数据已接收并提示对端下一次发送报文时应该携带的序列号,32 位。

  • Data Offset:数据偏移量,表示真正的数据从哪个位置开始,4 位。

  • Control bits:控制位,也叫 "flags",用来表明该报文的用途,1 位。目前一共有 8 种:

    • CWR:缩小拥塞窗口大小4
    • ECE:ECN-Echo4
    • URG:紧急标志,督促中间层设备关注 Urgent Pointer 字段并处理其指向的数据。
    • ACK:确认标志,表示 Acknowledgment Number 字段中的序列号之前的数据都已经接收到了,并提示对端下一次的报文序列号要从这个值开始。
    • PSH:要求对端收到数据之后不要存放在缓冲区而是直接交给接收程序去处理。
    • RST:重置连接。
    • SYN:同步标志,表明这是一个建立连接的请求,对端同意建立连接的回复也需要设置 SYN,同时带上 ACK
    • FIN:结束标志,表明本端已经不会再发送数据了,通常用来终止一个连接。
  • Window (size):流量控制的时候会使用到,表明接收端目前还能接收的数据窗口大小,见下文。

  • Data:TCP 报文携带的用户数据,动态位长。

下面我们以一个例子来描述这个过程5

  1. A 端 TCP 的初始状态是 CLOSED,B 端 TCP 的初始状态从 CLOSED 执行被动打开变为 LISTEN (passive open,一般是通过调用 socket()bind()listen() 这三个系统调用来完成);
  2. A 端 TCP 执行主动打开 (active open,一般是通过调用 socket()connect()),TCP 就会发送一个控制消息 CTL=SYN 到 B 端,同时携带一个序列号 SQE=100 在报文中,这个序列号就是 A 端的 ISS,A 的状态也随之变为 SYNC-SENT (第一次握手);
  3. B 端 TCP 收到报文之后,报文中的序列号就作为 B 端的 IRS,检查无误后将状态更改为 SYN-RECEIVED,回复一个控制消息 CTL=SYN,ACK,同时携带一个序列号 SQE=300 和一个确认号 ACK=101,这个序列号就是 B 端的 ISS,ACK 的值是 A 端的 SQE 加一,提示对端发送下一个消息来 ack 本端的 SYN 时用的序列号必须是 101 (第二次握手);
  4. A 端 TCP 收到报文后,报文中的序列号就作为 A 端的 IRS,检查无误后将状态变为 ESTABLISHED,根据要求回复一个控制消息 CTL=ACK,同时携带一个序列号 SQE=101 和一个确认号 ACK=301,同理,ACK 的值是 B 端的 SQE 加一,提示对端发送下一个消息时要用序列号 301,B 端收到回包之后也将状态更改为 ESTABLISHED (第三次握手,此时连接建立);
  5. 然后,A 端 TCP 就可以往 B 端 TCP 发送数据了,报文中的控制位是 CTL=ACK,序列号是 SQE=101,确认号是 ACK=301,并附上真正的数据。

TCP 使用序列号 (SQE) 和确认应答 (ACK) 来实现数据传输的可靠性,通过 SQE 和 ACK,TCP 可以对数据进行去重、排序和重发。三次握手建立连接的过程中,收发两端都会通过一个单调递增的系统"时钟"选定一个自己的序列号,TCP 协议里的 SQE 是一个无符号整数,也就是说取值范围是 [0, 232 - 1],因此,每发送一个报文之前,通过单调递增"时钟"分配序列号的时候都需要先对其进行基于 232 的取余运算之后才能用作该报文的 SQE。

注意到第 5 步里发送的报文中的几个数据段,除了 DATA 之外,其余的消息都和第 4 步中的一样。这是因为 TCP 协议标准规定6,纯粹的 ACK 报文不占用序列号,只有 SYN 报文和 DATA 报文会消耗序列号。这种设计的一个好处是就算第 4 步发送的报文在网络中丢失了,第 5 步发送的报文还是可以成功建立连接并让对端接收数据。

为什么需要三次握手

一直以来互联网公司的面试题总喜欢问 TCP 建立连接为什么需要三次握手,网络上也因此诞生了很多不同视角的"答案"。当然,真正的答案肯定还是要从 TCP 的标准里去找7

The principal reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion. To deal with this, a special control message, reset, is specified. If the receiving TCP peer is in a non-synchronized state (i.e., SYN-SENT, SYN-RECEIVED), it returns to LISTEN on receiving an acceptable reset.

上面这段话的核心意思就是:三次握手的主要作用是为了解决重复连接的问题。我们先来看一个最简单的例子7

  1. A 端 TCP 的初始状态是 CLOSED,B 端 TCP 的初始状态从 CLOSED 执行被动打开变为 LISTEN
  2. A 端 TCP 最开始发了一个 SQE=90,CTL=SYN 的报文到 B 端 TCP,状态变为 SYN-SENT,但是在网络中滞留了 (可能是因为交换机抖动,或者其他网络设备的问题),A 端因为迟迟没有收到回包,便认为 SQE=90,CTL=SYN 这个报文丢了,于是重新分配一个序列号并重发了一个 SQE=100,CTL=SYN 的报文;
  3. 没想到第一个报文其实并没有丢,只是因为网络延迟了,而且比第二个报文先一步到达 B 端 TCP;
  4. 此时 B 端并不知道还有一个新的 SYN 报文在路上,所以认为 SQE=90,CTL=SYN 是一个正常的连接请求,然后接受它并按三次握手的规则回复给对端一个 ACK=91 报文,同时将状态更改为 SYN-RECEIVED
  5. A 端收到回包之后,对比自己和这个收到的 SQE 之后发现这是一个回复过期 SYN 的报文,于是也按照三次握手的规则将过期 SQE 加一并设置一个 CTL=RST 发给 B 端,B 端收到 RST 报文之后的状态从 SYN-RECEIVED 回退到 LISTEN,等待新的 SYN 报文;
  6. 此时 SQE=100,CTL=SYN 终于抵达了 B 端,B 端按照三次握手的规则正常处理,回复一个 SQE=400,ACK=101 报文并变更状态为 SYN-RECEIVED
  7. A 端收到回包之后,检查无误之后状态更新为 ESTABLISHED
  8. A 端最后再回复一个 SQE=101,ACK=401 的报文给 B 端,使 B 端的状态也变更为 ESTABLISHED,在重复 SYN 报文的场景下成功建立正确的连接。

然后我们再根据这个例子来论证为什么建立连接必须得是三次握手,事实上在网络状况是理想的时候,两次握手其实就足够了,我们回顾一下三次握手的前两次,一来一回,已经把两端各自的 ISS 和 IRS 都沟通好了,理论上已经可以进行正常的通信了。但是世界并没有这么美好,因为正如我们所知,网络本身就是一个不可靠的系统,上面描述的这种 SYN 报文延迟抵达的例子是真实存在的,这种情况下,如果只有两次握手,此时只有 A 端 TCP 知道这个 SQE=90, CTL=SYN 报文是过期的,而 B 端 TCP 则毫无感知并会正常接受这个连接请求,那么 A 端就需要有一个手段能通知到 B 端,告知它这个连接有问题不能用,必须重来一遍。于是 TCP 在报文头部中引入了 RST 这个控制位,将其设置在一个新的报文中并发送给 B 端,让 B 端回退到 LISTEN 状态重新开始。

从上文论证可知,在网络不可靠的条件下,从理想中的两次握手进化到三次握手是不可避免的。因为在过期或者超时请求报文的第二次握手之后必须要有再多一次的网络通信来通知对端弃用这个连接,那么自然就产生了第三次握手。到这里我们就可以回答那个面试题了:为什么 TCP 建立连接需要三次握手?要从根源上回答这个问题,我们就要证明 TCP 可靠地建立一条连接所需的最少握手次数一定是三次,而要证明这个只需证明两次握手无法可靠地建立连接,我们已经在前一段论述中证明了在理想的网络条件下两次握手是足够的,但是在现实世界中的不可靠的网络环境中很可能会出现过期报文,因为是本端发起的建立连接的请求,自然也得让本端来决定用哪一个 SYN 报文来建立连接,而为了正确地处理这种情况,则必须要多一次额外的通信,要么是用来确认报文并告知对端一切正常,要么就是用来通知对端终止连接并重新开始,而这多出来的一次通信自然而然就成了第三次握手。

因此,我们也就证明了三次握手是在不可靠的网络中保证可靠的连接初始化的最少次数,最后根据数学归纳法,四次握手、五次握手、六次握手...这些大于三次的握手机制肯定也可以实现可靠的连接初始化,因为一般来说通信可靠性的级别是和通信次数成正比的,更多次的握手也就意味着能传递更多的信息,其可靠性一定比三次握手更强。比如三次握手过程中的第二次握手可以把 CTL=SYN,ACK 拆成两个报文分别发回去,就变成了四次握手,同样可以保证可靠的连接建立,但是就会多出来一次 RTT (Round-Trip Time),得不偿失,所以三次握手是在保证可靠性的前提下性价比最高的方案。

当然,三次握手并不是一个放诸四海皆准的实现,它只有放在 TCP 协议这个语境中才是成立的,如果要实现新的网络协议,完全可以通过其他的优化手段来减少建立连接时的握手次数,比如 Google 推出的 QUIC 协议,就极大地优化了建立连接的流程,基于 TCP 的 HTTP1/2 协议,使用 HTTPS 的时候通常需要 3 次往返 (不是握手,是 3 次 RTT,包含 TCP 的三次握手和 TLS 的握手) 才能建立连接,即便是借助 session resumption 也还是需要至少 2 次 RTT,而使用 QUIC 的话只需要 1 次 RTT,而这还是第一次访问该域名的情况下,如果是非首次连接,还能通过 QUIC 的缓存机制实现 0 RTT 的安全握手,直接进行数据传输。不过,QUIC 严格来说和 TCP 不是一个层面的协议,TCP 是 OSI 网络模型中的第 4 层传输层 (transport layer),而且是直接集成在内核中的,而 QUIC 是一种建立在 UDP 协议之上的"混合"协议,通常被认为是第 4 层传输层的协议,但我个人认为其本质应该是 4 + 5/7 层。UDP 是一种不可靠的传输协议,相较于 TCP 来说它要更加简单,没有 TCP 那么多复杂的机制来保证不丢包,也就是说 UDP 不会像 TCP 那样保证数据传输的可靠性,而 QUIC 作为一种通用的可靠性传输协议,它必须在 UDP 协议之上(用一种更高效的方式)重新实现一遍 "TCP"。除了减少握手次数之外,QUIC 相较于集成在内核中的 TCP 而带来的较高的更新成本 (需要升级内核才能使用最新的 TCP),QUIC 直接部署在应用端,因此可以实现快速更新迭代,不过 QUIC 协议是另外的故事了,以后有机会再聊。

这个 RST 控制位可谓是至关重要,不仅可以用于三次握手,而且也可以用于处理其他的连接异常情况。

Half-Open Connections

上面的是一个比较简单示例,还有其他更复杂的情况7

If the TCP peer is in one of the synchronized states (ESTABLISHED, FIN-WAIT-1, FIN-WAIT-2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT), it aborts the connection and informs its user. We discuss this latter case under "half-open" connections below.

这段话是 TCP 协议标准中接在上面那段话后面的,引出了一类新的问题:半开的连接8 (half-open connections)。这一类问题和第一个例子中的不同之处在于,上一个例子中出现问题的时候对端是出于非同步状态 (SYN-SENT,SYN-RECEIVED 等),也就是连接还没有成功建立之前,而 half-open connections 这一类问题描述的则是连接已经建立成功,然后其中的一端发生问题断开 (比如某一端突然宕机或者重启),这也是下一节要介绍的 TCP keepalive 机制要预防和解决的问题,但现在我先来介绍一下这一类问题是如何产生的,以及 TCP 的 RST 控制位是如果处理的。

假设有这样的一个场景9:A 端和 B 端正在使用 TCP 通信,这时候 A 端发生系统错误导致重启。通常不同操作系统上的 TCP 实现都会有错误恢复机制,能够使 A 重启后重新连接 B 端或者继续发送数据,如果是后者,A 端的 TCP 会收到一个 "connection not open" 的错误,然后还是会开始重新连接,整个流程如下所示:

  1. A 端 TCP 发生系统错误重启,重启前发送了 300 字节的数据到 B 端,成功到达的只有 100 字节;
  2. A 端重启后 TCP 处于 CLOSED 状态,此时 B 端 TCP 处于 ESTABLISHED 状态;
  3. A 端 TCP 尝试重新连接,发送 SQE=400,CTL=SYN 的报文到 B 端,状态转为 SYN-SENT
  4. B 端 TCP 收到报文的时候正处于同步状态 ESTABLISHED,发现报文中的序列号 (400) 超出了它本地已确认接收的数据序列号 (0~99),于是回复一个 ACK 报文,提示 A 端应该发序列号从 100 开始的数据;
  5. A 端收到回包之后,发现 ACK 的值不是 401,也就是对端没有按照三次握手的规则回复自己,于是认定当前是一条 "half-open" 连接,于是发送一个 RST 报文给 B 端,要求终止当前连接;
  6. B 端收到报文之后,马上终止当前连接;
  7. A 端重新发送第 3 步中的报文到 B 端请求建立连接,后面就又进入到了三次握手的流程。

除了 A 端重启后进行重连操作之外,如果在 A 端重启期间或者之后,B 端尝试给 A 端发送数据时候也能触发 A 给 B 发送 RST 报文终止连接,因为当 B 端的数据报文到达 A 端的时候,TCP 发现这个数据报文的连接在 A 端根本不存在,于是判定这条连接是异常的,要求立刻终止。

可以看出,RST 控制位对于 TCP 处理连接问题有多么重要,这个控制位被广泛地应用于 TCP 的连接错误处理中,每当连接异常,或者每当收到一个不正常的报文,TCP 都会马上发送 RST 到对端要求终止当前连接,然后重新建立新的连接,以确保双端的通信始终都是基于一条健康的连接来进行。TCP 在收到非法报文之后触发 RST 报文的连接状态可以大致分成以下三类10

  1. 报文对应的连接不存在,或者说状态是 CLOSED,此时 TCP 会发送 RST 到对端要求立刻终止该连接,本端连接状态保持不变。
  2. 当前连接处于非同步的状态 (LISTENSYN-SENTSYN-RECEIVED),且报文 ack 的序列号是一个本端未知的序列号,或者报文的安全等级不符合请求的级别,TCP 就会发送 RST,本端连接状态保持不变。
  3. 当前连接处于同步状态 (ESTABLISHEDFIN-WAIT-1FIN-WAIT-2CLOSE-WAITCLOSINGLAST-ACKTIME-WAIT),而报文 ack 一个未知的序列号、或者序列号超出窗口大小等,TCP 会回复一个空 ACK 报文 (不携带任何用户数据),其中的序列号是当前的 ISS,确认号是下一次期望接收的数据序列号,本端连接状态保持不变。但如果是报文的安全等级不符合请求的级别,则还是发送一个 RST,状态转入 CLOSED

发送 RST 的那一侧的 TCP 会进入 TIME-WAIT 状态。

TCP 传输数据的阶段

三次握手成功之后我们就会进入到数据传输阶段,现在我们再回到第一个最简单的三次握手5

三次握手完成之后就可以开始数据传输了,A 端 TCP 将数据打包成报文之后发送到 B 端,然后 B 端 TCP 接收数据之后就回复 ACK 报文,ACK 的值就是 A 端发过来的报文中 SQE 加上 Data 区域中的数据长度,也就是 A 端下一次应该发过来的数据的起始序列号。比如下面的例子2,第一次的序列号是 1,数据长度为 36,那么 ACK 值就是 37。

值得一提的是,我们最常用的 HTTP 协议就是基于 TCP 的,HTTP/1 在不开 pipeline 的情况下是客户端发送一个 request 请求,然后服务端回复一个 response 响应这样一来一回的模式,因此 HTTP 协议希望当请求包抵达对端的时候能尽快开始处理并回复,所以当一个 HTTP 请求包的尺寸不是很大而且可以用一个 MSS 就完成传输的话,这个 HTTP 请求在传输层的报文中通常就会设置一个 PSH 控制位,要求 TCP 收到这个包之后不要放入缓冲区了,解包之后直接转交给应用层去处理,因为后面已经不会再有数据了。

TCP 进行数据传输过程中还会具备以下的特性:

通过序列号和确认号保证数据完整性

我在前面的 TCP 建立连接的阶段中已经讲过很多次 SQEACK 了,这两个概念同样也被应用到了 TCP 传输数据的阶段中:TCP 通过检查 SQE 和回复 ACK 来实现可靠的数据传输。发送端将数据发送出去之后会一直等待对端的 ACK,如果收到 ACK 则说明数据已经成功抵达对端,反之如果一直等不到 ACK,则说明数据很有可能已经丢失了。一旦发送端认定数据丢失,则需要进行重发以确保对端能收到完整的数据,当然就算没有收到 ACK 并不能完全断定就是数据包丢失了,也有可能是 ACK 回包丢失了或者延迟了,但不论是数据包丢了还是 ACK 包丢了或是延迟了,都可以进行数据重发,接收端会根据 SQE 进行去重,重复数据会被筛掉。

动态计算重发的超时时间

前面描述了数据重发的可能性,但是等多久没收到 ACK 就算是超时呢?这个时间不能太长也不能太短,太长的话会导致性能低下,太短的话又会导致重发频率过高浪费带宽。而且这个值还需要根据不同的网络环境来设置,比如在 LAN 中这个值就要尽量小一点,在 WAN 中就要长一点。即便是在同一个网络中,也会有高峰期低峰期导致流量拥塞状况不同,这时候这个超时时间又不能一概而论。

TCP 作为一种通用的高性能传输协议,必须要有适应不同网络环境的动态伸缩能力,也就是说 TCP 必须要保证在不同的网络环境中都能提供尽可能高的数据传输性能。所以,TCP 是通过动态计算来确定这个超时时间的。TCP 在每次发送报文的时候都会计算 RTT (Round-Trip Time,往返时间) 及其标准差,将这个 RTT 加上标准差得到一个值,然后将超时时间设置成比这个值稍高一点即可。

以 MSS 为单位发送数据

在 TCP 建立连接的阶段同时也会确定一个未来发送数据报文的最小单位,也就是最大消息长度 (Maximum Segment Size,MSS)。由于传输层的下面的网络层受到再下一层的数据链路层的 MTU (Maximum Transmission Unit) 的限制,IP 协议会对数据包进行分片处理 (IP Fragmentation),而 TCP 报文又是存储在 IP 包之中的,所以 MSS 的值最好设置成 IP 分片的阀值,避免被分片处理,这样能保证一个 TCP 报文的 MSS 不会被拆分发送然后再到接收端重新组装。

传输大块数据的时候,TCP 会以 MSS 为单位拆分数据并分批发送,重发时也是一样。MSS 的值是在三次握手期间动态计算的:两端的机器会在交换报文的时候再 TCP 报文头中写入 MSS 选项,告知对方自己的网卡接口能处理的 MSS 大小,然后 TCP 会选择其中较小的那个作为 MSS 的最终值。

流量控制

前面提到过,TCP 的数据接收端需要对数据进行去重和排序等操作,当数据量很大的时候,可能会出现接收端过载而无法继续接收对端发过来的数据这种情况,继而导致无法及时回复 ACK 给对端,这又会导致发送端以为数据丢失然后进行数据重发,对接收端来说是雪上加霜。

为了缓解这种现象,TCP 提供了一种可以让接收端主动告知发送端它当前的负荷,从而使发送端调整其发数据的力度,也就是所谓的流控制。接收端通过在回复 ACK 的 TCP 报文头中的 Window 字段写入一个值来通知发送端当前自己还能接收的最大数据长度,让发送端根据这个值调整自己的发送力度,这个值越小就越表示当前接收端有点忙不过来了,发送端应该要减缓发送速率。

发送端如果发现接收端的回复报文中的 Window 字段值为 0 的时候,就会停止发送数据,然后等待接收端再次发送报文过来通知发送端可以继续发送数据。为了防止接收端的通知报文在网络中丢失,发送端也会周期性地主动向对端发送一个嗅探报文去获取最新的窗口大小。

利用窗口控制加速

如果按照常规的发一个数据报文收一个 ACK 然后再发下一个报文这样的模式进行数据传输,如果网络环境比较差导致 RTT 比较大,那么数据传输的效率就会很低。为了优化这个地方,TCP 引入了窗口的概念:传输数据的时候不再需要等待上一个报文收到 ACK 之后再发下一个,而是可以继续一直发,窗口大小就是能够持续发送的数据长度。也就说原先发送一个包含序列号 1 至 1000 的报文收到 ACK 之后才能发送 1001 至 2000 的报文,现在改成了发完第一个报文之后不用等 ACK 就可以立刻发送下一个报文。这个优化需要用到缓冲区,多个连续报文存储在缓冲区中,只要窗口范围内的前面部分的连续区域中的报文收到了 ACK,窗口就可以不停地往前滑动并继续发送后面部分的区域里的数据到对端并清理掉那些已经收到 ACK 回复的报文。如果窗口中的所有报文数据都正常收到了 ACK 就能以窗口大小为单位再往前一次性向前滑动,同时清除掉缓冲区中对应的区域,如果窗口中有报文没有收到 ACK,那么就要进行重发。这种机制也就是滑动窗口机制 (Sliding window)。

拥塞控制

高速重发控制

上一节中的滑动窗口机制在正常的网络环境中可以工作得很顺畅,但是一旦网络环境变差从而导致窗口中一些报文没有收到 ACK,那就必须得不断地重发数据,我们已经知道没有收到 ACK 也不一定就是数据包丢失了,也有可能只是 ACK 包丢了,数据其实已经成功被对端接收了,这个时候如果再重发数据只会浪费带宽。TCP 据此引入了高速重发控制 (Fast Retransmission),相对于前文提及的超时重发 (Retransmission) 是通过超时时间来决定是否重发,高速重发控制选择通过重复确认消息来决定是否重发:在一个窗口的所有报文中,如果中间有某一个报文的数据没有成功抵达接收端,那么接收端就会针对那一个特定的报文不停地发 ACK 消息,发送端如果重复三次收到了同一个 ACK 值的消息,那就认定数据包丢失,进行重发,否则就会默认该数据包已经被对端成功接收,也就是说在一个很大的窗口中即便中间有一些数据报文的 ACK 丢失也不需要进行重发,只要窗口中最后的那个数据报文的 ACK 有收到就可以直接进行窗口滑动了。

慢启动和拥塞窗口

有了前面提及的这些优化手段,收发两端之间就能够以一个比较高的性能传输大量的数据报文。但是在同一网络中传输的数据报文过多也可能会引起拥塞,就像是高速公路上的堵车一样。一旦发生网络拥塞,那就算单个节点的数据吞吐量再大也没用了,因为都被网络中的其他节点数据报文堵住了。

TCP 为了缓解这个问题,用了另外两种拥塞控制的机制:慢启动 (Slow Start) 和拥塞窗口 (Congestion Window)。慢启动机制的意思是在 TCP 刚开始进行通信的时候,会通过一个慢启动的算法实时计算数据传输的速率,尽量避免网络拥塞。慢启动机制利用一个叫拥塞窗口的缓冲区来实现:刚开始的时候根据网络环境将拥塞窗口的大小设置为 1、2、4 或者 10 MSS,然后以此来发送数据,之后每收到一个 ACK 应答就将拥塞窗口的大小加 1 MSS。每次按照拥塞窗口大小发送数据的时候会和流量控制那一节中介绍的接收端窗口大小作比较,选择二者中更小的那个窗口大小发送数据。

随着数据报文的 RTT 次数上升,拥塞窗口大小的也会呈指数级别地扩大,为了防止拥塞窗口变得过大,TCP 设置了一个慢启动阀值,一旦拥塞窗口超过了这个阀值,或者发送端检测到第一个数据报文丢失,又或者发送端发现接收端的流量控制窗口大小变成了 0,拥塞窗口的涨幅就会被某种和 ACK 报文数量成反比的公式所限制 (不同的慢启动算法的实现也会不太一样),也就是往后的每一个新的 ACK 报文给拥塞窗口带来的扩大比例会逐步降低,甚至最后有可能拥塞窗口每次扩大的长度小于 1 MSS,从而缓解网络拥塞。这里要注意拥塞窗口和前面提及的滑动窗口虽然都是在发送端维护的,但它们不是一个东西。

另外,慢启动机制能够适配超时重发和高速重复这两种重发控制机制。通过慢启动机制,在 TCP 通信开始的时候网络中的数据报文不会突然呈现出一种爆发式的上涨,而是一种渐进式的上升,而后如果发生网络拥塞现象,慢启动机制又会调整每一个节点吞吐量使其急速下降从而让整个网络的中的数据报文数量也快速回落,然后就又会慢慢上升,周而复始。

TCP 终止连接的阶段

四次挥手

一次正常的 TCP 连接终止流程如下11

  1. A 端 TCP 和 B 端 TCP 处于 ESTABLISHED 状态,正常通信中;
  2. A 端 TCP 执行主动关闭 (active close,一般是通过调用 close() 或者 shutdown() 系统调用来完成),发送 FIN 报文到 B 端,通知对端要关闭连接,本端状态转入 FIN-WAIT-1,这一次的报文也可以包含对 B 端上一次发送的数据报文的 ACK;B 端 TCP 收到 FIN 报文,检查无误之后执行被动关闭 (passive close),进入 CLOSE-WAIT 状态,然后把一个 EOF (end-of-file) 插入到应用程序的接收数据队列的末尾,让用户程序最后能读取到它并感知到对端已关闭 (第一次挥手)
  3. B 端回复给 A 端一个 ACK 报文,表示已收到连接终止的请求,A 端收到之后,检查无误之后转入 FIN-WAIT-2 状态 (第二次挥手)
  4. 等到 B 端的用户程序读取完缓冲区中的所有数据的时候就会收到那个 EOF,然后调用 close() 系统调用,此时 TCP 会往 A 端也发送一个 FIN 报文并进入 LAST-ACK 状态;A 端收到之后,检查无误之后转入 TIME-WAIT 状态 (第三次挥手)
  5. A 端收到 FIN 报文之后立即发送一个 ACK 报文到 B 端,正式确认应答 B 端发过来的 FIN 报文,然后 A 端转入 TIME-WAIT 状态,B 端收到这个 ACK 之后便转入 CLOSED 状态 (第四次挥手)
  6. A 端保持 TIME-WAIT 状态长达 2 MSL 之后如果没有收到 B 端发过来的任何报文,则判定对端已正常关闭,自己也随即转入 CLOSED 状态,TCP 连接正式终止。

上文提及的 MSL 全称是 maximum segment lifetime,TCP 协议标准的推荐值是 2 分钟,但是不同操作系统上的 TCP 实现可能会选择不一样的默认值,后文会介绍。

TCP 连接的终止方式分为正常方式和非正常方式,正常方式就是上面描述的这种通过发送 FIN 报文的方式,非正常方式就是前面介绍的 发送 RST 报文的方式。而且如果对端主动关闭了连接,不管是通过正常还是非正常的方式,对于本端来说必须是可区分的,通常是通过系统调用返回的不同错误来区分。

为什么需要四次挥手

前面我已经解答过了为什么 TCP 建立连接的时候需要三次握手,这个问题也是一个很经典的面试问题。但是有没有人思考过为什么 TCP 终止连接的时候需要四次挥手呢?这个问题好像就不如前一个问题那么经常出现在面试中了。实际上后面这个问题在某种程度上比前面那个问题更能体现 TCP 的 full-duplex (全双工) 传输的特性和复杂性。全双工也就是说允许两个设备之间同时进行双向的数据传输,相对的还有 half-duplexsimplex,前者也允许双向传输,但是一次只能一个方向,后者则只能单方向传输。

TCP 终止连接需要四次挥手的根源就在于其 full-duplex 特性。

Half-Closed Connections

TCP 协议标准12把针对连接的关闭操作定义为 "我已经没有数据需要发送了"。只有当 TCP 连接的状态变成 CLOSED 才真正意味着连接已经完全关闭了 (读写两个方向的通道都关闭了),所以也就是对于上图中的 A 端 TCP 来说,在状态 1 和 6 之间都属于半关闭状态;而对于 B 端 TCP 来说则是状态 1 和 5 之间属于半关闭状态。TCP 协议标准允许不同的操作系统实现半关闭的连接终止:可以关闭连接的写方向而继续读数据,也可以关闭连接的读方向而继续写数据。

这里以 Linux 为例,通常我们关闭一个 socket 是调用 close(),这个操作主要完成两件事:

  • 将该 socket 标记为已关闭;
  • 对当前 socket 的引用计数值减一。

Linux 内核会对文件或者套接字进行引用计数,如果是磁盘文件的话,引用计数用来决定是否要真正删除掉该文件,如果是 socket 的话,引用计数则用来决定是否要真正终止并释放该连接,如果 socket 的引用计数减一之后归零,那么内核就会让 TCP 向对端发送一个 FIN 报文开始四次挥手终止连接并最终释放掉 socket 对应的系统资源;如果引用计数仍然大于零,则说明还有其他的进程或线程正在引用这个 socket (例如父子进程或者进程中的多线程),那么内核就不会触发 TCP 的四次挥手流程。默认情况下,调用 close() 之后会马上返回,而且该 socket 的文件描述符也不能再用于后续的读写操作,如果触发四次挥手流程,则内核会在连接完全终止之前把当前 socket buffer 里的数据都先发送到对端。

可以看出 Linux 的 close() 系统调用的语义明显和 TCP 协议标准中的 CLOSE 操作是不同的。TCP 协议标准对 CLOSE 操作的定义较为宽松,不要求 CLOSE 一定要完全关闭连接的读写两个方向,只要最终两端的 TCP 进入 CLOSED 状态完全终止连接即可,但是 Linux 的 close() 则是会关闭读写两个方向。当然 Linux 的 close() 其实是在文件描述符这个层面上屏蔽了后续的读写操作 (也就是 socket fd 被标记成不可用,再对其调用 read()/write() 会返回错误),这条 TCP 连接实际上还是可用的 (正如我上文所述,A 端 TCP 在状态 1 和 6 之间属于半关闭状态),只是内核不再允许用户程序操作它了而已。

更加贴近 TCP 协议标准中的 half-close 操作的 Linux 系统调用应该是 shutdown(),它相对于 close() 有如下两个特点:

  1. shutdown() 会直接启动 TCP 四次挥手的流程,向对端发送一个 FIN 报文,不需要等引用计数归零。
  2. 可以关闭全双工连接的单个方向13

该系统调用的函数签名如下13

#include <sys/socket.h>

int shutdown(int sockfd, int how);

how 的可用值有:SHUT_RDSHUT_WRSHUT_RDWR,分别表示关闭读方向、关闭写方向和关闭读写方向,这三个值对 TCP socket 的具体影响是:

  • SHUT_RD:关闭连接的读通道 —— 进程不能再对 socket 进行读操作,而且 socket buffer 中的现存数据也会被丢弃。使用这个值执行 shutdown() 之后,已接收的数据如何处理取决于操作系统实现,Unix 会 ack 这些数据然后丢弃,Linux 会 ack 这些数据然后存入 socket buffer,如果当前有其他进程或者阻塞在 read()recv() 等操作上,则唤醒它们并返回数据。这个操作不会向对端发送 FIN 报文,只是本地标记该 socket 不能再进行读操作。
  • SHUT_WR:关闭连接的写通道 —— 进程不能再对 socket 进行写操作,但是 socket buffer 中的数据会被发送到对端,然后再跟一个 FIN 报文开启四次挥手流程,不受 socket 的引用计数是否归零这个条件的限制。
  • SHUT_RDWR:关闭连接的读写通道 —— 相当于使用 SHUT_RDSHUT_WR 分别调用 shutdown() 两次。

SHUT_WR 相比较于其余两个参数更加常用,有时候我们需要通知对端现在已经完成了所有数据的发送,但是还需要接收对端的数据时,就可以用这个参数调用 shutdown(),调用完之后这个 socket 还可以调用 read() 或者 recv() 等函数继续读取数据直到它们返回 0,这表示 EOF,也就是对端已经 close() 且数据已经读完,然后就可以再调用 close() 完成四次挥手的最后一步并释放掉这个 socket 了;SHUT_WR 的另一种使用场景是当你使用了 fdopen()14 基于 socket fd 创建了一个文件指针在使用,如果你使用的是 close() 来关闭 socket,socket 的系统资源会被释放掉,包括 socket fd 也会被回收,那么内核之后就有可能会把这个 socket fd 分配给新的打开文件使用,这时候你如果继续操作前面创建的文件指针就会污染另一个文件的数据,但如果你使用的是 SHUT_WR,那么操作那个文件指针就只会报错,你直接关闭掉就行。所以就算使用了 shutdown() 最后也需要调用 close(),因为前者不会释放 socket 对应的系统资源。

现在我们就能回答这个问题了,为什么 TCP 终止连接需要四次挥手?因为 TCP 连接是全双工的通道,而根据 TCP 协议标准11CLOSE 操作被定义为 "我没有数据要发送了",然后将缓冲区里遗留的数据发送到对端,同时还允许本端继续读已接收的数据,对端执行 CLOSE 操作后也是如此。也就是说 CLOSE 操作的本质是保证连接最终能达到"全关闭" (相对于前文所述的半关闭),同时确保读写两个方向最后的数据都能得到妥善处理 (在 Linux 中可以直接通过 close() 关闭读写两个通道,也可以用 shutdown() 关闭其中一个通道,继续操作另一个通道,最后彻底关闭就行)。所以,对于全双工的 TCP 连接来说,需要两端都执行一次 CLOSE 操作发送 FIN 报文去主动通知对端说数据已经都读写完,可以正式关闭连接了,并得到对方回复的 ACK 确认报文,两端都需要一去一回共两次报文,共计四次网络包传输,这就是四次挥手背后的原理。

关于 TCP 使用 close() 系统调用方面还有一个很重要的套接字选项 —— SO_LINGERclose() 的默认行为会保证在关闭 socket 之后还会把缓冲区中遗留的数据都发送到对端,但是通过设置 SO_LINGER 可以改变这一行为,用于更复杂的场景。这一部分的内容在《UNIX 网络编程 —— 卷一:套接字联网 API》的第七章第 5 节中有详尽的论述,感兴趣的请自行前往阅读,这里不再赘述。

TIME-WAIT 状态

TIME-WAIT 在 TCP 中可能是最令人疑惑的一个网络状态了,这个状态看起来好像没有必要存在似的,但实际上这个状态非常重要。一个连接从进入 TMIE-WAIT 状态到最终变成 CLOSED 要持续 2 个 MSL 的时间。任何的 TCP 实现都需要给 MSL 设置一个默认值,最新的 TCP 协议标准15 对此的建议值是 2 分钟,不过也有一些 TCP 实现会选择其他的大小,比如 Linux 选择的是 1 分钟,BSD 系列的操作系统选择的是 30 秒,所以 TIME-WAIT 状态的持续时间通常在 1 分钟到 4 分钟之间。

这个状态的作用主要是:

  1. 可靠地实现 TCP 全双工连接的终止。
  2. 防止过期的 TCP 报文出现在新连接中。

第一个作用我们可以通过一个场景来理解:假设 TCP 四次挥手的最后一个 ACK 报文丢失了,也就是说 B 端没有收到 A 端对它的 FIN 报文进行确认的 ACK 报文,那为了保证四次挥手的可靠性,B 端肯定要重发它的 FIN 报文通知 A 端重发 (实际上 TCP 也必须妥善处理四次挥手中的其余报文丢失的情况,不止是最后一次的报文)。因此 A 端必须要记录并维护这个状态信息,也就是说 A 端需要知道第二次收到对端的 FIN 报文表示需要重发最后一个 ACK,否则的话,如果没有记录这个状态信息,则会把第二个 FIN 当成是非法报文,然后就会发送一个 RST 报文,导致四次挥手不能正常完成。所以要用一个新的状态 TIME-WAIT 来记录并维护这个信息,有人可能要问了,为什么非得引入一个新状态,用原来那个 FIN-WAIT-2 不行吗?理论上可以,让这个状态记录多个信息,但是根据单一职责的原则,每一个状态最好不要表示多种含义,FIN-WAIT-2 已经用来表示接收到对端的对其 FINACK 了,所以最好的方式就是引入一个新的状态。

第二个作用可能更加的重要。我们知道数据在网络中很有可能会莫名其妙就丢失了,甚至会莫名其妙又出现了,这通常是路由异常引起的:某一个报文在经过路由器的时候,路由器突然崩溃或者两个路由器之间的某条线路断了,路由协议可能需要花费几秒到几分钟的时间从故障中恢复过来并找到另一条通路继续传送这个报文,在这期间 TCP 可能会判定这个报文已丢失并进行重发,与此同时路由循环被修复了,那个被判定已丢失的报文被重新传输到目的地,这个时候就会出现重复报文的问题,这个问题我们已经在 TCP 三次握手那一章讨论过了,这个问题在只有三次握手的场景中可以被妥善处理。

然而如果这个问题发生在四次挥手和三次握手之间,就会比较棘手,因为 TCP 连接是可以被重用的,也就是说可以在关闭某条连接之后立刻用相同的 IP/Port 重新打开一条新的连接,如果一个连接终止到连接重新建立这个过程发生在一个非常小的时间窗口内的话,比如我们在 A 端关闭某一条指向 B 端的 TCP 连接 X 之后马上又使用相同的本地 IP/Port 和 B 端的 IP/Port 重新打开新的一条连接 Z (三次握手已完成),新连接被称之为旧连接的化身 (incarnation),如果重复报文在此时抵达 B 端,就会被 TCP 送往连接 Z,因为这个重复报文中的 IP 包中的目的 IP/Port 和新连接的 IP/Port 是一样的,那么 B 端 TCP 可能会判定这是一个非法的报文从而发送 RST 终止连接,TCP 协议必须正确地处理这种问题。TCP 引入 TIME-WAIT 状态就是为了解决这个问题,TCP 禁止处于 TIME-WAIT 状态的连接被重用,而这个状态会持续 2 MSL,通常足够让这条旧连接上所有的重复报文都传输完成并丢弃掉了,之后再建立的新连接上就不会再有来自上一条连接的过期报文了。当然,像 Linux 和 FreeBSD,还有其他的类 Unix 操作系统都提供了 SO_REUSEADDRSO_REUSEPORT 套接字选项,允许用户无视 TIME-WAIT 强制重用连接,但这是另一个故事了,我后续会再写一篇文章来深入介绍这方面的内容。

TCP keepalive 机制

我在 "TCP 连接从建立到终止" 那一章中已经介绍过 half-opened connections 的问题,正好可以用来引入 TCP keepalive 这个概念:

具体流程前面已经介绍过了,这里就不再赘述了,如果不记得的话请回到上面复习一下。

A 端重启之后 A 和 B 之间的连接实际上就已经不能再用来实现可靠通信了。从 A 端的视角来看,这条连接已经不复存在了,然而从 B 端的视角来看,依旧是一切正常,因为 A 端的重启是一个突发的意外,连系统都整个宕机了所以 TCP 来不及通知 B 端终止这个连接,而系统对 TCP 连接也没有相应的持久化机制,因此在重启后也无法找回重启前所有的连接并一一通知远端关闭这些过期的连接,B 端当然就毫不知情了。这时候如果 A 端尝试重连或者 B 端尝试往对端发送数据的话,A 端的 TCP 就会检测到这是一条异常连接,通过发送 RST 报文到对端要求终止连接。

上面这种情况 TCP 能够妥善处理,但是现在考虑另一种可能的情况:A 端宕机之后就再也没有恢复过来了 (可能是硬件损坏导致机器彻底报废),或者重启之后也不进行重连了,而 B 端正在等待 A 端发送完剩下的数据之后才能响应,也就是说两边都不会再去操作这条死连接 (dead connection) 了,这种现象被称之为连接泄露。如果系统中只有一两条死连接倒还好,影响不大,但要是系统管理的连接数巨大,而且其中有很多连接都因为对端异常退出而成为死连接的话就会很麻烦,因为这些死连接会持续占用系统的资源,从而挤占其他进程可用资源,如果系统资源比较紧张的话就会影响到整体的系统性能。TCP keepalive16 正是为了解决这个问题而生的。

我将在下一篇文章中介绍 TCP keepalive 机制,以及它在不同的平台上的实现的异同和坑,还有在 TCP 程序中更好地利用这个机制的最佳实践。

总结

本文分析了 TCP 协议从建立连接开始,到传输数据阶段,再到最后的终止连接的阶段这一整个网络的可靠通信过程。重点解析了三次握手和四次挥手背后的原理,之所以需要三次握手是因为要正确地处理过期的重复 SYN 报文,而之所以需要四次挥手是因为 TCP 连接是全双工的通道,需要两端都主动关闭各自的通道并发送 FIN 报文通知对方,然后还需要得到对端的 ACK 回复,至于数据传输阶段,则重点讲解了 TCP 在这个过程中所应用的优化手段如流量控制、拥塞控制等,使读者对 TCP 通信的底层工作原理有一个系统性的认知和一定深度的了解。

读者们可以把本文的各个章节作为未来深入研究 TCP 协议的入口,从这些入口进去,结合 TCP 协议标准继续一步步深入学习,最终便能够更加全面且细致地了解 TCP 协议的核心,并在以后编写 TCP 程序的时候根据这些知识进行深度的优化和改进。

参考 & 延伸阅读

Footnotes

  1. RFC 9293 —— Transmission Control Protocol (TCP): Header Format

  2. Transmission Control Protocol (TCP) —— Khan Academy 2

  3. OSI mode —— Wikipedial

  4. RFC 3168 —— The Addition of Explicit Congestion Notification (ECN) to IP 2

  5. RFC 9293 —— Section 3.5.3 2

  6. RFC 9293 —— Section 3.5.6

  7. RFC 9293 —— Section 3.5.11 2 3

  8. RFC 9293 —— Half-Open Connections and Other Anomalies

  9. RFC 9293 —— Section 3.5.13

  10. RFC 9293 —— Reset Generation

  11. RFC 9293 —— Normal Close Sequence 2

  12. RFC 9293 —— Closing a Connection

  13. shutdown(2) — Linux manual page 2

  14. fdopen(3p) — Linux manual page

  15. RFC 9293 —— Knowing When to Keep Quiet

  16. RFC 9293 —— TCP Keep-Alives