TCP 协议

[重要] 本文默认读者已经体系地学习过操作系统。

为了读者能更好地学习 TCP 协议,本文首先简单介绍 TCP 协议(是啥),然后再简述 TCP 的主要内容(干嘛的),最后再阐述 TCP 的各个细节(原理)。

1. 简介

1.1 TCP 协议是什么

与 UDP 不同,TCP(Transmission Control Protocol)则“人如其名”,可以说是对“传输、发送、通信”进行“控制”的“协议”。

TCP 与 UDP 的区别相当大。它充分地实现了数据传输时各种控制功能,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。而这些在 UDP 中都没有。此外,TCP 作为一种面向有连接的协议,只有在确认通信对端存在时才会发送数据,从而可以控制通信流量的浪费(由于 UDP 没有连接控制,所以即使对端从一开始就不存在或中途退出网络,数据包还是能够发送出去)。

在 UDP 中,由应用层划分的数据包在网络中分发的「顺序」取决于网络中的「路由选择」,是难以确定的。

1.2 TCP 协议的作用

TCP 协议是在不可靠的网络环境中提供可靠的数据传输服务而设计的。它解决了以下几个问题:

  • 数据丢失:由于网络故障、拥塞、错误或攻击,数据包可能在传输过程中丢失或损坏。TCP 协议通过序列号、确认号、校验和、重传机制等方法,保证了数据的完整性和正确性。
  • 数据乱序:由于网络的异构性、路由的动态变化、分片的不同顺序等原因,数据包可能以不同的顺序到达接收方。TCP 协议通过序列号、确认号、缓冲区等方法,保证了数据的有序性和连续性。
  • 数据重复:由于网络延迟、重传机制、路由变化等原因,数据包可能被发送或接收多次。TCP 协议通过序列号、确认号、滑动窗口等方法,避免了数据的重复性和冗余性。
  • 流量控制:由于发送方和接收方的处理能力和网络带宽可能不匹配,发送方可能会发送过多的数据,导致接收方或中间节点的缓冲区溢出。TCP 协议通过滑动窗口、停止-等待等方法,根据接收方的反馈,调整发送方的发送速率,防止了缓冲区溢出和数据丢失。
  • 拥塞控制:由于网络中的节点或链路可能超过其承载能力,导致网络拥塞和性能下降。TCP 协议通过慢启动、拥塞避免、快速重传、快速恢复等方法,根据网络状况,动态调整发送方的拥塞窗口,避免了网络拥塞和数据丢失。

这些问题将被 TCP 在一定程度上解决。

1.3 什么是“面向连接”

连接是指各种设备、线路,或网络中进行通信的两个应用程序为了相互传递消息而专有的、虚拟的通信线路,也叫做虚拟电路。

一旦建立了连接,进行通信的应用程序只使用这个虚拟的通信线路发送和接收数据,就可以保障信息的传输。应用程序可以不用顾虑提供尽职服务的 IP 网络上可能发生的各种问题,依然可以转发数据。TCP 则负责控制连接的建立、断开、保持等管理工作。

Image00226

注意,“端对端”中的“端”指的是主机上特定 口号对应的进程。

面向连接是 TCP 的一种特性,它意味着在数据传输之前,两个通信实体必须建立一个连接。这个连接是由一系列的握手消息来建立的,它们用于协商连接的参数,如序号、窗口大小和最大报文段长度。面向连接的目的是保证数据的可靠传输,即数据按照正确的顺序、完整性和无差错地到达目的地。面向连接也使得 TCP 能够实现流量控制和拥塞控制,以适应网络的状况。

2. 简述 TCP

2.1 封装和解包

  • 封装:将应用层传来的数据分割成一个个的报文段,每个报文段都有一个序号和一个校验和。TCP 在发送端将报文段封装成 IP 数据报,加上源地址和目的地址,然后通过网络层发送到目的地。
  • 解包:TCP 在接收端将 IP 数据报解封装,提取出报文段,根据序号和校验和来检查报文段的完整性和顺序。如果报文段有损坏或丢失,TCP 会发送重传请求,要求发送端重新发送报文段。如果报文段没有问题,TCP 会将其放入接收缓冲区,并按照序号排序。当接收缓冲区中有一定数量的连续报文段时,TCP 会将它们传递给应用层。根据当前网页内容,这就是 TCP 进行解包和交付的过程。

如何确定缓冲区?

我们知道:

  • 端口号是 TCP 报文段中的一个字段,它用于标识发送端和接收端的应用程序。
  • 套接字是一种抽象的数据结构,它由 IP 地址和端口号组成,用于表示网络上的一个通信点。
  • 文件描述符是操作系统为每个打开的文件或设备分配的一个整数,它可以用于读写文件或设备。

TCP 在建立连接时,会为每个连接分配一个套接字对,即一个源套接字和一个目的套接字。这个套接字对就是 TCP 连接的唯一标识。TCP 在接收端,会根据报文段中的源地址、源端口、目的地址和目的端口来匹配相应的套接字对,然后将报文段放入该套接字对对应的接收缓冲区。

TCP 在传递数据给应用层时,会根据应用层请求的套接字来从相应的接收缓冲区中取出数据。因此,缓冲区是由套接字来确定的,而不是由文件描述符来确定的。文件描述符和套接字之间有一种映射关系,即每个文件描述符都可以对应一个套接字,但不是每个套接字都可以对应一个文件描述符。

2.2 TCP 报文格式

就报文格式而言,TCP 比 UDP 复杂得多,下文结合 TCP 报文格式,阐述 TCP 是如何「解包」的,其他组成部分的功能将在第三节「详述」部分阐述。

Image00247

TCP 的报头是变长的,包括固定的 20 字节和变长的选项。其中,“数据偏移”也叫做“首部长度”,它占固定 4 位,作用是保存报头整体的长度,以便接收端能够正确解析报文中的字段。值得注意的是,虽然首部长度占 4 位,但是它的单位是 1 个字节,那么 4 个比特位能表示的范围 0~15,就能表示 0~60 字节。

图中,每一行有 4 个字节,解包步骤如下:

  1. 提取报头:
    • 除了选项之外的报头叫做标准报头,一共 20 字节。
    • 提取选项:根据 4 位首部长度获取报头的整体大小,减去 20 字节的标准报头,得到选项。如果没有选项的话就能直接得到有效载荷。
  2. 提取有效载荷:有效载荷 = 报文-报头 (-选项)

注意,TCP 连接是由以下四个属性(四元组)唯一确认的:

  • 源 IP 地址:发送数据的主机的 IP 地址。
  • 源端口号:发送数据的应用程序的端口号,通常是一个随机分配的临时端口号。
  • 目标 IP 地址:接收数据的主机的 IP 地址。
  • 目标端口号:接收数据的应用程序的端口号,通常是一个预先定义的固定端口号。

这四个属性组成了一个套接字(socket),也就是 TCP 连接的端点。一条 TCP 连接由两个套接字唯一确定,也就是通信双方的地址和端口信息。

所以『端对端』从操作系统的角度理解是进程,从代码实现的角度来看就是 socket,因为 socket 的实现 bind 了端口号。

四元组+协议 =五元组,可以唯一确认某一协议的连接。

2.3 什么是“面向字节流”

由于 TCP 面向字节流,所以它不一定每次都能接收到未被分割的数据,因此不需要判定报文之间的边界

这句话的意思是,TCP 协议在传输数据时,不会保留数据的边界信息,也就是说,发送方发送的数据可能会被拆分或合并成不同的 TCP 报文段,接收方收到的数据也可能是不完整或多个数据拼接在一起的。因此,接收方不能根据 TCP 报文段来判断数据的完整性和顺序,而需要自己定义一些规则来区分不同的数据。

简单地说,“流”就像水龙头中的水,我们要接一桶水,可以一次性接满,也可以分批次接。

TCP 是面向字节流的协议,与 UDP 是面向报文的协议相对应。UDP 协议在传输数据时,会保留数据的边界信息,也就是说,发送方发送的数据就是一个 UDP 报文,接收方收到的数据也是一个 UDP 报文,每个报文都是一个完整的数据单元。

面向字节流和面向报文的区别主要在于上层应用程序如何看待 TCP 和 UDP 的传输方式:

  • 对于 TCP 来说,数据是以字节为单位连续地传输的,没有任何结构或边界的概念。
  • 对于 UDP 来说,数据是以报文为单位分别传输的,每个报文都有自己的边界和长度。

从代码实现来看,面向字节流就相当于这些数据都由一个字符数组保存。

2.4 通过 ACK 机制实现一定可靠性

ACK (Acknowledgement,到达确认 )机制是指:

  • TCP 在接收端收到报文段后,会发送一个确认报文段(ACK)给发送端,表示已经收到了某个序号的报文段。
  • 发送端收到 ACK 后,会更新自己的发送窗口,表示可以继续发送更多的报文段。
Image00227

通常,两个人对话时,在谈话的停顿处可以点头或询问以确认谈话内容。如果对方迟迟没有任何反馈,说话的一方还可以再重复一遍以保证对方确实听到。因此,对方是否理解了此次对话内容,对方是否完全听到了对话的内容,都要靠对方的反应来判断。网络中的“确认应答”就是类似这样的一个概念。当对方听懂对话内容时会说:“嗯”,这就相当于返回了一个确认应答(ACK)。而当对方没有理解对话内容或没有听清时会问一句“咦?”这好比一个否定确认应答(NACK(Negative Acknowledgement) )。

[注] 通常情况下,大写的 ACK 表示首部的确认位是 ACK,表示这是一个确认应答报文;小写的 ack 表示确认字段的值,即接收方期望发送方下一次应该发送数据的序列号,接收方发送 ack 序号,那么表明它已经接收了到 ack 为止的所有数据,因此 ack 也叫做确认号。

TCP 通过肯定的确认应答(ACK)实现可靠的数据传输。当发送端将数据发出之后会等待对端的确认应答。如果有确认应答,说明数据已经成功到达对端。反之,则数据丢失的可能性很大。

Image00228

如果发送端在一定时间内没有收到 ACK,它会认为报文段丢失或延迟,然后重新发送报文段。这样,TCP 可以保证数据不会因为网络故障而丢失。

需要强调的是,ACK 机制并不能保证数据的顺序和完整性,也就是说,TCP 仅靠 ACK 机制是无法完全保证可靠性的。如果报文段到达的顺序和发送的顺序不一致,或者报文段被篡改或损坏,ACK 机制就无法检测出来。

因此,TCP 还需要其他的机制来保证可靠性,如序号机制、校验和机制、重传超时机制、累积确认机制、选择性确认机制等。

此处的“窗口”即下文将着重介绍的“滑动窗口”。

通过上面两张图,可以体会到 ACK 机制很像现实生活中人们之间交流的过程,这个比喻是很恰当的(事实上“通信”这件事的主体只不过是从人变成了机器,通信过程中的各种细节还是类似的)。实际上,TCP 包括目前主流的网络通信协议,都是基于 ACK 机制来实现可靠的数据传输的。只不过不同协议会根据具体需求有不同的细节和优化。

值得注意的是,之所以图示中表示报文传输的箭头总是斜的,是因为数据不管在网络还是在机器内部传输,不论路程有多短,都需要消耗一定时间。这就像子弹不论多快,都不可能以直线运动一样。

[了解] 为什么要让 TCP 提供可靠性,其他层次的协议不可以吗?

让 TCP 提供可靠性,是因为 TCP 是运输层的一个协议,而运输层的主要功能之一就是为上层的应用层提供可靠的端到端的数据传输服务。

其他层次的协议也可以提供可靠性,但是可能会有一些问题或者限制。

  • 应用层的协议可以在自己的层次上实现可靠性,例如 FTP、HTTP 等,但是这样会增加应用层的复杂度和开销,而且可能会和运输层的可靠性机制冲突或者重复。
  • 网络层的协议可以提供可靠性,例如 IPsec 等,但是这样会增加网络层的负担和延迟,而且可能会和运输层的可靠性机制冲突或者重复。
  • 链路层的协议可以提供可靠性,例如 PPP、ATM 等,但是这样只能保证链路之间的可靠性,而不能保证 端到端的可靠性,而且可能会和运输层的可靠性机制冲突或者重复。

因此,在互联网协议栈中,让 TCP 提供可靠性,是一种比较合理和高效的设计选择,它可以为上层应用提供一个可靠的字节流服务,而不需要关心下层网络的细节和不确定性。

3. 详述 TCP

3.1 基本认识

TCP 报头格式

在第二节中简单介绍了 TCP 报头中的 4 位首部长度(数据偏移),下面将介绍其他部分。

[注] 标*的为重点

image-20230708171209400

了解即可:

TCP 的报头在 Linux 内核中属于 struct tcphdr 数据类型,该类型定义在 linux/tcp.h 文件中。TCP 的报头包含了一些字段,其中 6 个标志位(URG、ACK、PSH、RST、SYN、FIN)是用来表示 TCP 的控制信息的,它们本质上是 位域/位段,即用一个字节或者一个字中的某些位来表示一个变量。

TCP 的报头的结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct tcphdr {
	__be16 source; // 源端口号
	__be16 dest; // 目的端口号
	__be32 seq; // 序列号
	__be32 ack_seq; // 确认号
#if defined (__LITTLE_ENDIAN_BITFIELD)
	__u16 res1:4, // 保留位
	doff:4, // 数据偏移,表示报头长度
	fin:1, // FIN 标志位,表示结束连接
	syn:1, // SYN 标志位,表示请求建立连接
	rst:1, // RST 标志位,表示重置连接
	psh:1, // PSH 标志位,表示推送数据
	ack:1, // ACK 标志位,表示确认收到数据
	urg:1, // URG 标志位,表示紧急数据
	ece:1, // ECE 标志位,表示显式拥塞通知回应
	cwr:1; // CWR 标志位,表示拥塞窗口减少
#elif defined (__BIG_ENDIAN_BITFIELD)
	__u16 doff:4, // 数据偏移,表示报头长度
	res1:4, // 保留位
	cwr:1, // CWR 标志位,表示拥塞窗口减少
	ece:1, // ECE 标志位,表示显式拥塞通知回应
	urg:1, // URG 标志位,表示紧急数据
	ack:1, // ACK 标志位,表示确认收到数据
	psh:1, // PSH 标志位,表示推送数据
	rst:1, // RST 标志位,表示重置连接
	syn:1, // SYN 标志位,表示请求建立连接
	fin:1; // FIN 标志位,表示结束连接
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif	
	__be16 window; // 窗口大小
	__sum16 check; // 校验和
	__be16 urg_ptr; // 紧急指针,指示紧急数据的位置
};

这是一个结构体,其中的一些字段是位段。位段是一种用来节省空间的数据结构,它可以用一个字节或者一个字中的某些位来表示一个变量。

例如,TCP 报头中的标志位字段,就是用一个 16 位的字中的 6 个位来表示 6 个不同的变量,每个变量只占 1 位。

16 位源/目标端口号
  • 源端口号(Source Port):表示发送端端口号,字段长 16 位。

  • 目标端口号(Destination Port):表示接收端端口号,字段长度 16 位。

32 位序列号

在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就 累加 一次该 数据字节数 的大小。

作用:由于请求很可能不止一个,而且通信的任意一方接收到的报文中都含有序号,所以要用序号给每个请求标号,以待条件允许时,只要对其排序,就可以实现有序地回应,解决网络包乱序问题。

*32 位确认应答号

指下一次 应该收到 的数据的序列号。即在 2.4 节中简述的 ACK 机制。

实际上,它是指已收到确认应答号减一为止的数据。发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。

作用:解决丢包问题。例如 2.4 中的第一个例子,对端主机发送了 1~1000 的数据,那么收到数据的一端就要发送 1001 的确认应答号,表示 1001 之前的数据已经被成功接收

image-20230708173847157

确认应答号非常重要,如果它的值是 x,那么发送 x 的一端要传达的信息就是:我已经收到了 x 之前(注意是之前)的数据。如果发送数据的一端收不到 x 或者收到的 x 和预期的不一样(可能是上次的),那么它会认为接收数据的一端没有成功接收到数据,即发生了丢包,此时发送数据的一端就会重新发送数据。这样发送数据的一端就能按照确认应答传达的信息继续发送下一段数据,以保证数据不被丢失。

4 位首部长度

首部长度表示 TCP 所传输的数据部分应该从 TCP 包的哪个位开始计算,看作 TCP 首部的长度。该字段长 4 位,单位为 4 字节(即 32 位)。

不包括选项字段 的话,TCP 的首部规定为 20 字节长,因此首部长度字段可以设置为 5。反之,如果该字段的值为 5,那说明从 TCP 包的最一开始到 20 字节为止都是 TCP 首部,余下的部分为 TCP 数据。

4/6 位保留位

暂时不用关心。

该字段主要是为了以后扩展时使用,其长度一般为 4 位。一般设置为 0,但即使收到的包在该字段不为 0,此包也不会被丢弃(保留字段的第 4 位(如下图中的第 7 位)用于实验目的,相当于 NS(Nonce Sum)标志位。) 。

*8/6 位控制位

字段长为 8 位,每一位从左至右分别为 CWR、ECE、URG、ACK、PSH、RST、SYN、FIN。这些控制标志也叫做控制位。当它们对应位上的值为 1 时,具体含义如图所示。

image-20230708170846789

[注] 如上所述:

  • 如果 TCP 首部没有选项(Options)字段,那么数据偏移字段的值就是 5,表示 TCP 首部长度为 20 字节。这时,保留位占 6 位,控制位占 6 位。

  • 如果 TCP 首部有选项字段,那么数据偏移字段的值就大于 5,表示 TCP 首部长度大于 20 字节。这时,保留位占 4 位,控制位占 8 位。

因此,有的书里 TCP 的保留位是 4 位,控制位是 8 位,有的是 6 位保留位,控制位是 6 位,都是正确的,只是根据不同的情况来解释数据偏移字段而已。

下面要介绍的是 TCP 首部没有选项字段的情况,即保留位占 6 位,控制位占 6 位,去除了 8 和 9 位(CWR 和 ECE)。

服务端可能会随时收到来自不同客户端的报文,所以报文中要 携带标志位区分报文的类型,实际上它们都是宏。

其中有三个标志位是关于『请求报文』的(即建立和断开连接的过程中所必须设置的):

  • SYN(Synchronize Flag):表示该报文是一个 建立连接的请求报文。SYN 为 1 表示希望建立连接,并在其序列号的字段进行序列号初始值的设定(Synchronize 本身有同步的意思。也就意味着建立连接的双方,序列号和确认应答号要保持同步。)。

  • FIN(Fin Flag):该位为 1 时,表示 本端 今后不会再有数据发送,是一个 断开连接的请求报文。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位置为 1 的 TCP 段。每个主机又对对方的 FIN 包进行确认应答以后就可以断开连接。不过,主机收到 FIN 设置为 1 的 TCP 段以后不必马上回复一个 FIN 包,而是可以等到缓冲区中的所有数据都因已成功发送而被自动删除之后再发。

  • ACK(Acknowledgement Flag):该位为 1 时,确认应答 的字段变为有效。只要报文具有『应答特征』,那么它就应该被设置为 1。TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1。细节会在『ACK 机制』中介绍。

SYN、FIN 和 ACK 标志位都可以与其他标志位组合使用,例如:

  • SYN+ACK 表示对连接请求的确认,并且也请求建立连接。

值得注意的是:

  • SYN 和 FIN 标志位都需要对方的确认,而 ACK 标志位本身就是一种确认。
  • SYN 和 FIN 标志位都会改变 TCP 连接的状态,而 ACK 标志位不会。
  • SYN 标志位只会出现在建立连接的『三次握手』过程中,FIN 标志位只会出现在终止连接的四次挥手过程中,而 ACK 标志位会出现在整个 TCP 通信过程中。简单地说:
    • SYN:只出现在连接建立阶段;
    • ACK:出现在整个通信阶段;
    • FIN:只出现在断开连接阶段。

下面是用来处理 TCP 协议中不同属性的数据的三个标志位(它们都是 建立连接之后 才会使用的标志位,它们不会出现在三次握手或四次挥手的过程中):

  • PSH(Push Flag):该位为 1 时,告知接收端应用程序应该立刻将 TCP 接收缓冲区中的数据读走,而不是等待缓冲区满了再向上交付。当 PSH 为 0 时,则不需要立即传而是先进行缓存。PSH 标志位可以提高数据的及时性,适用于实时性要求较高的应用,例如 SSH 和 Telnet。

  • RST(Reset Flag):该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。RST 标志位可以用于拒绝非法的报文段或者拒绝连接请求,也可以用于在连接发生错误时快速释放资源。

  • URG(Urgent Flag):该位为 1 时,表示包中有需要紧急处理的数据。对于需要紧急处理的数据,会结合后面的『紧急指针』。紧急指针字段指示了紧急数据在报文段中的位置。URG 标志位可以提供一种类似于带外数据的传输方式,适合于传输一些异常或重要的信息,如中断或终止命令等。

对于这后三个标记位,应该结合 TCP 的握手过程理解。

*16 位窗口大小

由于服务端在任何时候都可能接收来自不同客户端发送的数据,因此服务端接收数据的能力是有限的,而且是实时变化的。所以客户端就要以合适的速率传输数据给服务端,这取决于服务端的接收缓冲区中剩余空间的大小。类似地,客户端发送数据的能力也是有限的。

速度的快慢是相对的,这取决于通信双方的发送能力和接收能力。举个例子,新老师在上课时经常会问同学们讲课的速度,这是因为新老师需要知晓同学接收信息的能力。类似地,在上网课时,老师会时不时说“听懂打 1”,通过老师请求-学生反馈的方式获取学生的接收能力。

如何保证发送方用合适的流量发送?

服务端在响应时,给客户端同步自己的接收能力。如何告知对方呢?

报头中的 16 位窗口大小指的是接收端当前可以接收的数据量,双方进行报文交换的过程,就是报头交换的过程。参与通信的任意一方都可能会发送或接收数据,那么在发送数据时,应该将报头中的窗口大小填写为自己可以接收的数据量的大小。

值得注意的是,窗口大小是指接收端当前可以接收的数据量,它并不一定等于当前可变缓冲区的剩余大小。因为接收端可能会根据网络状况或者应用需求,动态地调整自己的接收窗口大小,而不是简单地根据缓冲区的剩余大小来设置。例如将会使用后续要介绍的『拥塞控制』算法限制窗口大小等。

关于窗口大小的具体作用,将在后续的『滑动窗口』中介绍。

其他字段

剩下的字段包括:校验和、紧急指针和选项。在此学习时可以最后再补充它们,在此仅做介绍。

下文大部分内容引用自《图解 TCP/IP》

校验和

校验和(checksum)是用来 检测 TCP 报头和数据是否有错误的,它占 16 位,是对报头和数据的所有字节求和后取反得到的。发送端在发送报文段时,会计算校验和并填充在报头中;接收端在收到报文段时,会重新计算校验和并与报头中的值比较,如果不相等,说明报文段有错误,需要丢弃或者重传。

image-20230709190634816

源 IP 地址与目标 IP 地址在 IPv4 的情况下都是 32 位字段,在 IPv6 地址时都为 128 位字段。填充是为了补充位数时用,一般填入 0。

TCP 的校验和与 UDP 相似,区别在于 TCP 的校验和无法关闭。

TCP 和 UDP 一样在计算校验和的时候使用 TCP 伪首部。这个伪首部如上图所示。为了让其全长为 16 位的整数倍,需要在数据部分的最后填充 0。首先将 TCP 校验和字段设置为 0。然后以 16 位为单位进行 1 的补码和计算,再将它们总和的 1 的补码和放入校验和字段。

接收端在收到 TCP 数据段以后,从 IP 首部获取 IP 地址信息构造 TCP 伪首部,再进行校验和计算。由于校验和字段里保存着除本字段以外其他部分的和的补码值,因此如果计算校验和字段在内的所有数据的 16 位和以后,得出的结果是“16 位全部为 1(1 的补码中该值为 0(负数 0)、二进制中为 1111111111111111,十六进制中为 FFFF,十进制中则为正整数 65535。) ”说明所收到的数据是正确的。

使用校验和的目的是什么?

有噪声干扰的通信途中如果出现位错误,可以由数据链路的 FCS 检查出来。那么为什么 TCP 或 UDP 中也需要校验和呢?

其实,相比检查噪声影响导致的错误,TCP 与 UDP 的校验和更是一种进行路由器内存故障或程序漏洞导致的数据是否被破坏的检查。

有过 C 语言编程经验的人都知道,如果指针使用不当,极有可能会破坏内存中的数据结构。路由器的程序中也可能会存在漏洞,或程序异常宕掉的可能。在互联网中发送数据包要经由好多个路由器,一旦在发送途中的某一个路由器发生故障,经过此路由器的包、协议首部或数据就极有可能被破坏。即使在这种情况下,TCP 或 UDP 如果能够提供校验和计算,也可以判断协议首部和数据是否被破坏。

16 位紧急指针

紧急指针(urgent pointer)是用来处理紧急数据的,它占 16 位,只有当 URG 标志位被设置时才有效,它表示紧急数据在报文段中的位置。发送端在发送紧急数据时,会设置 URG 标志位并填充紧急指针;接收端在收到 URG 标志位时,会根据紧急指针找到紧急数据,并优先处理。

如何处理紧急数据?

如何处理紧急数据属于应用的问题。一般在暂时中断通信,或中断通信的情况下使用。例如在 Web 浏览器中点击停止按钮,或者使用 TELNET 输入 Ctrl + C 时都会有 URG 为 1 的包。此外,紧急指针也用作表示数据流分段的标志

选项

选项(options)是用来扩展 TCP 功能的,用于提高 TCP 的传输性能。它是可选的,可以占 0 到 320 位,一般是 32 位的整数倍,这取决于数据偏移(首部长度)。选项可以用来设置一些参数或者协商一些特性,例如最大报文段长度(MSS)、窗口缩放因子(WSF)、选择性确认(SACK)等。选项一般在 TCP 连接建立时交换,也可以在数据传输过程中使用。

注意

紧急指针并不常用,也不太可靠。 紧急指针只能表示一个字节的位置,而不是一个数据块的范围;而且不同的操作系统对紧急指针的处理方式也不一致,有些会将紧急数据单独传递给应用层,有些会将紧急数据与普通数据混合在一起。因此,在实际应用中,很少使用紧急指针来传输重要或异常的信息,而更多地使用其他的方式,例如单独的信道或者应用层协议。

相比之下,校验和和选项可能更值得关注,因为它们对 TCP 的可靠性和性能有很大的影响。校验和可以保证 TCP 报文段的完整性和正确性;选项可以提供一些高级功能和优化策略。

通过序列号和 ACK 机制提高可靠性

在 2.4 中,说明了 ACK 机制能够提高可靠性,但仅靠 ACK 机制是无法完全实现可靠性的。言外之意是,TCP 为了实现它的可靠性,采取了若干措施,付出了很多代价。

其中之一就是序列号配合 ACK 机制,在 2.4 中的例子中,我只说明了『确认延迟到达』的一种情况,即主机 A 发送的数据发生了丢包,导致主机 B 无法接收数据,也就无法发送确认应答。

还有一种情况是主机 B 收到了主机 A 发送的数据,但是主机 B 发送的确认应答发生了丢包。两种情况对于主机 A 都是一样的:发送了数据却收不到确认应答。

image-20230709192411441

此外,也有可能因为一些其他原因导致确认应答延迟到达,在源主机重发数据以后才到达的情况也履见不鲜。不论如何,发送了数据却收不到确认应答,源发送主机只要按照机制重发数据即可。

对于但是对于目标主机来说,这简直是一种“灾难”。它会反复收到相同的数据。而为了对上层应用提供可靠的传输,必须得放弃重复的数据包。为此,就必须引入一种机制,它能够识别是否已经接收数据,又能够判断是否需要接收

上述这些确认应答处理、重发控制以及重复控制等功能都可以通过序列号实现。序列号是按顺序给发送数据的每一个字节(8 位字节)都标上号码的编号。接收端查询接收数据 TCP 首部中的序列号和数据的长度,将自己下一步应该接收的序号作为确认应答返送回去。就这样,通过序列号和确认应答号,TCP 可以实现可靠传输。

image-20230709192950975

其中:

  • 序列号(或确认应答号)也指字节与字节之间的分隔。

  • TCP 的数据长度并未写入 TCP 首部。实际通信中求得 TCP 包的长度的计算公式是:

    image-20231107173628682
  • MSS(Maximum Segment Size,报文最大长度):在建立 TCP 连接的同时,也可以确定发送数据包的单位,我们也可以称其为“最大消息长度”(MSS)。最理想的情况是,最大消息长度正好是 IP 中不会被分片处理的最大数据长度(这样就省去了分片和重组的成本)。

    TCP 在传送大量数据时,是 以 MSS 的大小将数据进行分割 发送。进行 重发 时也是以 MSS 为单位。

    MSS 是在三次握手的时候,在两端主机之间被计算得出。两端的主机在发出建立连接的请求时,会在 TCP 首部中写入 MSS 选项,告诉对方自己的接口能够适应的 MSS 的大小(为附加 MSS 选项,TCP 首部将不再是 20 字节,而是 4 字节的整数倍。如下图所示的+4。) 。然后会在两者之间选择一个较小的值投入使用(在建立连接时,如果某一方的 MSS 选项被省略,可以选为 IP 包的长度不超过 576 字节的值(IP 首部 20 字节,TCP 首部 20 字节,MSS 536 字节)。) 。

    image-20230709193250404

因此,TCP 是以『段』(Segment)为单位发送数据的,和 MSS 对应。

序列号和确认应答号

序列号的作用是标记数据的顺序,那么确认号有什么作用?

序列号由发送数据的一方发出,而确认号由接收数据的一方发出,确认号告诉发送数据的一方:我已经接收到了你这次发送的数据,请你从这个序号的位置继续发送

Image00227

其中,1001 和 2001 都是确认号。它们表示的意义是确认号之前的数据都已收到,这样便能保证数据的完整性(如果网络条件良好的话)。

注意,它们的单位是字节,这恰好和字符数组的单位大小相同。

序列号和确认号存在的原因是,要保证 TCP 是全双工的,即主机 A 在接收主机 B 的数据的同时,也要给主机 B 发送它自己要发送的数据(往往是主机 A 的应答)。就像生活中吵架一样,边吵边听,互不影响,既可以收,也可以发

因此,TCP 是一个 双向 的字节流协议,也就是说,每个方向上都有一个独立的字节流和序列号空间。因此,对于任意一方来说,它既有自己的序列号,也有对方的确认序号;它既有自己发送的报文段,也有对方发送的报文段。

不论是请求还是应答,本质上对于任意一方都是报文,报文在“字节流”的意义下是一个字符数组,那么序列号就相当于数组的下标,确认序号就是数组未被使用的最新的位置。

image-20230709201405566
小结

序列号和确认序号的作用:

  • 将请求和应答一一对应起来;
  • 确认序号表示的是它之前的数据已经全部收到;
  • 允许部分确认应答丢失,或者不发送确认应答;
  • 保证了 TCP 的全双工通信。

3.2 连接的建立

如何理解“连接”

我们知道,TCP 在『端对端』之间建立的信道,为上层『端』对应的进程提供服务,它由客户端和服务端的套接字(socket)以及它们之间交换的数据包(segment)组成。TCP 连接的建立、维持和终止都需要遵循一定的协议和状态机制。另外,中心化的 Client-Server 模式使得大量不同的 Client 将会与同一台 Server 建立连接,那么 Server 端势必要对这些来源不同的连接进行管理。

从数据结构的角度理解:我们知道,TCP 是处于传输层的协议,也就是说,TCP 的各种逻辑由操作系统(特指 Linux)维护,那么这些数据就得按照操作系统的规则组织,即先描述,后组织

现在我们知道了,这些连接在操作系统眼里,只不过内核中的数据结构类型(通过结构体组织),当连接成功被建立时,内存中就会创建对应的『连接对象』。管理不同的连接,即对这些连接对象进行增删查改等操作。

既然组织连接相关的数据结构需要操作系统维护,那么维护是需要成本的,主要是CPU 和内存资源。这是许多网络攻击方式的切入点。

当然,TCP 连接需要维护一些状态信息和参数,例如序号、确认号、窗口大小、重传计时器等。这些信息和参数被存储在一个称为传输控制块(Transmission Control Block,TCB)的数据结构中。每个 TCP 连接都有一个唯一的 TCB 与之对应,操作系统用一张表来存储所有的 TCB。TCB 中的信息和参数会随着连接的状态变化而更新。

为什么说在学习网络之前一定要先学好操作系统呢?

最重要的原因就如刚才所说,两个具有代表性的协议:TCP 和 UDP 都是传输层的协议,而传输层由操作系统内核维护,那么协议的实现必须符合操作系统中的规则。

另外,在 Linux 中,传输控制块(Transmission Control Block,TCB)和线程控制块(Thread Control Block,TCB)或者进程控制块(Process Control Block,PCB)之间的关系是不同的,它们分别属于不同的层次(前者是传输层,后两者是内核),它们之间的联系是:

  • 一个进程可以创建多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。因此,线程控制块中有一个指针指向所属进程的进程控制块。
  • 一个进程或者线程可以创建多个套接字,这些套接字用于与其他进程或者线程进行通信。因此,进程控制块或者线程控制块中有一个文件描述符表,其中包含了指向套接字对应传输控制块的指针。

三次握手

TCP 不像 UDP 一样不检查通信信道是否正常而直接向网络中发送数据,它会在数据通信之前,通过 TCP 首部发送一个 SYN 包作为建立连接的请求等待确认应答(为了描述的方便,通常将 TCP 中发送第一个 SYN 包的一方叫做客户端,接收这个的一方叫做服务端) 。

  • 如果对端发来确认应答,则认为可以进行数据通信。

  • 如果对端的确认应答未能到达,就不会进行数据通信。

从客户端-服务端模式的角度来看,TCP 连接的建立需要经过三次握手(three-way handshake)的过程,即:

  1. [连接请求 A] 客户端向服务端发送一个 SYN 包,请求方向 A->B 的连接;
  2. [连接请求 B+响应 A] 服务端收到后回送一个 SYN+ACK 包,表示方向 B->A 的连接请求,并同意建立 A->B 连接;
  3. [响应 B] 客户端再发送一个 ACK 包,确认连接成功。

这样,双方就建立了一个可靠的、双向的、基于字节流的连接。三次握手的图示如下。

image-20230710231316082

这些包使用 TCP 首部用于控制的字段来管理 TCP 连接,建立一个 TCP 连接需要发送 3 个包,形象的称为“三次握手”。

注意:

  • 图中虽然以 SYN 等标记位请求和应答(包括下文常用标志位代替报文),但实际上两端交换的是报文,而不是标记位。报文可能携带数据,也可能只含有报头。理论上在建立连接时,每个报文都应该有回应(就像打电话一样),在奇数次握手中,最后一个报文在连接建立之前一定没有回应的。
  • 客户端和服务端都要向对方发送建立连接的请求(SYN),并且需要接收到对方的确认应答(ACK)后,才能认为『这个方向』的通信信道建立成功。这是因为 TCP 要实现『全双工』通信,就必须要保证双方通信的信道是畅通的。
  • 有的时候把这个建立连接的过程叫做“四次挥手”,这是因为这种说法把第二次握手 ACK+SYN 拆分成了两次握手,实际上都是一样的。

为啥一个方向的信道不能保证『全双工』呢?

这个问题和『加锁』的问题非常类似。我们知道,要保证一个临界资源的读写一致性,就要保证在每次读或写时只有一个线程或进程对其操作,否则会出现数据异常。

那么如果我们读和写的部分互不干扰的话,还会出现这样的问题吗?

答案是不会,也就是说,只要我们将读和写的粒度降到尽可能小,使得它们没有交集,那么在保证数据一致性的同时,还能保证一定的效率(不过这有一定难度),因为读写的区域往往是变化的。

不过『全双工』的实现只需要靠读写两个缓冲区即可。

为什么是 3 次握手,而不是 1 次、2 次、4 次?

这是一个经典的问题,有很多不同的解释和角度。可以从几个方面来回答,在这里仅从效率角度讨论,在『再次理解“三次握手”中』会从多个角度回答这个问题。

  • 为什么不能用 1 次或 2 次呢?

    • 如果只用 1 次,那么客户端发送一个 SYN 后就认为连接建立成功,但是如果这个 SYN 丢失了或者被延迟了,那么服务器端就无法知道客户端的请求,也无法给客户端发送数据。除此之外,每次连接都会占用服务端一定的 CPU 和内存资源,只用 1 次握手就认为建立连接成功,那么当服务端在短时间内接收到大量 SYN 连接请求,会造成服务端异常,即 SYN 洪水攻击。
    • 如果只用 2 次,那么客户端发送一个 SYN 后,服务器端回复一个 SYN+ACK 后就认为连接建立成功,但是如果这个 SYN+ACK 丢失了或者被延迟了,那么客户端就无法知道服务器端的响应,也无法给服务器端发送数据。这种情况下,如果客户端重复地向服务端发送 SYN 请求,也会造成服务端的 SYN 洪水。
  • *从连接失败的成本来说,如果是 3 次握手,客户端和服务端互相发送报文时,主动建立连接的一方是第一个发送 SYN 报文和最后一个发送 ACK 的一方。那么客户端建立连接的时机会比服务端更靠后。也就是说,建立连接的双方发出和收到的报文数量都是相等的,这样 SYN 洪水攻击也就失效了,因为三次握手会让发出 SYN 的一方(即服务端)接收等量的 ACK 响应,当最后一次 ACK 没有被成功接收时,失败的成本就会嫁接到客户端,这样服务端就能承担最小程度的连接失败成本。

  • 那么为什么不能用 4 次或更多呢?其实从理论上讲,用 4 次或更多也是可以的,只要最后一次是客户端发送一个 ACK 给服务器端就行(为啥?因为要嫁接连接失败成本),即 5/7/9 次。.. 但是这样做没有必要,因为第三次握手已经足够保证双方的同步和确认信息了,再多发送一次或多次只会增加网络开销和延迟。也就是说,三次握手是验证双方通信信道连接成功的最小次数

TCP 的三次握手,主要是为了在保证连接可靠性和双向性的同时,尽量减少网络开销和延迟。

此外,TCP 的三次握手嫁接连接失败成本的限度是有限的,因为攻击者的机器可能会有很多,如果攻击者使用病毒感染世界各地的机器,操纵它们在同一时刻向同一台服务器发送仅仅几次连接请求,这样失败的成本对于每台发送请求的主机而言只是几个毫无作用报文,甚至比打开浏览器访问一个网页的成本还要低,而被攻击的服务器如果 CPU 和内存不够强大的话,会承受不住压力而出现异常。这就是 DDoS(分布式拒绝服务)攻击。因此 TCP 采取了更多保护措施,例如黑白名单过滤策略等等。

值得注意的是,TCP 的三次握手并不能保证连接可靠性(下面这一节会介绍),它要解决的问题有两个:

  • 嫁接连接失败成本
  • 验证全双工通信(主要),即保证两个方向的通信信道通畅。

三次握手的目的不仅在于让通信双方了解一个连接正在建立,还在于利用数据包中的选项来传递信息。

可靠性

尽管 TCP 依靠各种办法使得连接成功的可能性尽可能高,但是三次握手并不能 100%保证双方通信信道连接成功,这是因为,三次握手中的前两次握手能确保一定被对端接收到,而第三次握手是无法知晓它是否成功被接收的。原因在于此时服务端可能会出现宕机、关机等不可预测的行为,导致第三次握手的 ACK 无法正常被服务端接收,也就是丢包,这样连接就会建立失败。

第一次和第二次握手丢包不需要担心,因为如果发送报文的一方在一定时间内没有收到对方的反馈,就会重新发送报文。

实际上,不存在 100%可靠的网络协议,但是 TCP 能够在『局部』以最大限度地保证可靠性。『局部』从通信的距离理解就是『端到端』的距离,言外之意是,当通信的距离(物理上)很长时,网络协议难以保证其可靠性。

  • 这是因为任何经由某种介质的通信行为都可能受到干扰、丢包、延迟等影响,这是一个从数学和物理上都无法解决的两军问题。

TCP 在局部保证了 100%的可靠性,是因为它通过一系列机制保证数据能够保序、无差错、不重复地从一端传输到另一端。

一个很常见的例子:游戏厂商往往会在各地架设服务器,以供玩家选择最短距离的服务器,这样延迟能尽可能低,丢包率也会比较稳定。加速器也是类似的原理,有些服务器离玩家很远,那么加速器充当着跳板的角色,间接地缩短了两者的距离。

标志位
RST

在客户端发送第三个报文即 ACK 报文后,客户端此时可能会直接向对端发送数据(报文),但由于这个 ACK 报文是没有应答的,因此如果服务端未收到 ACK 报文时,服务端认为连接出现异常,会返回一个含有异常标志位的报头信息 RST。

仅做举例,实际上发生类似情况的概率很小。因为客户端发送数据时也会携带 ACK 标记位。

PSH

让优先级更高的报文先被处理。

URG

这里的『指针』不应该局限于语言层面上的指针,实际上只要能表示『方向』,都可以叫做指针。紧急指针表示的是一个位置,但是 16 位只能表示一个地址,它本质上是一个偏移量。

TCP 的状态

上文简要介绍了 TCP 三次握手的过程,以及三次握手的原理,既然第三个报文 ACK 无法收到应答,那么什么时候才算连接建立成功呢?这就需要用各种状态表示当前 TCP 连接,以对应不同的操作和响应。

友情链接:TCP 的 11 种状态

TCP 的状态有 11 种,分别是:

  • CLOSED:初始状态,表示 TCP 连接是“关闭着的”或“未打开的”。
  • LISTEN:表示服务器端的某个 SOCKET 处于监听状态,可以接受客户端的连接。
  • SYN_SENT:表示客户端已发送 SYN 报文,请求建立连接。
  • SYN_RCVD:表示服务器收到了客户端的 SYN 报文,并回复了 SYN+ACK 报文,等待客户端的确认。
  • ESTABLISHED:表示 TCP 连接已经成功建立,双方可以进行数据传输。
  • FIN_WAIT_1:表示主动关闭连接的一方已发送 FIN 报文,等待对方的 ACK 或 FIN 报文。
  • FIN_WAIT_2:表示主动关闭连接的一方已收到对方的 ACK 报文,等待对方的 FIN 报文。
  • CLOSE_WAIT:表示被动关闭连接的一方已收到对方的 FIN 报文,等待本地用户的连接终止请求。
  • CLOSING:表示双方同时发送了 FIN 报文,但是主动关闭连接的一方没有收到对方的 ACK 报文,等待对方的 ACK 报文。
  • LAST_ACK:表示被动关闭连接的一方已发送 FIN+ACK 报文,等待对方的 ACK 报文。
  • TIME_WAIT:表示主动关闭连接的一方已收到对方的 FIN+ACK 报文,并回复了 ACK 报文,等待足够的时间以确保对方收到 ACK 报文。
三次握手

TCP 的状态在三次握手中的变化是这样的:

image-20230711173812497

图片和描述来自:小林 coding:TCP 三次握手过程是怎样的?

注意:

  • 第三次握手可以携带数据,前两次握手不能携带数据。因为它是一个普通的 TCP 确认报文段,它的 ACK 标志位被设置为 1,表示对服务端的 SYN+ACK 报文段的确认。如果客户端有数据要发送,它可以在这个报文段中携带数据,而不必等待服务端发送数据。

    这么做的好处是可以提高传输效率,减少网络延迟。否则就要等待服务端发送数据后才能发送它自己的数据,这样就增加了一个往返时间。

    不过,TCP 的第三次握手是否能够携带数据,取决于服务端是否支持,否则可能会造成网络拥塞和重传。

  • 图中的箭头指向的状态交界处是有原因的,状态改变的时机只在发出或接收到报文。


回答本节的问题:

只有双方都处于 ESTABLISHED 状态,才能认为 TCP 的连接是成功的,双方才能正常发送数据。TCP 的第三次握手发送的 ACK 报文是没有响应的,因为它只是用来确认对方的 SYN+ACK 报文,而不是用来请求建立连接。

  • 对于客户端而言,一旦发送了这个 ACK 报文后,它就处于 ESTABLISHED 状态,因为它已经完成了三次握手的过程。
  • 对于服务端而言,只有当它收到了这个 ACK 报文以后才会处于 ESTABLISHED 状态,因为它需要等待客户端的确认才能确定连接已经建立。

这样,服务端和客户端在 TCP 的连接成功的认知上存在着时间差,如果服务端并未收到第三次握手发送的 ACK 报文,会出现什么情况?

  • 服务端的 TCP 连接状态为 SYN_RECV,并且会根据 TCP 的『超时重传机制』,会等待 3 秒、6 秒、12 秒后重新发送 SYN+ACK 包,以便客户端重新发送 ACK 包。
  • 客户端在接收到 SYN+ACK 包后,就认为 TCP 连接已经建立,状态为 ESTABLISHED。如果此时客户端向服务端发送数据,服务端将以 RST 包响应,用于强制关闭 TCP 连接。
  • 如果服务端收到客户端重发的 ACK 包,会先判断全连接队列是否已满,如果未满则从半连接队列中拿出相关信息存放入全连接队列中,之后服务端 accept() 处理此请求。如果已满,则根据 tcp_abort_on_overflow 参数的值决定是扔掉 ACK 包还是发送 RST 包给客户端。
半连接和全连接队列

tcp_abort_on_overflow 是一个布尔型参数,当服务端的监听队列满时,新的连接请求会有两种处理方式,一是丢弃,二是拒绝连接(通过向服务端发送 RST 报文实现)。通过哪种方式处理,取决于这个参数:

  • tcp_abort_on_overflow 为 0,丢弃服务端发送的 ACK 报文,不建立连接。
  • tcp_abort_on_overflow 为 1,发送 RST 报文给客户端,拒绝连接。

另外, 服务端的监听队列有两种:

TCP 半连接队列和全连接队列是服务端在处理 TCP 连接时维护的两个队列,它们的含义如下:

  • 半连接队列,也称SYN 队列,是存放已收到客户端的 SYN 报文,但还未收到客户端的 ACK 报文的连接请求的队列(即完成了前两次握手)。服务端会向客户端发送 SYN+ACK 报文,并等待客户端的回复。
  • 全连接队列,也称accept 队列,是存放已完成三次握手,但还未被应用程序 accept 的连接请求的队列。服务端会从半连接队列中移除连接请求,并创建一个新的 socket,然后将其放入全连接队列。

半连接队列和全连接队列都有最大长度限制,如果超过限制,服务端会根据 tcp_abort_on_overflow 参数的值来决定是丢弃新的连接请求还是发送 RST 报文给客户端。

它们和 socket 的关系是:

  • 服务端通过 socket 函数创建一个监听 socket,并通过 bind 函数绑定一个地址和端口,然后通过 listen 函数指定监听队列的大小。
  • 当客户端发起连接请求时,服务端会根据 TCP 三次握手的进度,将连接请求放入半连接队列或全连接队列。
  • 当应用程序调用 accept 函数时,服务端会从全连接队列中取出一个连接请求,并返回一个新的 socket 给应用程序,用于和客户端通信。

再次理解“三次握手”

在前面几个小节中,我们知道了什么是连接,也了解了 TCP 的三次握手过程和 TCP 状态的变化。在了解这些前提后,我们再来谈谈 TCP 为什么是三次握手。

TCP 连接除了要保证建立连接的效率、验证全双工之外,虽然它不保证 100%的可靠性,但是它是用于保证可靠性和流量控制维护的某些状态信息(包括 Socket、序列号和窗口大小)的前提。

那么问题就转化为:为什么只有三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接

结论:

  • 阻止重复历史连接的初始化(主要)
  • 同步双方的初始序列号
  • 避免资源浪费
阻止重复历史连接的初始化
  • 三次握手的首要原因是防止旧的重复连接初始化造成混乱

首先谈谈什么是『历史连接』。

有这样一个场景:假如客户端先发送了 SYN 报文(Seq=90),然后它突然关机了,好巧不巧,SYN(Seq=90)也被网络阻塞了,导致服务端并未收到。当客户端重启后,又向服务端发送了 SYN 报文(Seq=100)以重新发起连接。这里的 SYN(Seq=90)就被称为历史连接。

注意,这里的 SYN 不是后面要讲的『重传』SYN,因为序列号不同。

TCP 的三次握手通过序列号和确认号的机制来防止旧的重复连接初始化造成混乱。具体来说:

  • 在第一次握手中,客户端发送一个 SYN 报文,携带一个随机的初始序列 Seq=x,表示客户端想要建立连接,并告诉服务端自己的序列号。
  • 在第二次握手中,服务端回复一个 SYN+ACK 报文,携带一个随机的初始序列号 Seq=y,表示服务端同意建立连接,并告诉客户端自己的序列号。同时,服务端也确认了客户端的序列号,将确认号 ack 设置为 x+1,表示期待收到客户端下一个字节的序列号。
  • 在第三次握手中,客户端回复一个 ACK 报文,将确认号 ack 设置为 y+1,表示确认了服务端的序列号,并期待收到服务端下一个字节的序列号。至此,双方都同步了各自的初始序列号,并确认了对方的初始序列号,连接建立成功。

这样的过程可以防止旧的重复连接初始化造成混乱,因为:

  • 第一次握手:如果客户端发送的 SYN 报文是旧的重复报文,那么它携带的初始序列号 Seq=x 可能已经被服务端使用过或者超出了服务端期待的范围。这样,服务端收到这个旧的 SYN 报文后,会认为它是无效的或者已经过期的,不会回复 SYN+ACK 报文,也不会建立连接
  • 第二次握手:如果服务端回复的 SYN+ACK 报文是旧的重复报文,那么它携带的初始序列号 Seq=y 可能已经被客户端使用过或者超出了客户端期待的范围。这样,客户端收到这个 SYN+ACK 报文后,会认为它是无效的或者已经过期的,不会回复 ACK 报文,也不会建立连接
  • 第三次握手:如果客户端回复的 ACK 报文是旧的重复报文,那么它携带的确认号 ack 可能已经被服务端使用过或者超出了服务端期待的范围。这样,服务端收到这个 ACK 报文后,会认为它是无效的或者已经过期的,不会分配资源给这个连接,也不会进行数据传输
三次握手避免历史连接

代入上面假设的场景,如果在 SYN(Seq=100)正在发送的途中,原先 SYN(Seq=90)刚好被服务端接收,那么服务端会返回 ACK(Seq=91),客户端应该收到的是 ACK(Seq=101)而不是 ACK(Seq=91),此时客户端就会发起 RST 报文以终止连接。服务端收到后,释放连接。

经过一段之间后,新的 SYN(Seq=100)被服务端接收,服务端返回 ACK(Seq=101),客户端检查确认应答号是正确的,就会发送自己的 ACK 报文,连接成功,且避免了旧的重复连接初始化造成混乱。

因此,通过序列号和确认号的机制,TCP 可以在三次握手中验证双方是否是当前有效的连接请求,并且同步双方的初始序列号。这样可以防止旧的重复连接初始化造成混乱。


上面的例子是服务端先收到了『旧 SYN』报文的情况,如果服务端先收到了『新 SYN』报文再收到『旧 SYN』报文时,会发生什么?

  • 从数据结构的角度理解这个过程:如果服务端在收到 RST 报文之前,先收到了「新 SYN 报文」,那么服务端会认为客户端想要建立一个新的连接,而不是继续之前的连接。服务端会为新的 SYN 报文分配一个新的 TCB,并发送 SYN+ACK 报文给客户端。同时,服务端会保留旧的 TCB,直到收到 RST 报文或者超时。这样,服务端就可以同时处理两个不同的连接请求,而不会混淆它们。

为什么两次握手不能防止旧的重复连接初始化造成混乱呢?

如果只有两次握手,那么客户端发送的 SYN 报文可能会在网络中延迟,导致服务端收到一个过期的连接请求,从而建立一个无效的连接,浪费资源。

这是因为在『两次握手』的情况下,服务端只要收到了客户端发送的第一个报文,就认为它已经建立好了这个方向的连接,立即处于 ESTABLISHED 状态。然而客户端只有当收到服务端发送的 ACK+SYN 报文后,才会认为它处于 ESTABLISHED 状态。

问题就在于,客户端和服务端切换到 ESTABLISHED 状态的时机不论多少次握手,都会有时差,这是由机制本身决定的。如果在『服务端处于 ESTABLISHED 状态,客户端处于 SYN_SENT 状态并将要切换到 ESTABLISHED 状态之前』这个时间段内,报文的传输出现了问题,那么整个连接就会失败。

两次握手无法阻止历史连接

在这个时间段内,如果客户端发送的旧 SYN(Seq=100)较新 SYN(Seq=200)更先被服务端收到,服务端进入 ESTABLISHED 状态,像客户端发送 SYN+ACK(Seq=101)报文。客户端通过校验发现,ACK(Seq=101)不是自己期望的 ACK(Seq=201),于是向服务端发送 RST 报文以终止连接。

直到新 SYN(Seq=200)被服务端接收到以后,才能正常建立连接。

但是这个过程中(注意在两次握手的情况下),服务端已经和客户端的建立了一个旧连接,这个旧连接因为双方的确认应答序号不一致而被迫终止,造成的后果不仅是终止了这个连接,更在于白白浪费了建立连接和发送数据的资源(图中 RST 之前),我们知道建立连接是有成本的。

三次握手可以保证客户端在收到服务端的 SYN+ACK 报文后才确认连接,如果客户端没有回复 ACK 报文,那么服务端会认为连接请求无效,不会建立连接。简单地说,两次握手只能 100%地建立一个方向的通信信道(客户端<-服务端),但是三次握手就能建立双方向的通信信道。

到底该如何理解呢?

你发现了吗?不论是上面分析三次握手还是两次握手,最后一次总是单方面的报文,TCP 协议是无法 100%保证这最后一个报文能被对方收到的,那么分析问题时,就把最后一次当做不存在。那么问题就变得简单了,既然 TCP 是全双工的,那么就要建立双方向的通信信道。两次握手中只有一次握手能 100%建立通信信道,只有一个方向,不满足 TCP 的全双工通信要求,当然不行了。

双方向具体如何理解?

我们知道,只有处于 ESTABLISHED 状态的一端才能发送数据,例如第一次握手后,服务端处于 ESTABLISHED 状态,那么意味着客户端<-服务端这个方向的通信信道连接成功,而不是指发送 SYN 这个方向(图中的箭头)。

问:为啥这么确定地说 100%?

因为没有第一次握手,就没有第二次握手。

同步双方初始序列号

序列号是 TCP 协议实现可靠传输的一个重要机制,它可以帮助双方识别和处理重复、丢失、乱序、延迟的数据包。

初始序列号是建立 TCP 连接时双方协商的一个随机数,它可以防止历史连接的干扰和恶意攻击。

通过三次握手,双方可以互相确认对方的初始序列号,并在此基础上递增序列号来发送后续的数据包。这样一来一回,才能确保双方的初始序列号能被可靠的同步。

避免资源浪费

刚才在介绍两次握手时,说明了两次握手只能确保建立单方向的通信信道(客户端->服务端),这个过程对客户端是无感知的,只要它没有收到第二次握手服务端发送的 SYN+ACK 报文,就会根据超时重传机制发送若干 SYN 报文以请求连接。

两次握手会造成资源浪费

例如,如果客户端发送的 SYN 报文在网络中阻塞了,重复发送多次 SYN 报文,那么服务端在收到请求后就会建立多个冗余的无效链接,造成不必要的资源浪费。即两次握手会造成消息滞留情况下,服务端重复接受无用的连接请求 SYN 报文,而造成重复分配资源。

两次握手不能根据上下文 SYN 的序列号来丢弃历史请求报文吗

两次握手只能在客户端端阻止历史连接,而不能在服务端阻止历史连接。因为:

  • 两次握手可以根据 SYN 的序列号来丢弃历史报文,但是不能阻止历史连接。也就是说,如果客户端收到了一个过期的 SYN+ACK 报文(比如之前网络延迟导致的),它可以根据序列号判断这是一个历史连接,并发送 RST 报文来拒绝连接。
  • 但是服务端在收到客户端的 SYN 报文后,就进入了 ESTABLISHED 状态,并没有『中间状态』来阻止历史连接。也就是说,如果服务端收到了一个过期的 SYN 报文(比如之前网络延迟导致的),它无法根据序列号判断这是一个历史连接,并可能建立一个无效的连接,并向客户端发送数据。

3.3 重传机制

在上面的示例中,我们知道客户端在发送数据后的一段时间内如果得不到服务端的回应,会重新发送请求连接的报文,这个过程通过『重发机制』完成,重发机制根据不同因素的驱动,主要分为两种:

  • 超时重传机制:以固定时间为驱动。如上例。
  • 快速重传机制:以数据为驱动。

超时重传机制

对于报文的发送方,如果收不到对方的应答,有两种情况(如下图):

  1. 报文被对方丢弃了
  2. 报文被对方收到了,但是对方发出的确认应答丢包了
image-20230712175117584

这是无法被发送方确定的,即使确定了也没有意义。但是也不能让发送方傻乎乎地一直等这个应答,所以设置了一个有效时间,一旦报文发出,没有在规定时间内收到对方发送的确认应答,那么发送方会重新发送一份完全相同的报文。这就是 TCP 的超时重传机制。

重发超时的具体时间长度又是如何确定的呢?

最理想的是,找到一个最小时间,它能保证“确认应答一定能在这个时间内返回”。然而这个时间长短随着数据包途径的网络环境的不同而有所变化。

TCP 要求不论处在何种网络环境下都要提供高性能通信,并且无论网络拥堵情况发生何种变化,都必须保持这一特性。

为此,它在每次发包时都会计算往返时间(Round Trip Time 也叫 RTT,是指报文段的往返时间) 及其偏差(RTT 时间波动的值、方差。有时也叫抖动) 。将这个往返时间和偏差相加重发超时的时间,就是比这个总和要稍大一点的值,即 RTO(Retransmission Timeout 超时重传时间)。

RTT 的偏差(也叫绝对误差)是指 RTT 的真实值和平滑估计值之间的差值,它反映了 RTT 的波动程度。将 RTT 的平滑估计值和偏差相加再乘以一个系数,就可以得到 RTO 的值。一般来说,这个系数是 4,也就是说 RTO = (SRTT + RTTVAR) * 4,其中 SRTT 是 RTT 的平滑估计值,RTTVAR 是 RTT 的偏差。

那么 RTT 具体指的是什么呢?

RTT
  • RTT 指的是数据发送时刻接收到确认的时刻的差值,也就是包的往返时间。

注意,数据也不会被无限、反复地重发。达到一定重发次数之后,如果仍没有任何确认应答返回,就会判断为网络或对端主机发生了异常,强制关闭连接。并且通知应用通信异常强行终止。

为什么 RTO = (SRTT + RTTVAR) * 4?(为什么超时重传时间 RTO 的值应该略大于报文往返 RTT 的值呢?)

  • 超时时间不能太短也不能太长,这是一个由大量测试和实践得出的经验公式(具体因版本而异)。使 RTO 比 RTT 的值稍大,是为了避免因为网络延迟而导致的误判和不必要的重传,因为重传会增加网络的负担和拥塞 。

关于这个经验公式的推导,可以参看 郑烇老师讲的课 和《TCP/IP 详解 卷 1 协议》第 464 页。

举两个极端的例子(注意看箭头和括号):

超时时间较长与较短
  • 超时时间 RTO 太大:重发报文的间隔太长,导致效率低下。
  • 超时时间 RTO 太小:重发报文的间隔太小,可能报文并未丢包就向网络中重发了报文(因为网络传输有距离),会增加网络拥塞的程度。最终出现雪崩效应,让网络状况雪上加霜。

由此可见,RTO 的大小取决于网络环境,它会随时间而改变,TCP 必须跟踪这些变化并实时做出调整以维持较好的性能。

略大于 RTT 的 RTO,是上述两种情况的折中:

RTO 应略大于 RTT

注意,

尽管经过了一系列实践和测试,但事实上总会存在某些超时重传解决不了的情况,即超时重传的 RTO 宁愿长也不能短,缺点的严重性取决于具体场景。例如像多人网游这样对延迟要求十分高的场景,使用超时重传就会很低效,这就需要使用『快速重传』机制解决。

快速重传

快速重传有两种方式,一种是基于重复 ACK 的快速重传,另一种是基于 SACK 的快速重传:

  • 基于重复 ACK 的快速重传是指当发送方连续收到三个相同的 ACK 报文时,就认为该序号对应的数据包丢失了,于是在超时定时器到期之前就立即重传该数据包。
  • 基于 SACK 的快速重传是指当接收方收到失序的数据包时,会在 TCP 头部增加一个 SACK 字段,告诉发送方已经收到的数据包序号范围,这样发送方可以准确地知道哪些数据包丢失了,并且只重传丢失的数据包。

假设有这样的场景:在超时重传的计时器还未触发这个时间段内,客户端已经接收到服务端发送的若干相同 ACK 报文,那么此时也就没有必要再等下去了,毕竟服务端都已经收到了上次发送的报文,直接重发丢失的报文就好了。这就叫快速重传。

快速重传机制

在上例中,客户端发送的 2 号报文丢包,服务端发送多个 ACK(Seq=2)报文,其中,第一个报文表示服务端接收到了 1 号报文。后续 3/4/5 号报文被服务端接收到后校验错误,总共向客户端发送了 3 个 ACK(Seq=2)报文。

一旦客户端满足了这两个条件,就能触发基于重复 ACK 快速重传机制:

  1. 在这个过程中,没有触发超时重传
  2. 并且收到了 1ACK(Seq=2)+ 3ACK(Seq=2

但是,基于重复 ACK 快速重传机制在很多时候只能重传一个报文,如果要重传多个,那么既需要对对端也支持,网络状况也要允许,难免出现兼容性问题。

基于 SACK 的快速重传机制解决了应该要重传哪些报文这一问题。

首先介绍『SACK』是什么:

  • SACK 是选择性确认(Selective Acknowledgment)的缩写,是一种 TCP 的选项,用于允许 TCP 单独确认非连续的数据段,从而减少重传的数据量和提高传输效率。
  • SACK 的工作原理是,当接收方收到失序的数据段时,会在 TCP 头部增加一个 SACK 字段,告诉发送方已经收到的数据段序号范围,这样发送方可以准确地知道哪些数据段丢失了,并且只重传丢失的数据段。
  • SACK 选项并不是强制的,只有当双方都支持 SACK 时才会被使用(Linux 2.4 后默认支持)。TCP 连接建立时会在 TCP 头中协商 SACK 细节。
  • 此外,D-SACK(Duplicate SACK)是一种扩展的 SACK,用于告诉发送方有哪些数据段被重复接收了。

从缓冲区的角度理解重发机制:

选择性确认

我们知道,TCP 的发送端和接收端都有各自的收发缓冲区,而 TCP 的接收端可以提供 SACK 功能,以 TCP 头部基类的 ACK 号字段来描述其接收到的数据。这些 ACK 号是有实际意义的,在上文提到过,它可以视为一个字符数组的下标。那么某几段数据丢失,实际上就是这个字符数组中产生了『空缺』。

空缺指的是 ACK 号与接收端接收缓冲区中的其他数据之间的间隔,即图中右边白色的空缺。而 TCP 发送端的任务就是通过重传丢失的数据来填补接收端缓冲区的空缺。要求是保证不能重复地发送接收端已经收到的数据。那么此时 SACK 就能很好地发挥作用,减少不必要的重传。

关于 SACK 的具体实现,参看《TCP/IP 详解 卷 1 协议》第 478 页。

3.4 连接的断开

四次挥手

客户端主动关闭连接 —— TCP 四次挥手

TCP 的四次挥手的过程是这样的:

  • 第一次挥手:主动关闭方(客户端或服务器,上例是客户端)发送一个 FIN 标志位为 1 的数据包,表示要结束数据传输,进入 FIN_WAIT_1 状态,等待对方的确认。
  • 第二次挥手:被动关闭方(服务器或客户端)收到 FIN 包后,发送一个 ACK 标志位为 1 的数据包,表示已经收到对方的结束请求,进入 CLOSE_WAIT 状态,但还可以继续发送数据。主动关闭方接收到 ACK 数据包,进入FIN_WAIT_2状态。
  • 第三次挥手:被动关闭方在发送完所有数据后,再发送一个 FIN 标志位为 1 的数据包,表示自己也要结束数据传输,进入 LAST_ACK 状态,等待对方的最后确认。
  • 第四次挥手:主动关闭方收到 FIN 包后,发送一个 ACK 标志位为 1 的数据包,表示已经收到对方的结束请求,进入 TIME_WAIT 状态,等待 2MSL 时间后确保对方收到确认,然后关闭连接,释放资源,进入 CLOSE状态。

注意:

  • 四次挥手:左->右和左<-右两个方向上,都各自有 FIN 请求关闭连接报文(红色),和一个 ACK 确认关闭连接报文(蓝色)。

  • 主动关闭连接的一方才有 TIME_WAIT 状态。

常见问题

FIN 和 ACK 我知道,为什么要有两个 FIN_WAIT 状态呢?

两个 FIN_WAIT 状态的区别是,FIN_WAIT_1 状态表示主动关闭方(客户端或服务器)发送了 FIN 包,等待被动关闭方(服务器或客户端)的 ACK 包。而 FIN_WAIT_2 状态表示主动关闭方收到了被动关闭方的 ACK 包,等待被动关闭方的 FIN 包。

一般情况下,FIN_WAIT_1 状态持续的时间很短,因为被动关闭方会马上回复 ACK 包。但是,如果被动关闭方没有及时回复 ACK 包,或者网络链路出现故障,导致主动关闭方收不到 ACK 包,那么主动关闭方就会一直处于 FIN_WAIT_1 状态,直到超时或者重传达到一定次数后,放弃连接并进入 CLOSED 状态。

Linux 内核中有一个参数 net.ipv4.tcp_orphan_retries ,用来控制在收不到 ACK 包的情况下,主动关闭方在销毁连接前等待几轮 RTO 退避。

FIN_WAIT_2 状态持续的时间取决于被动关闭方是否还有数据要发送,以及是否及时发送 FIN 包。如果被动关闭方及时发送 FIN 包,那么主动关闭方就会回复 ACK 包,并进入 TIME_WAIT 状态。如果被动关闭方没有及时发送 FIN 包,那么主动关闭方就会一直处于 FIN_WAIT_2 状态,直到超时或者收到重复的 FIN 包后,进入 TIME_WAIT 状态。

另外,内核中的参数 net.ipv4.tcp_fin_timeout ,用来控制在收不到 FIN 包的情况下,主动关闭方在超时前等待多长时间。

什么是 TIME_WAIT 状态?

处于 TIME_WAIT 状态的一端,说明:

  • 它正在等待一段时间,以确保对方收到了最后一个 ACK 包,或者处理可能出现的重复的 FIN 包。

  • 也处于一个半关闭的状态,即它已经发送了 FIN 包,表示不再发送数据,但是还可以接收对方的数据,直到对方也发送了 FIN 包。

**TIME_WAIT **状态也称为 2MSL 等待状态,在这个状态下,TCP 将会等待两倍于 MSL(最大段生存期)的时间,有时也被称为加倍等待。每个实现都必须为 MSL 设定一个数值,它代表任何报文段在被丢弃前在网络中被允许存在的最长时间。

什么是半关闭?

  • 半关闭状态是一种单向关闭的状态,它只关闭了某个方向的连接,即数据传输。另一个方向的连接,即数据接收,还是保持打开的。
  • 半关闭状态的作用是让一方可以继续发送数据,直到把所有数据都发送完毕,再发送 FIN 包。这样可以避免数据的丢失或者重复发送。

因此,TIME_WAIT 状态存在的目的有两个:

  • 可靠地实现 TCP 全双工连接的终止,防止最后一个 ACK 丢失而导致对方无法正常关闭。
  • 允许老的重复报文段在网络中消逝,防止新的连接收到旧的报文段而导致数据错乱。

但是,TIME_WAIT 状态的缺点是:

  • 它会占用端口资源,如果有大量的 TIME_WAIT 状态存在,可能会导致端口资源耗尽,无法建立新的连接。

  • 它会延长连接的释放时间,如果有新的连接请求到来,需要等待 TIME_WAIT 状态结束后才能使用相同的端口。

  • TIME_WAIT 状态的持续时间是 2 倍的 MSL(报文最大生存时间),通常为 2 分钟或 4 分钟。在这段时间内,该连接占用的端口不能被再次使用。

为什么 TIME_WAIT 状态的持续时间是 2 倍的 MSL?

  • 为了可靠地实现 TCP 全双工连接的终止,防止最后一个 ACK 丢失,导致对方重发 FIN,需要在收到 FIN 后等待一个 MSL 的时间,以便重发 ACK。(假如最后一个 ACK 丢失,服务器会重发一个 FIN,虽然此时客户端的进程终止了,但 TCP 连接依然存在,依然可以重发最后一个 ACK)
  • 为了允许老的重复分节在网络中消逝,防止新的连接被旧的分节干扰,需要在发送 ACK 后等待一个 MSL 的时间,以便新的连接不会使用相同的套接字对。

在 CentOS 7 中,MSL 为 60s:

image-20230717230118794

服务器出现大量 CLOSE_WAIT 状态连接的原因有哪些?

CLOSE_WAIT 状态表示一个 TCP 连接已经结束,但是仍有一方在等待关闭连接的状态。这一方是被动关闭的一方,也就是说它已经接收到了对方发送的 FIN 报文,但是还没有发送自己的 FIN 报文。

当出现大量处于 CLOSE_WAIT 状态的连接时,很大可能是由于没有关闭连接,即『代码层面上』没有调用 close() 关闭 sockfd 文件描述符。也可能是由于响应太慢或者超时设置过小,导致对方不耐烦直接 timeout,而本地还在忙于耗时逻辑。还有一种可能是 BACKLOG 太大,导致来不及消费的请求还在队列里就被对方关闭了。

服务器出现大量 TIME_WAIT 状态连接的原因有哪些?

首先要知道,TIME_WAIT 状态是主动关闭连接的一方才会出现的状态。服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接

问题就转化为,什么原因会导致服务端主动断开连接:

  1. HTTP 没有使用长连接。即服务器使用了短连接,这意味着每次请求都需要建立一个新的 TCP 连接,而且在响应完毕后,服务端会主动关闭连接,导致产生大量的 TIME_WAIT 状态的连接,占用系统资源(端口号+CPU+内存),影响新连接的建立。
  2. HTTP 长连接超时。如果客户端在一段时间内没有发送新的请求,服务端会认为客户端已经不需要继续使用该连接,就会主动关闭连接,以释放资源。这个超时时间可以由服务端配置。
  3. 服务器收到了客户端异常或重复的 FIN 包,导致进入 TIME_WAIT 状态等待对方的 ACK 包,但是没有收到,只能等待超时后关闭。
  4. HTTP 长连接的请求数量达到上限。如果一个连接上发起的请求数量超过了服务端设定的最大值,服务端会主动关闭连接,以防止客户端占用过多的资源。
  5. 服务端设置了过长的 MSL(报文最大生存时间),导致 TIME_WAIT 状态持续时间过长,无法及时回收资源。

什么是长连接/短连接?

长连接和短连接是指在 TCP 协议中,连接的建立和关闭的方式。简单来说:

  • 长连接:客户端和服务器建立一次连接后,可以连续发送多个数据包,不会主动关闭连接,除非出现异常或者双方协商关闭。长连接适合于操作频繁,点对点的通信,可以减少建立和关闭连接的开销,提高网络效率。
  • 短连接:客户端和服务器每次通信都要建立一个新的连接,发送一个数据包后就关闭连接。短连接适合于并发量大,请求频率低的通信,可以节省服务器的资源,防止过多的无效连接。

如何解决服务器出现大量 TIME_WAIT 状态的连接这一问题?

  1. 保证客户端和服务端双方的 HTTP header 中有Connection: Keep-Alive选项,以使用长连接,使得连接状态能被保持一段时间,减少 TIME_WAIT 状态的连接数量,提高效率。
  2. HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,从而避免连接建立和释放的开销。但是如果“杀鸡用牛刀”,只有一个 HTTP 请求也用长连接,那么长连接也会占用资源。所以服务端一般会设置一个 keepalive_timeout 参数,一旦计时器超出了这个范围且无新请求,就会让连接处于 TIME_WAIT 状态。
  3. 设置 tcp_fin_timeout:TCP 的 FIN 等待超时时间,即服务器在收到客户端的 FIN 包后,进入 TIME_WAIT 状态的最长时间。如果在这个时间内没有收到客户端的 ACK 包,服务器会关闭连接。这个参数可以减少 TIME_WAIT 状态的连接数量,节省系统资源。
  4. keepalive_requests 参数被用定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接,变成 TIME_WAIT 状态的连接。其默认值是 100 ,意味着每个 HTTP 长连接最多只能承载 100 次请求。如果 QPS (每秒请求数)很高时(超过 10000 个),默认值会让服务端频繁地关闭连接,出现大量 TIME_WAIT 状态的连接。解决办法是增大 keepalive_requests 参数的值。

[注] 如果之前有 Socket 编程经验的同学,在测试时总会遇到这种情况:以某个端口运行进程,如果测试时用 Ctrl + C 终止了服务端进程,这相当于服务端主动关闭连接,在 TIME_WAIT 期间再用同一个端口测试,就出现绑定失败的错误。(值得注意的是,在刚开始 Socket 编程时,一般实现的是短连接)

如何解决这个问题?(TIME_WAIT 状态的连接导致这段时间内绑定端口失败)

端口复用】在 TCP 连接没有完全断开之前不允许重新监听,这个做法是为了保证 TCP 连接的可靠性和安全性,防止新的连接被旧的分节干扰。但是在一些情况下,这么做是不合适的,比如:

  • 服务器需要处理非常大量的客户端的连接,每个连接的生存时间可能很短,但是每秒都有很大数量的客户端来请求。这个时候如果由服务器端主动关闭连接(例如关闭某些只连接不传输数据的客户端的连接),就会产生大量 TIME_WAIT 连接,导致服务器的端口不够用,无法处理新的连接。
  • 服务器应用程序意外终止或重启,导致服务器端主动关闭连接,进入 TIME_WAIT 状态。这个时候如果服务器应用程序想要重新监听同样的端口,就会失败。

还记得 2.2 中提到的『四元组』唯一确定一个 TCP 连接吗?实际上源 IP 和源端口对于某个服务端而言是固定的,那么如果新连接的目的 IP 和目的端口号和 TIME_WAIT 状态的连接占用的四元组重复了,就会出现问题。

在这些情况下,可以通过一些方法来解决 TIME_WAIT 状态的问题,比如:

  • 【主要】使用setsockopt()设置 socket 文件描述符的选项 SO_REUSEADDR 为 1 来允许 TIME_WAIT 状态的 socket 被重用,即允许创建端口号相同,但是 IP 地址不同的多个 socket 文件描述符。
  • 缩短 TIME_WAIT 的持续时间等。

测试

下面用一个例子来测试当客户端主动关闭连接时,会出现什么情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// Sock.hpp
#pragma once

#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>

class Sock
{
private:
    const static int gbacklog = 20;

public:
    Sock() {}
    int Socket()
    {
        int listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock < 0)
        {
            exit(2);
        }
        return listensock;
    }
    void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0")
    {
        struct sockaddr_in local;
        memset(&local, 0, sizeof local);
        local.sin_family = AF_INET;
        local.sin_port = htons(port);
        inet_pton(AF_INET, ip.c_str(), &local.sin_addr);
        if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0)
        {
            exit(3);
        }
    }
    void Listen(int sock)
    {
        if (listen(sock, gbacklog) < 0)
        {
            exit(4);
        }

    }
    int Accept(int listensock, std::string *ip, uint16_t *port)
    {
        struct sockaddr_in src;
        socklen_t len = sizeof(src);
        int servicesock = accept(listensock, (struct sockaddr *)&src, &len);
        if (servicesock < 0)
        {
            return -1;
        }
        if(port) *port = ntohs(src.sin_port);
        if(ip) *ip = inet_ntoa(src.sin_addr);
        return servicesock;
    }
    bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port)
    {
        struct sockaddr_in server;
        memset(&server, 0, sizeof(server));
        server.sin_family = AF_INET;
        server.sin_port = htons(server_port);
        server.sin_addr.s_addr = inet_addr(server_ip.c_str());

        if(connect(sock, (struct sockaddr*)&server, sizeof(server))==0) return true;
        else return false;
    }
    ~Sock() {}
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// main.cc
#include "Sock.hpp"

int main()
{
    Sock sock;
    int listensock = sock.Socket();
    sock.Bind(listensock, 8080);

    sock.Listen(listensock);

    while(true)
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = sock.Accept(listensock, &clientip, &clientport);
        if(sockfd > 0)
        {
            std::cout << "[" << clientip << ":" << clientport << "]# " << sockfd << std::endl;
        }
    }
}

运行:

image-20230725222639503

通过指令 netstat 查看,这个进程确实已经被运行起来了,并且正处于监听状态。现在用另一个会话用 telnet 工具在本地进行测试: image 注意到,此时这个连接处于 ESTABLISHED 状态,表示连接创建成功。

telnet 相当于客户端,那么下面这个客户端主动关闭连接会发生什么呢?

image-20230725225214226

注意,由于我只有一台主机可以用来测试,实际上如果用其他主机作为客户端连接到这个 8080 的监听端口的话,再用这个命令查看相关信息,IP 地址可能和服务器运营商提供的公网 IP 不同,这是因为后者提供的是虚拟 IP。

注意到在服务器上,这个连接的状态变化为了 CLOSE_WAIT。这是因为我们的代码中没有在关闭连接时关闭文件描述符,造成了在这段时间内占用了这个文件描述符。如果你在短时间内重复连接的话,会发现文件描述符会一直递增,同时也会出现 CLOSE_WAIT 状态的连接:

image-20230725230325537

我们知道文件描述符是有上限的,而且连接本身也会占用资源,如果客户端主动关闭连接后,服务端却没有关闭文件描述符,最终会导致进程崩溃。

在服务端中增加关闭连接操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include "Sock.hpp"

int main()
{
	// ...
    while(true)
    {
        // ... 
        sleep (10);
        close(sockfd);
        std::cout << sockfd << " had closed" << std::endl;

    }
}

在 sleep 的 10s 内,服务端连接处于正常连接状态:

image-20230725232856474

当服务端主动调用 close,关闭连接时,虽然四次挥手已经完成,但是作为主动断开连接的一方,要维持一段时间的 TIME_WAIT 状态。在这个状态下,连接已经关闭,但其地址信息 IP 和 PORT 依旧是被占用的。

image-20230725233141284

值得注意的是,作为服务器,一旦启动后无特殊需求(如维护)是不会主动关闭连接的,上面代码模拟的通常是服务端进程因为异常而终止的情况。

文件描述符的生命周期随进程,不论服务端进程是正常退出还是异常退出,只要服务端进程退出,此时就应该立即重启服务器。但问题在于,由于是服务端主动关闭请求,此时服务器必然存在大量处于 TIME_WAIT 状态的连接,而它们在一段时间内占用了 IP 和端口。如果是双 11 这样的场景,发生这种是被称之为事故,是要被定级的。

操作系统提供了 Listen 套接字的属性,以供地址复用。这样服务器一旦挂掉重启后,虽然存在大量处于 TIME_WAIT 状态的连接,但是这个选项可以绕过 TIME_WAIT 限制,直接复用原先使用的地址。

只需要在 Socket 初始化时设置选项:

1
2
3
4
5
6
7
8
// Sock.hpp::Sock
int Socket()
{
    // ...
    int opt = 1;
    setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
    // ...
}

并且将刚才在 main.cc 中增加的代码删除,方面手动终止和重启服务端进程。

建立一个连接并主动关闭服务端:

image-20230725234739980

重启服务端进程,并尝试重新建立连接:

image-20230725234953769

即使此时这个 PORT 对应的连接处于 TIME_WAIT 状态,由于设置了地址复用选项,可以无视它的存在,跳过这段占用时间。

3.5 流量控制

TCP 除了要保证连接的可靠性,还要保证数据传输的效率。这是因为,TCP 在每次发送数据时,网络和机器本身的承载能力是动态的。提升效率的主要手段在于减少“发送-回应”的次数,即增大回应的粒度,以多条数据为一组作为一次回应的内容,这一组数据在缓冲区中就叫做『滑动窗口』。

在上文提到过『缓冲区』,现在我们就对它有了简单的认识:收发缓冲区的『剩余空间』决定了收发的能力。

为什么要流量控制?

由于缓冲区的大小是固定的,剩余空间也是动态变化的,所以进程的收发能力也是动态变化的。上一时刻可能最大能接收 1000 字节的数据,下一时刻可能只能接收 10 字节的数据,如果依然按照这样的速率发送,接收端的接收缓冲区就会经常处于满的状态,这就可能会造成丢包问题(我们知道这可能会触发丢包重传等一系列连锁机制)。因此发送方不能盲目地发送数据,要考虑对端的接收能力。

滑动窗口

TCP 的流量控制主要通过滑动窗口机制来实现的。

滑动窗口是指在 TCP 连接的数据传输过程中,两端系统使用的流量控制机制。滑动窗口由发送方和接收方各自维护一个窗口大小,表示当前可以发送或接收的数据量。发送方的窗口大小取决于接收方的『窗口大小』字段,即接收方告诉发送方自己还有多少空闲缓存可以接收数据。

接收方的窗口大小取决于自己的缓存大小和已经接收但未确认的数据量。当接收方收到数据后,会返回一个确认报文,并在报文中携带自己的通告窗口大小,告诉发送方可以继续发送多少数据。当发送方收到确认报文后,会根据通告窗口大小调整自己的窗口大小,并向前滑动窗口,即更新已经发送和确认的序号范围。

这样,通过滑动窗口协议,可以实现发送方根据接收方的处理能力来调节发送速度,从而实现流量控制。

其中滑动窗口大小的动态更新过程:

  • 窗口大小越大,数据包的往返时间越短,网络的吞吐量越高。
  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值,以让发送端更新。
  • 发送端会根据接收到的新窗口大小控制发送速率。
  • 如果接收端的缓冲区满后,窗口大小会被更新为 0;表示发送方不应该再短时间内发送数据了,为了保证通信的持续性,接收端会定期发送窗口探测的数据段给发送端,以告知发送端自己窗口的最新大小。如果仍然为零,发送端就会继续等待下一个持续计时器超时,再次发送窗口探测报文,直到接收端的窗口变为非零。
    • 在窗口大小为 0 这种情况下,发送端除了通过等待接收端定时发送报文以更新窗口大小之外,还能主动发送不含数据的报文以询问接收端的窗口大小。
image-20230723085634284
  • 图片引用自:《TCP/IP 详解 卷 1:协议》第 498 页。

  • 图中的 C 代指 Client,S 代指 Server。由于 TCP 是全双工的,所以 Client 和 Server 都需要收发数据,因此都需要以这种方式得知对方的接收能力。

  • 网络的吞吐率为:$X=\frac {N} {\mathbb {E} [T]}$,其中 X 是吞吐率,N 是网络中的数据包数量,$\mathbb {E} [T] $是数据包的平均往返时间。

当发送方第一次发送数据给接收方时,怎么知道对方接受数据的能力?

实际上,当发送方第一次发送数据给接收方时,它是通过 TCP 的三次握手过程来知道对方接收数据的能力的。具体来说,发送方在第一次握手时,会发送一个 SYN 报文,其中包含了自己的初始序列号(ISN)和最大段大小(MSS)。接收方在第二次握手时,会回复一个 SYN+ACK 报文,其中包含了自己的 ISN 和 MSS,以及一个『窗口』大小,表示自己当前可以接收的数据量。发送方在第三次握手时,会回复一个 ACK 报文,确认接收到了对方的 SYN+ACK 报文。这样,三次握手完成后,双方就知道了彼此的序列号、段大小和窗口大小,从而可以根据这些信息来调整自己的发送速度和接收能力。

『窗口大小』字段在报头中占 16 位,也就是$2^{16} - 1=65535$,这意味着窗口大小最大是 65535(字节)吗?

不一定。TCP 窗口大小字段本身是 16 位的,所以最大值是 65535 字节。但是,TCP 还支持一种叫做窗口缩放的选项,它可以在 TCP 三次握手期间协商一个缩放因子,用于将窗口大小乘以一个 2 的幂,从而扩大窗口的范围。窗口缩放选项的值可以从 0 到 14,所以最大的缩放因子是$2^{14}=16384$,这样最大的窗口大小就可以达到$65535\times 16384=1$ GB。

当然,这个值也受限于操作系统缓冲区的大小和网络状况的影响。

*滑动窗口的原理

上面介绍了滑动窗口的概念,下面要介绍缓冲区是如何实现滑动窗口的。

由于 TCP 为了保证可靠性而付出了一定的代价,所以需要通过多种方式保证其效率,例如减少『发送-接收』的次数,即将若干个数据打包为一组再发送,这一组的大小由一个『窗口结构』维护,因为这个数据包的大小因网络和应用程序实际情况而异,因此它是动态变化的,叫做『滑动窗口』。

为什么减少『发送-接收』的次数就能提高效率呢?

  • 数据在网络中往返的时间越长,通信效率越低。窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

从数据结构和缓冲区的角度理解:滑动窗口是一个变化的数值,表示当前可以发送或接收的数据量。滑动窗口的大小取决于操作系统缓冲区的大小和网络状况。滑动窗口可以用两个指针来表示,一个指向缓冲区中『已发送或已接收的数据』的第一个字节,另一个指向缓冲区中『未发送或未接收的数据』的第一个字节。『这两个指针之间的距离就是滑动窗口的大小』。当数据发送或接收时,这两个指针会相应地移动,从而实现窗口的滑动。

以 TCP 的『发送窗口』为例:

image-20230723173851992

其中,在这个状态下:

  • 绿色:已发送并收到 ACK 确认的数据。
  • 蓝色:已发送但未收到 ACK 确认的数据。
  • 黄色:未发送但总大小在接收方接收范围内。
  • 红色:未发送但总大小不在接收方处理范围内。

窗口是红色方框中的部分,它由两部分组成,那么滑动窗口表示的是当前状态下可以发送或接收的数据的范围。如果发送方已经发送了一些数据,但还没有收到接收方的确认,那么这些数据仍然属于滑动窗口的一部分(蓝色),直到收到确认或超时重传。同样,如果接收方已经接收了一些数据,但还没有交给应用层处理,那么这些数据也仍然属于滑动窗口的一部分,直到被应用层读取或丢弃。

滑动窗口主要需要实现两方面:

  • 希望一次性能发送尽可能多的数据给对方(蓝色区域)。
  • 保证对方能够来得及接收(由接收方发送的报文中的窗口大小字段决定)。

值得注意的是:

  • 滑动窗口的范围是数据的字节序号,不是下标。字节序号是 TCP 协议为每个字节分配的一个唯一的编号,用于标识数据的顺序和位置。字节序号是 32 位的整数,从$0$~$2^{32}-1$循环变化。

窗口由两部分组成,一部分是已经发送但未收到 ACK 确认的,一部分是未发送的。对于前者,我们理想地认为接收方 100%收到,那么接收方的接收缓冲区在短时间内就被占用了蓝色这么大的空间,剩下的空间才是真正可用的缓冲区大小,我们把黄色部分称为『可用窗口大小』。

窗口的『滑动』和『可用窗口大小』的维护,通过三个指针实现:

  • SND.UNA:指向的是已发送但未收到确认的第一个字节的序列号(蓝色的起始位置)。
  • SND.NXT:指向未发送但可发送范围的第一个字节的序列号(黄色的起始位置)。它的意义是指示发送方下一次要发送的数据的位置,作用是维护蓝色区域。
  • SND.WND:表示发送/提供窗口的大小(红色方框)。

由图,可以得到右边界指针: $$ SND.UNA+SND.WND $$ SND.NXT 和 SND.UNA+SND.WND(右边界)之间的差值表示『可用窗口大小』,即发送方还可以发送多少数据而不需要等待接收方的确认:

  • 如果 SND.NXT 等于 SND.UNA+SND.WND,那么表示可用窗口为 0,发送方必须停止发送数据,直到收到接收方的窗口更新。
  • 如果 SND.NXT 小于 SND.UNA+SND.WND,那么表示可用窗口为正,发送方可以继续发送数据,直到达到窗口的右边界。

即: $$ 可用窗口大小 = [红色方框]SND.WND -[蓝色区域](SND.NXT - SND.UNA) $$ 随着时间的推移,当接收到返回的数据 ACK,滑动窗口也随之右移。窗口两端的相对运动使得窗口增大或减小:

  • 关闭:即窗口左边界右移。当发送数据得到 ACK 确认时,说明这个数据在『这一刻』已经被接收端的确认,窗口会减小。
  • 打开:即窗口右边界左移,使得可发送数据量增大。当已确认数据得到处理,接收端可用缓存变大,窗口也随之变大。
  • 收缩:即窗口右边界左移,这意味着可以发送或接收的数据量减少了。当接收方的缓冲区被填满了,或者网络状况变差了,或者发送方收到了重复的确认,或者其他原因,导致窗口变小。窗口右边界左移会降低数据传输的效率,可能导致拥塞或超时。

发送方为了维护滑动窗口,需要开辟发送缓冲区,以存储待发送和已发送但未确认的数据,并根据接收方和网络状况动态调整缓冲区和窗口的大小。

当应用程序向 TCP 协议栈发起发送请求时,数据先被放入发送缓冲区,然后由 TCP 协议栈将缓冲区中的数据发送出去。它的具体作用是记录当前还有哪些数据没有收到 ACK 应答。只有收到了 ACK 应答的数据,才能从缓冲区中取出(删除,表示已经被使用)。

发送缓冲区的大小决定了发送方的发送窗口的大小,而发送窗口的大小又决定了一次能够发送的数据的大小,也就是飞行报文的大小。飞行报文是指已经发送出去但还没有收到确认应答的报文(也就是蓝色区域)。如果飞行报文的大小与带宽时延积相等,那么就可以最大化地利用网络带宽。也就是说,当窗口越大时,网络的吞吐率越高。

滑动窗口的大小是这样变化的:

  • 对于蓝色区域的几个数据包,可以无需等待任何 ACK,直接就能发送。
  • 当收到第一个 ACK 报文时,滑动窗口向后移动,继续发送第下一个数据包,以此类推。绿色区域逐渐变大,红色区域逐渐减小。
  • 操作系统会根据缓冲区中的数据是否有对应的 ACK 应答决定它是否被移出缓冲区。
image-20230724005741261

当然,这只是窗口变化的其中一种情况,因为滑动窗口的动态变化的。但引起窗口移动的条件是『已经发送但未接收到 ACK 应答』的数据收到了 ACK 应答。

实际上,当发送方发送了一个数据段后,就会启动一个定时器,如果在定时器超时之前收到了接收方的 ACK 应答,就表示该数据段已经成功传输,那么发送方就会把窗口向右移动一个数据段的大小,从而可以继续发送下一个数据段。如果在定时器超时之前没有收到 ACK 应答,就表示该数据段可能丢失或者延迟了,那么发送方就会重传该数据段,并把窗口缩小一半,从而减少网络拥塞。

可用窗口大小是指接收方通知发送方的当前可接收的数据量,它反映了接收方的缓冲区空间和网络拥塞程度。可用窗口大小的意义在于,它可以使 TCP 协议适应不同的网络环境和传输需求,提高网络的吞吐率和效率。可用窗口大小可以通过 TCP 头部中的窗口字段来表示,但是由于该字段只有 16 位,最大只能表示 65535 字节,所以当网络带宽较大时,可能会限制 TCP 的性能。为了解决这个问题,TCP 引入了窗口缩放选项 (RFC 1323) ,它可以通过一个缩放因子来扩展窗口字段的表示范围,最大可以达到 1 GB。

接收窗口和发送窗口的大小是相等的吗?

窗口的移动是通过指针+偏移量实现的,可以认为是缓冲区的下标的运算。这么说基本上是正确的。当接收方收到数据发送 ACK 确认应答报文,报文的大小就是这个偏移量。这么说也基本上是正确的,但是要注意报文的大小不一定等于窗口的偏移量,因为报文中还包含了其他信息,比如序列号、确认号、校验和等。

除此之外,通信双方在交换报文时也是存在时间差的,比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows Size 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。

滑动窗口只能向右移动吗?(理解两个指针的含义)

不是的,发送方的滑动窗口可以向两个方向移动,分别是向右向左。向右移动表示发送方可以发送更多的数据,向左移动表示发送方已经收到了一些数据的确认。

也可以不移动,例如发送方发送数据,接收方回复 ACK,如果接收方的上层应用程序一直不取出数据,那么它的接收缓冲区就会一直减小。此时即使当发送方一直发送数据,窗口也不会向右移动。

窗口大小也可能为零,就像上图中的情况,维护窗口的两个指针重合了,说明对方的接收缓冲区已满,偏移量为 0。

当发送方收到接收方发来的确认应答时,SND.UNA 会向右移动,相应地,发送窗口也会向右移动,这称为窗口合拢。当接收方通告了一个更大的窗口大小时,SND.WND 会增加,相应地,发送窗口也会向右移动,这称为窗口张开

窗口的移动和 ACK 有什么关系?

接收方只能在接收窗口内接收数据,并且要及时将数据传递给应用层,以免缓存溢出。当接收方收到发送方发来的数据报文时,就会根据序号和校验和来判断是否正确,并根据累计确认或选择确认的原则,回复 ACK 确认报文,通知对方已经成功接收。如果接收方发现有序号不连续或重复的数据段,就会暂时缓存它们,并重复回复最后一个正确连续序号的 ACK 报文,以便让发送方重传丢失或错误的数据段。当接收方将所有缓存中的数据段按序交付给应用层后,就会移动接收窗口的左边界,并向右滑动窗口,准备接收后续数据。

例如在上面这个例子中,发送方起初一次性发送了序号 4/5/6 这三个数据包,但是只收到了来自接收方 4 和 5 的 ACK 确认应答,序号 6 暂时没有收到。那么这个状态下窗口的右边界只能从 6 开始,只有收到了对应 ACK 确认应答的数据包才能被滑出窗口外。

[ACK 的含义] 另外,还记得 ACK 表示的是什么吗?–对于接收到 ACK 的一方,它代表这对方已经接收到 ACK 序号之前的数据,那么 ACK 就是我下次要发送的下一个数据的序号。只要收到了 ACK,就代表这个序号的数据包被接收到了,没有的话就等下次重发。

[强调连续性] 在这个意义下,假如在上例中,对于这一组连续的报文,接收方没有收到中间序号为 5 的数据包,在发送方重传以后,如果收到了接收方 5 之后的 ACK 应答,也认为 5 号报文被对方接收;但是如果没有收到连续报文中间的数据的 ACK 应答,例如收到了 4 号和 6 号,但是没有收到 5 号的 ACK,那么发送方会认为对方只收到了 4 号 ACK。这么做的原因是方便稍后重传数据包,使得窗口的左边界能够单调地向一个方向移动:接收到 ACK 就移动(把接收到 ACK 的序号滑出窗口);没有接收到就不动。

言外之意,数据发送是否被对方确认,最终还是要看发送方,也就是要确认两次,如果不是『连续序号』ACK 的话,发生缺失处后面的报文不论被接收方确认了多少,在发送方这边看都是不算数的。

这样窗口的更新方式就比较统一了,只要收到 ACK 应答,序号是几,就更新到几,不用担心报文丢失或确认应答丢失,根据序号的定义,丢失的报文最终是不会被发送方确认的,窗口也就不会越过这个序号。

由于窗口由一个环形数组维护,因此它不会出现越界问题,需要处理的是跨越起点的两部分。(参考环形队列的解决办法)从上面的例子不难体会到,滑动窗口解决的是效率问题,而重传机制保证了一定程度的可靠性。

3.6 拥塞控制

网络是一种共享资源,在网络中每时每刻都有无数台机器在使用 TCP 协议进行通信,对于通信的参与方,它们对网络是无感知的。因为通信双方只会交换对方的接收能力,只关心对方的状态。极端地说,如果网络上大部分发送方都在重传数据,那么网络将会越来越拥堵,就像滚雪球一样,更何况网络本身就可能处于阻塞状态。

『拥塞控制』就是控制发送方发送的数据的数量,以避免它们造成或加剧网络拥堵。拥塞控制通过『拥塞窗口』来维护。

拥塞控制与流量控制的区别:

image-20231114160740018

拥塞窗口

拥塞窗口和发送窗口的关系:

  • 拥塞窗口是发送方维护的一个状态变量,它表示当前网络的拥塞程度,也就是发送方可以在没有确认的情况下发送的数据量。

  • 发送窗口是发送方根据拥塞窗口和接收方通告的接收窗口计算出来的一个变量,它表示发送方在当前时刻可以发送的数据范围。

  • 发送窗口的大小等于拥塞窗口和接收窗口(对方接受能力)中的较小值,即 $swnd = min(cwnd, rwnd)$。

  • 发送窗口的大小决定了发送方的传输速率和网络的吞吐量,因此发送方要根据网络反馈来调整拥塞窗口的大小,以达到最优的传输效率。也就是说,拥塞窗口随网络状况动态变化

值得注意的是,即使是单台主机一次性向网络中发送大量数据,也可能会引发网络拥塞的上限值,所以发送窗口要尽可能小。

拥塞窗口 cwnd 变化的规则:

  • 只要网络中没有出现拥塞,cwnd 就会增大;
  • 但网络中出现了拥塞,cwnd 就减少;

拥塞窗口如何得知网络的阻塞情况?

发送方没有在规定时间内接收到 ACK 应答报文,也就是**发生了超时重传,就会认为网络出现了拥塞。**主要有以下几种方法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
    • 超时重传
    • 快速重传
    • 快速恢复

慢启动

慢启动即在两端建立 TCP 连接或由超时重传导致的丢包后,将拥塞窗口设为一个较小的值(一般是 1),每收到一个 ACK 就增加一个 MSS,使得拥塞窗口呈指数增长。

这么做的原因是(引用自 [REC5681]):

在传输初始阶段,由于未知网络传输能力,需要缓慢探测可用传输资源,防止短时间内大量数据注入导致拥塞。慢启动算法正是针对这一问题而设计。在数据传输之初或者重传计时器检测到丢包后,需要执行慢启动。

慢启动的规则是:当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。

假设没有出现丢包情况且每个数据包都有相应的 ACK,第一个数据段的 ACK 到达,说明可发送一个新的数据段。每接收到一个『好的 ACK 响应』,慢启动算法会以 $min(N,SMSS)$ 来增加 cwnd 值。这里的 N 是指在未经确认的传输数据中能通过这一“好的 ACK”确认的字节数。所谓的“好的 ACK”是指新接收的 ACK 号大于之前收到的 ACK。

以下内容引用自《TCP/IP 详解 卷 1 协议》第 521 页。

因此,在接收到一个数据段的 ACK 后,通常 cwnd 值会增加到 2,接着会发送两个数据段。如果成功收到相应的新的 ACK,cwnd 会由 2 变 4,由 4 变 8,以此类推。一般情况下假设没有丢包且每个数据包都有相应 ACK,在轮后 W 的值为$ W=2^k$即$k=log_2W$,需要 k 个 RTT 时间操作窗口才能达到 W 大小。这种增长看似很快(以指数函数增长),但若与一开始就允许以最大可用速率(即接收方通知窗口大小)发送相比,仍显缓慢。( W 不会超过 awnd)

如果假设某个 TCP 连接中接收方的通知窗口非常大(比如说,无穷大),这时 cwnd 就是影响发送速率的主要因素(设发送方有较大发送需求)。如前所述,cwnd 会随着 RTT 呈指数增长。因此,最终 cwnd(W 也如此)会增至很大,大量数据包的发送将导致网络瘫痪 (TCP 吞吐量与 W/RTT 成正比)。当发生上述情况时,cwnd 将大幅度减小(减至原值一半)。这是 TCP 由慢启动阶段至拥塞避免阶段的转折点,与 cwnd 和『慢启动阈值』(slow start threshold,$ssthresh$) 相关。

下图(左)描述了慢启动操作。数值部分以 RTT 为单位。假设该连接首先发送一个包(图上部),返回一个 ACK,接着在第二个 RTT 时间里发送两个包,会接收到两个 ACK。TCP 发送方每接收一个 ACK 就会执行一次 cwnd 的增长操作,以此类推。

image-20230724171901886

右图描述了 cwnd 随时间增长的指数函数。图中另一条曲线显示了每两个数据包收到一个 ACK 时 cwnd 的增长情况。通常在 ACK 延时情况下会采用这种方式,这时的 cwnd 仍以指数增长,只是增幅不是很大。正因 ACK 可能会延时到达,所以一些 TCP 操作只在慢启动阶段完成后才返回 ACK。Linux 系统中,这被称为快速确认(快速 ACK 模式)。

慢启动算法中的发包个数按指数增长,那么它应该什么时候停下?

通过参数『慢启动阈值』(ssthresh)控制:

  • 当 cwnd < ssthresh 时,使用慢启动算法。
  • 当 cwnd >= ssthresh 时,使用『拥塞避免』算法。

拥塞避免

如上所述,在连接建立之初以及由超时判定丢包发生的情况下,需要执行慢启动操作。在慢启动阶段,cwnd 会快速增长,帮助确立一个慢启动值。一旦达到阈值,就意味着可能有更多可用的传输资源。如果立即全部占用这些资源,将会使共享路由器队列的其他连接出现严重的丢包和重传情况,从而导致整个网络性能不稳定。

为了得到更多的传输资源而不致影响其他连接传输,TCP 实现了拥塞避免算法。一旦确立慢启动闻值,TCP 会进入『拥塞避免』阶段,cwnd 每次的增长值近似于成功传输的数据段大小这种随时间线性增长方式与慢启动的指数增长相比缓慢许多。更准确地说,每当收到一个 ACK 时,cwnd 增加 1/cwnd。

例如,假定 ssthresh 为 8:当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长

image-20230724173955104 image-20230724173546712

实际上,拥塞避免算法就是将原本慢启动算法的『指数增长』变成了近似『线性增长』,仍然处于增长阶段,但是增长速度缓慢了一些。

如果一直这样随它增长下去,网络中会出现大量数据,造成一定拥堵,然后出现丢包,这时就需要对丢失的数据包进行重传。

此时,触发了重传机制后,需要使用『拥塞发生』算法解决。

拥塞发生

当有大量的数据包经过重传发送到网络中时,网络处于阻塞状态,需要使用『拥塞发生』算法解决。根据造成拥塞的重传机制,主要包括两种:

  • 解决由于超时重传导致的拥塞算法
  • 解决由于快速重传导致的拥塞算法
发生超时重传的拥塞发生算法

当发生了超时重传,会触发对应的拥塞发生算法:

  • ssthresh 设为 cwnd/2。即当前拥塞窗口大小的一半。
  • cwnd 重置为初始值,一般为 10(Linux)。即 10 个 MSS。

在 Linux 下通过ss(Socket Statistics)命令查看:

image-20230724223036592

在 80s 末期的 4.2UNIX 版本的 TCP 版本中,这个初始值是 1MSS(许多教科书中也是以此为例的),直至 cwnd 增长为 ssthresh。

但是这种做法的缺点是对于有较高带宽和较长延迟的(大 BDP 链路)网络链路,这么做会使得带宽利用率低下。因为 TCP 发送方经重新慢启动,回归到的还是未丢包状态 (cwnd 启动初始值设置过小)。

尽管如此,这么做仍然是一种比较激进的策略,毕竟对于通信参与方而言,慢启动会『突然』减少数据流,之前好不容易把速度提上来,这一旦出发了超时重传,速率又跟刚连接时一样了。用户会感受到网络卡顿。

为解决这一问题,针对不同的丢包情况,重新考虑是否需要重回慢启动状态。若是由重复 ACK 引起的丢包(引发快速重传)cwnd 值将被设为上一个 ssthresh,而非先前的 1 SMSS。在大多数 TCP 版本中,超时仍是引发慢启动的主要原因。这种方法使得 TCP 无须重新慢启动,而只要把传输速率减半即可。

发生快速重传的拥塞发生算法

我们知道,TCP 的快速重传是基于冗余 ACK 的重传机制,即接收方在收到一个乱序的数据包后,会立即返回对前一个正确收到的数据包的确认报文(ACK),如果发送方连续收到三个或以上相同的 ACK,就认为对应序号的数据包丢失了。此时发送端就会快速地重传,不必等待超时再重传。

从快速重传机制可以知道,这种错误不会那么严重,因此不必等待代价高昂的超时重传。

再进入快速恢复阶段:

  • cwnd = cwnd/2;ssthresh = cwnd。即将拥塞窗口设为当前拥塞窗口的一半,并每收到一个冗余 ACK 就增加一个 MSS,直到收到新的 ACK 为止。
  • 进入『快速恢复』算法。

这种机制的优点是可以快速地检测和恢复丢失的数据包,减少了等待时间和网络负载。

快速恢复

快速恢复通常与快速重传配合使用,目的是在数据包丢失后,快速恢复发送窗口的大小,避免过度降低发送速率。

举个例子,假设发送方发送了数据包 M1,M2,M3,M4,M5,接收方收到了 M1,M2,M4,M5,但没有收到 M3。按照快速重传的规则,接收方会连续发送三个对 M2 的重复确认(ACK),让发送方知道 M3 丢失了,并立即重传 M3。这时,按照快速恢复的规则,发送方会执行以下步骤:

  • 将 ssthresh 设置为当前拥塞窗口 cwnd 的一半,并重传丢失的数据包 M3。
  • 将当前的 cwnd 设置为 ssthress 加上 3 个最大报文段大小(MSS),即 cwnd = ssthresh + 3*MSS。这是为了保持网络的利用率,避免因为重传而减少发送新数据包的数量。
  • 每收到一个冗余 ACK(对 M2 的重复确认),就将 cwnd 加上一个 MSS,并发送一个新的数据包(如果有)。这是为了利用冗余 ACK 来增加拥塞窗口,使得发送方可以继续发送数据包,而不是等待重传计时器到期。
  • 当接收方收到重传的数据包 M3 后,会发送一个新的 ACK(对 M5 的确认),表示已经收到了所有的数据包。这时,发送方会将 cwnd 设置为 ssthresh,并退出快速恢复阶段,进入拥塞避免阶段。

发送方为什么收到新的数据后,将 cwnd 重新设置为原先的 ssthresh ?

这是为了避免拥塞窗口过大导致网络再次出现拥塞。因为在快速恢复阶段,发送方的拥塞窗口是根据冗余 ACK 来增加的,而不是根据网络的实际情况来调整的。所以,当收到新的数据后,发送方认为网络已经恢复正常,就将拥塞窗口重新设置为原先的 ssthresh,也就是丢包前的一半,然后再按照拥塞避免算法来逐渐增加拥塞窗口。这样做可以保证发送方不会过分占用网络资源,也可以适应网络的变化。

TCP 拥塞控制的变化过程如下:

image-20230724234059457

图片来源:SlideToDoc

另一张图也可以总结:

image-20230724235122873

图片来源:TCP 协议的拥塞控制

其中:

  • 指数增长。刚开始进行 TCP 通信时拥塞窗口的值为 1,并不断按指数的方式进行增长。
  • 加法增大。拥塞避免:当拥塞窗口由慢开始增长到 “ssthresh 的初始值”(16) 时,不再翻倍增长而是每次增加 1,此为拥塞避免的“加法增大”,降低了拥塞窗口的增长速度。
  • (图中已弃用)乘法减小。拥塞窗口在线性增长的过程中,在增大到 24 时如果发生了网络拥塞,此时慢启动的阈值将变为当前拥塞窗口的一半,也就是 12,并且拥塞窗口的值被重新设置为 1,所以下一次拥塞窗口由指数增长变为线性增长时拥塞窗口的值应该是 12。
  • 快恢复:由图可以看出快恢复和快重传是紧密相连的,在执行快重传结束时,就执行了快恢复,快恢复则是把 “ssthresh 的值” 设置为快重传最后一次执行值的一半,然后通过拥塞控制的 “加法增大” 进行线性的增长,降低了发送方发送的速率,解决了拥塞问题。

参与通信的双方都会根据网络状况来进行这些操作,以保证网络的通畅。值得注意的是,对于每台主机而言,拥塞窗口的大小不一定非要相同,即使它们处于同一局域网,这取决于它们的发送速率、网络延迟、丢包率等因素。因此在同一时刻有的主机发生了网络拥塞,有的却没有。

拥塞控制算法的目的就是让每个主机根据自己的情况动态调整拥塞窗口,以达到最优的网络性能。这也算是 TCP 想尽可能快地将数据传输给对方,同时也要避免给网络造成太大压力的折中方案。这是因为,一旦连接处于网络拥塞状态:

  1. 前期要让网络缓一缓,对应着指数增长的缓慢,且少。
  2. 中后期网络恢复,有一定能力承载更大的流量,但是此时正处于“指数爆炸时期”,为了保证通信效率,使用了线性增长。

TCP 比 UDP 多了这么多步骤,效率还能比 UDP 高吗?

TCP 和 UDP 的效率比较并不是一个简单的问题,它取决于很多因素,比如数据包的大小、网络的质量、应用的需求等。一般来说,UDP 比 TCP 更快,但也不是绝对的。下面是一些影响 TCP 和 UDP 效率的因素:

  • TCP 和 UDP 的报头大小不同。TCP 的报头至少有 20 字节,最多有 60 字节,而 UDP 的报头只有 8 字节。这意味着 UDP 的开销更小,占用的空间更少。
  • TCP 和 UDP 的确认机制不同。TCP 是可靠的协议,它需要在发送方和接收方之间进行握手、确认、重传等操作,以保证数据包的完整性和顺序。而 UDP 是不可靠的协议,它不需要进行任何确认,只是尽力而为地发送数据包。这意味着 UDP 的处理更快,但也可能导致数据包的丢失或乱序。
  • TCP 和 UDP 的传输方式不同。TCP 是基于字节流的协议,它会将应用层的数据分割成多个字节,并按照顺序发送。而 UDP 是基于消息的协议,它会将应用层的数据封装成一个个数据块,并保留消息边界。这意味着 UDP 可以更好地适应不同大小的数据包,而 TCP 可能需要缓存或填充数据以适应网络段。

综上所述,UDP 在一些场景下比 TCP 更快,比如:

  • 数据包较小,不需要分片或重组。
  • 网络质量较好,丢包率较低。
  • 应用对实时性要求较高,对可靠性要求较低。

而 TCP 在一些场景下比 UDP 更快,比如:

  • 数据包较大,需要分片或重组。
  • 网络质量较差,丢包率较高。
  • 应用对可靠性要求较高,对实时性要求较低。

3.7 延迟应答

TCP 中的延迟应答是一种优化策略,它的目的是为了减少网络上的小数据包,提高网络利用率和传输效率。它的原理是接收方在收到数据包后,并不立即发送确认应答,而是等待一段时间,让缓冲区中的数据被处理,从而增大窗口大小,使发送方可以发送更多的数据。

值得注意的是,延迟应答的目的不是保证可靠性,而是保证留有时间让接收缓冲区中的数据尽可能被上层应用程序取出,这样 ACK 中的窗口大小就可以尽可能地大,从而增大网络吞吐量,提高数据的传输效率。

但是延迟应答也有一些缺点,比如:

  • 延迟应答会增加数据包的往返时间(RTT),可能影响某些对时延敏感的应用。
  • 延迟应答会使发送方等待更长的时间才能得到确认,可能影响拥塞控制和流量控制的效果。
  • 延迟应答会使接收方缓冲区占用更长的时间,可能影响接收方的处理能力。

因此,在某些情况下,需要关闭或调整延迟应答的机制,以适应不同的网络环境和应用需求。一般来说,有以下几种方法可以解决或缓解延迟应答的问题:

  • 修改操作系统的参数,比如在 Linux 中可以通过设置/proc/sys/net/ipv4/tcp_delack_min来调整最小延迟时间。
  • 修改协议层的参数,比如在 TCP 中可以通过设置TCP_QUICKACK选项来强制发送确认应答。

不是所有的数据包都可以延迟应答,这些限制是为了保证数据的可靠传输,避免发送方等待太久或者重复发送数据:

  • 数量限制:每隔一定数量的数据包就必须发送一个确认应答,一般是两个。
  • 时间限制:超过最大延迟时间就必须发送一个确认应答,一般是 200 毫秒。
  • 状态限制:如果接收方没有数据要发送,就不能使用捎带应答,只能单独发送确认应答。

3.8 捎带应答

捎带应答是在延迟应答的基础上进行的,也就是说,接收方在收到数据包后,并不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。如果有,就把确认应答和数据一起发送,这就是捎带应答。如果没有,就单独发送确认应答。

捎带应答的好处是可以减少网络上的小数据包和开销,提高网络利用率和传输效率。因为如果每次发送一个确认应答或一个数据包,都需要占用一个 TCP 包的报头空间,这些报头空间会占用网络资源,增加网络开销,降低网络性能。而如果把确认应答和数据一起发送,就可以节省一个 TCP 包的报头空间,减少网络资源的消耗,提高网络性能。

假设有两个主机 A 和 B,它们之间使用 TCP 协议进行通信,A 是发送方,B 是接收方。假设每个数据包的大小是 1000 字节,延迟应答的最大时间是 200 毫秒,每隔两个数据包就必须发送一个确认应答。下面是一个可能的通信过程:

  • A 向 B 发送第一个数据包,编号为 1。
  • B 收到第一个数据包,但不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。
  • A 向 B 发送第二个数据包,编号为 2。
  • B 收到第二个数据包,由于已经达到了数量限制,就必须发送一个确认应答。假设此时 B 有数据要发送给 A,就把确认应答和数据一起发送,这就是捎带应答。假设 B 要发送的数据包编号为 3,那么它就会在这个数据包中附加一个确认应答,编号为 2。
  • A 收到捎带应答和数据包,知道前两个数据包已经被 B 正确接收,并处理 B 发来的数据包。
  • A 向 B 发送第三个数据包,编号为 4。
  • B 收到第三个数据包,但不立即发送确认应答,而是等待一段时间,看是否有其他数据要发送。
  • A 向 B 发送第四个数据包,编号为 5。
  • B 收到第四个数据包,由于已经达到了数量限制,就必须发送一个确认应答。假设此时 B 没有数据要发送给 A,就单独发送一个确认应答,编号为 5。
  • A 收到确认应答,知道前四个数据包已经被 B 正确接收。

在这个过程中,在第二次和第四次通信时,B 都使用了捎带应答的机制,在同一个 TCP 包中即发送了确认应答又发送了数据。这样做可以减少网络上的小数据包和开销,并提高网络利用率和传输效率。

另外,捎带应答在保证发送数据的效率之外,由于捎带应答的报文携带了有效数据,因此对方收到该报文后会对其进行响应,当收到这个响应报文时不仅能够确保发送的数据被对方可靠的收到了,同时也能确保捎带的 ACK 应答也被对方可靠的收到了。

3.9 面向字节流

当创建一个 TCP 的 socket 时,同时在内核中会创建一个发送缓冲区和一个接收缓冲区。

  • 调用 write 函数就可以将数据写入发送缓冲区中,但是如果发送缓冲区已满,write 函数会阻塞,直到有足够的空间可以写入数据。发送缓冲区当中的数据会由 TCP 自行进行发送,但是发送的字节流的大小会根据窗口大小、拥塞控制、流量控制等因素来动态调整。如果发送的字节数太长,TCP 会将其拆分成多个数据包发出。如果发送的字节数太短,TCP 可能会先将其留在发送缓冲区当中,等到合适的时机再进行发送。

  • 接收数据的时候,数据也是从网卡驱动程序到达内核的接收缓冲区,可以通过调用 read 函数来读取接收缓冲区当中的数据。但是如果接收缓冲区为空,read 函数会阻塞,直到有数据到达。接收缓冲区当中的数据也是由 TCP 自行进行接收,但是接收的字节流的大小会根据窗口大小、确认机制等因素来动态调整。而调用 read 函数读取接收缓冲区中的数据时,也可以按任意字节数进行读取。

由于缓冲区的存在,TCP 程序的读和写不需要一一匹配,例如:

  • 写 100 个字节数据时,可以调用一次 write 写 100 字节,也可以调用 100 次 write,每次写一个字节。
  • 读 100 个字节数据时,也完全不需要考虑写的时候是怎么写的,既可以一次 read100 个字节,也可以一次 read 一个字节,重复 100 次。

实际对于 TCP 来说,它并不关心发送缓冲区当中的是什么数据,在 TCP 看来这些只是一个个的字节数据,并且给每个字节分配了一个序号,并通过序号和确认号来保证字节流的顺序和完整性。它的任务就是将这些数据准确无误地发送到对方的接收缓冲区当中就行了,而至于如何解释这些数据完全由上层应用来决定,这就叫做面向字节流。而 OS 也是一样的,它只关心缓冲区的剩余大小,而不关心数据本身。

3.10 粘包问题

首先要明确:

  • 粘包问题中的 “包”,指的是应用层的数据包
  • 在 TCP 的协议头中,没有如同 UDP 一样的 “报文长度” 这样的字段。
  • 站在传输层的角度,TCP 是一个一个报文过来的,按照序号排好序放在缓冲区中。
  • 站在应用层的角度,看到的只是一串连续的字节数据。
  • 那么应用程序看到了这么一连串的字节数据,就不知道从哪个部分开始到哪个部分,是一个完整的应用层数据包。

导致粘包问题的因素是报文之间的边界不清晰

粘包问题指的是发送方发送的多个数据包在接收方被合并为一个数据包的现象。这是因为 TCP 是面向字节流的协议,它不关心数据的逻辑结构,只负责将字节流按序和完整地传输给对方。TCP 在发送或接收数据时,都会通过缓冲区来进行优化,根据网络状况和窗口大小来动态调整发送或接收的字节流的大小。这样就可能导致发送方发送的多个数据包被拼接在一起,或者一个数据包被拆分成多个部分。

解决办法:

  • 对于定长的包,保证每次都按固定大小读取即可。
  • 对于变长的包,可以在报头的位置,约定一个包总长度的字段,从而就知道了包的结束位置。比如 HTTP 报头当中就包含 Content-Length 属性,表示正文的长度。
  • 对于变长的包,还可以在包和包之间使用明确的分隔符。因为应用层协议是程序员自己来定的,只要保证分隔符不和正文冲突即可。

UDP 没有粘包问题:

这是因为 UDP 是面向报文的协议,它将数据视为一个个独立的报文,每个报文都有自己的边界和长度。UDP 在发送或接收数据时,都是以报文为单位,不会对报文进行拆分或合并。UDP 不保证报文的顺序和完整性,只负责将报文原封不动地传输给对方。

UDP 要冗余一些信息是因为 UDP 没有可靠性保证,它不会对丢失、重复、乱序的报文进行处理,这些工作需要交给应用层来完成。所以 UDP 通常会在报文中添加一些额外的信息,如序号、校验和、长度等,来帮助应用层识别和处理异常的报文。

3.11 TCP 异常情况

这是一个宽泛的问题,下面就 TCP 协议的工作原理和常见的故障场景来简要介绍一些可能的异常情况:

  • TCP 连接建立过程中的异常。这些异常通常是由于网络不通、目标主机或端口不存在、服务端应用程序阻塞或崩溃等原因导致的。例如:

    • 客户端发送 SYN 包后,没有收到服务端的 SYN+ACK 包,可能是因为网络不通或者服务端没有监听该端口。
    • 客户端发送 SYN 包后,收到服务端的 RST 包,可能是因为服务端拒绝了连接请求或者服务端没有监听该端口。
    • 客户端发送 ACK 包后,没有收到服务端的数据包,可能是因为服务端应用程序被阻塞或崩溃了。

    当一个进程退出时,该进程曾经打开的文件描述符都会自动关闭,因此当客户端进程退出时,相当于自动调用了 close 函数关闭了对应的文件描述符,此时双方操作系统在底层会正常完成四次挥手,然后释放对应的连接资源。也就是说,进程终止时会释放文件描述符,TCP 底层仍然可以发送 FIN,和进程正常退出没有区别。

  • TCP 连接断开过程中的异常。这些异常通常是由于网络不稳定、主机宕机、应用程序异常退出等原因导致的。例如:

    • 客户端或服务端发送 FIN 包后,没有收到对方的 ACK 包,可能是因为网络不稳定或者对方主机宕机了。
    • 客户端或服务端发送 FIN 包后,收到对方的 RST 包,可能是因为对方应用程序异常退出了。
    • 客户端或服务端发送 RST 包后,没有收到对方的任何响应,可能是因为对方已经关闭了连接或者主机宕机了。

    当客户端正常访问服务器时,如果将客户端主机重启,此时建立好的连接会怎么样?

    当我们选择重启主机时,操作系统会先杀掉所有进程然后再进行关机重启,因此机器重启和进程终止的情况是一样的,此时双方操作系统也会正常完成四次挥手,然后释放对应的连接资源。

  • TCP 连接传输数据过程中的异常。这些异常通常是由于网络拥塞、数据丢失、数据乱序、数据重复、数据错误等原因导致的。例如:

    • 客户端或服务端发送数据包后,没有收到对方的 ACK 包,可能是因为网络拥塞或者数据丢失了。
    • 客户端或服务端收到对方的数据包后,发现序号不连续,可能是因为数据乱序了。
    • 客户端或服务端收到对方的数据包后,发现序号重复,可能是因为数据重复了。
    • 客户端或服务端收到对方的数据包后,发现校验和错误,可能是因为数据错误了。

    当客户端正常访问服务器时,如果将客户端突然掉线了,此时建立好的连接会怎么样?

    当客户端掉线后,服务器端在短时间内无法知道客户端掉线了,因此在服务器端会维持与客户端建立的连接,但这个连接也不会一直维持,因为 TCP 是有保活策略的。

  • 服务器会定期客户端客户端的存在状况,检查对方是否在线,如果连续多次都没有收到 ACK 应答,此时服务器就会关闭这条连接。

  • 此外,客户端也可能会定期向服务器 “报平安”,如果服务器长时间没有收到客户端的消息,此时服务器也会将对应的连接关闭。

其中服务器定期询问客户端的存在状态的做法,叫做基于保活定时器的一种心跳机制,是由 TCP 实现的。此外,应用层的某些协议,也有一些类似的检测机制,例如基于长连接的 HTTP,也会定期检测对方的存在状态。

TCP 协议本身具有一定的容错和恢复能力,可以通过超时重传、滑动窗口、流量控制、拥塞控制等机制来处理一些异常情况。但是有些异常情况需要应用层协议或者用户干预来解决。例如:

  • 如果 TCP 连接建立失败,可以尝试重新建立连接或者检查网络和目标主机是否正常。
  • 如果 TCP 连接断开失败,可以尝试关闭套接字或者检查网络和对方主机是否正常。
  • 如果 TCP 连接传输数据失败,可以尝试重发数据或者检查网络和对方主机是否正常。

4. TCP 小结

小结

TCP 协议这么复杂就是因为 TCP 既要保证可靠性,同时又尽可能的提高性能。

可靠性:

  • 检验和。
  • 序列号。
  • 确认应答。
  • 超时重传。
  • 连接管理。
  • 流量控制。
  • 拥塞控制。

提高性能:

  • 滑动窗口。
  • 快速重传。
  • 延迟应答。
  • 捎带应答。

需要注意的是,TCP 的这些机制有些能够通过 TCP 报头体现出来的,但还有一些是通过代码逻辑体现出来的。

TCP 定时器

此外,TCP 当中还设置了各种定时器。

  • 重传定时器:为了控制丢失的报文段或丢弃的报文段,也就是对报文段确认的等待时间。
  • 坚持定时器:专门为对方零窗口通知而设立的,也就是向对方发送窗口探测的时间间隔。
  • 保活定时器:为了检查空闲连接的存在状态,也就是向对方发送探查报文的时间间隔。
  • TIME_WAIT 定时器:双方在四次挥手后,主动断开连接的一方需要等待的时长。

理解传输控制协议

TCP 的各种机制实际都没有谈及数据真正的发送,这些都叫做传输数据的策略。TCP 协议是在网络数据传输当中做决策的,它提供的是理论支持,比如 TCP 要求当发出的报文在一段时间内收不到 ACK 应答就应该进行超时重传,而数据真正的发送实际是由底层的 IP 和 MAC 帧完成的。

TCP 做决策和 IP+MAC 做执行,我们将它们统称为通信细节,它们最终的目的就是为了将数据传输到对端主机。而传输数据的目的是什么则是由应用层决定的。因此应用层决定的是通信的意义,而传输层及其往下的各层决定的是通信的方式。

Socket 编程相关问题

Accept

accept 要不要参与三次握手的过程呢?

accept() 不需要参与三次握手的过程。三次握手是 TCP 协议在内核层面完成的,accept 只是在应用层面从完成队列中取出一个已经建立的连接,并返回一个新的套接字。也就是说,连接已经在内核中建立好了,accept() 只是一个查询和返回的过程,并不影响三次握手的逻辑。

如果不调用 accept(),可以建立连接成功吗?

如果不调用 accept,连接仍然可以建立成功,只是在应用层面无法获取到新的套接字。这时,连接会一直处于完成队列中,直到被取出或者超时。如果完成队列满了,那么后续的连接请求就会被拒绝或者忽略。

这么说的话,如果上层来不及调用 accept 函数,而且对端还在短时间内发送了大量连接请求,难道所有连接都应该事先建立好吗?

不是,TCP 协议为了防止这种情况,提供了一个未完成队列,用来存放已经收到 SYN 包,但还没有收到 ACK 包的连接。这些连接还没有建立成功,只是处于半连接状态。如果未完成队列也满了,那么后续的连接请求就会被丢弃。所以,TCP 协议并不会为每个连接请求都建立成功的连接,而是有一定的限制和策略。

那么这对队列有什么要求?

这需要了解 TCP 协议在内核层面维护的两个队列:未完成队列和完成队列。未完成队列用于存放已经收到 SYN 包,但还没有收到 ACK 包的连接,也就是半连接状态。完成队列用于存放已经完成三次握手的连接,也就是全连接状态。

我们可以把 TCP 服务器看作是餐厅,把客户端看作是顾客,把未完成队列看作是等候区,把完成队列看作是就餐区。那么:

  • 当顾客来到餐厅时,需要先在等候区排队,等候区的大小由餐厅的规模决定,如果等候区满了,那么后来的顾客就无法进入,只能等待或者离开。
  • 当等候区有空位时,顾客可以进入等候区,并向餐厅发出就餐请求,这相当于发送 SYN 包。
  • 当餐厅收到就餐请求时,会给顾客一个号码牌,并告诉顾客稍后会有空位,这相当于发送 SYN+ACK 包。
  • 当顾客收到号码牌时,会给餐厅一个确认信号,并等待被叫号,这相当于发送 ACK 包。
  • 当就餐区有空位时,餐厅会根据号码牌叫号,并将顾客从等候区移到就餐区,这相当于完成三次握手,并将连接从未完成队列移到完成队列。
  • 当顾客在就餐区用完餐后,会离开餐厅,并释放空位,这相当于断开连接,并清空队列。

对这两个队列的要求主要是:

  • 队列的大小。队列的大小决定了 TCP 服务器能够处理的连接请求的数量,如果队列满了,那么后续的连接请求就会被拒绝或者丢弃。队列的大小可以通过一些内核参数或者应用层参数来设置。例如:
    • 未完成队列的大小由内核参数net.ipv4.tcp_max_syn_backlog设置。
    • 完成队列的大小由应用层参数listen函数中的backlog参数(第二个)和内核参数net.core.somaxconn共同决定,取二者中较小的值。
  • 队列的处理策略。队列的处理策略决定了 TCP 服务器在遇到异常情况时如何响应客户端。例如:
    • 如果未完成队列满了,TCP 服务器可以选择是否启用syncookie机制,来防止syn flood攻击。如果启用了syncookie机制,那么 TCP 服务器会根据客户端的 SYN 包计算出一个特殊的序号,并在收到客户端的 ACK 包时验证其合法性。如果不启用syncookie机制,那么 TCP 服务器会丢弃新来的 SYN 包,并等待客户端超时重传或者放弃。
    • 如果完成队列满了,TCP 服务器可以选择是否启用tcp_abort_on_overflow参数,来决定是否直接发送 RST 包给客户端。如果启用了该参数,那么 TCP 服务器会直接发送 RST 包给客户端,并关闭连接。如果不启用该参数,那么 TCP 服务器会丢弃客户端发送的 ACK 包,并等待客户端重传或者放弃。

Listen

listen 函数的第二个参数,也就是 backlog 参数,是用来设置完成队列的大小的。它表示餐厅可以同时容纳多少个就餐的顾客。如果 backlog 参数设置得太小,那么餐厅就会很快满座,无法接待更多的顾客。如果 backlog 参数设置得太大,那么餐厅就会浪费空间和资源,而且可能超过餐厅的实际规模。所以,backlog 参数需要根据餐厅的服务能力和顾客的需求来合理设置。

参考资料

  • 《图解 TCP/IP》
  • 《TCP/IP 详解 卷 1 协议》
  • 小林 coding