深度解密 TCP 协议(三次握手、四次挥手、拥塞控制、性能优化)

楔子

巨人的肩膀:公众号《小林 coding》

随着你工作经验的积累,你会越来越意识到底层网络协议的重要性。比如我们时时刻刻在使用的 HTTP 协议其实只负责包装数据,并不负责数据传输,真正负责传输的是 TCP/IP 协议;再比如我们使用的 Web 框架,它们本质上就是一个 socket,而 socket 又是对 TCP/IP 协议的一个封装,可以让我们更方便地使用 TCP/IP 协议,而不用关注背后的原理。

注:socket 不是什么协议,也不属于 "四层" 或 "七层" 中的任意一层,它只是一组调用接口,对 TCP/IP 协议栈进行了一个封装。如果非要把 socket 放到 OSI 七层模型中的话,那么可以把它看成是位于「应用层」和「传输层」之间的一个抽象层。

所以一切都指向了 TCP/IP 协议,如果想成为一个高手的话,那么 TCP/IP 协议是必须要了解的。特别是大厂,面试官几乎不会问题某个框架怎么使用,更喜欢问你底层的 TCP/IP 协议。那么接下来我们就来看看 TCP/IP 中的 TCP。

认识 TCP

TCP 是面向连接的、可靠的、基于字节流的传输层通信协议。

  • 面向连接:一定是「一对一」才能连接,不能像 UDP 协议那样可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的;
  • 可靠的:无论的网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端;
  • 字节流:消息是「没有边界」的,所以无论我们消息有多大都可以进行传输。并且消息是「有序的」,当「前一个」消息没有收到的时候,即使后面的字节已经收到,那么也不能扔给应用层去处理,同时对「重复」的报文会自动丢弃;

我们来看看 TCP 报文格式,相比 HTTP 报文(Header + Body),TCP 报文就显得复杂许多。

序列号:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。

确认应答号:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决不丢包的问题。

控制位:

  • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1
  • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接
  • SYC:该位为 1 时,表示希望建立连,并在其「序列号」的字段进行序列号初始值的设定
  • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位置为 1 的 TCP 段

为什么需要 TCP 协议? TCP 工作在哪一层?

IP 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。

如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 TCP 协议来负责。因为 TCP 是一个工作在「传输层」并且「可靠」的数据传输服务,它能确保接收端接收的网络包是「无损坏、无间隔、非冗余和按序的」。

什么是 TCP 连接?

我们来看看 RFC 793 是如何定义「连接」的:

Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.

简单来说就是,用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合(包括 socket、序列号和窗口大小)称为连接。

所以我们可以知道,建立一个 TCP 连接是需要客户端与服务器端达成上述三个信息的共识:

  • socket:由 IP 地址和端口号组成
  • 序列号:用来解决乱序问题等
  • 窗口大小:用来做流量控制

如何唯一确定一个 TCP 连接呢?

TCP 四元组可以唯一的确定一个连接,四元组包括:源地址、源端口、目的地址、目的端口。

源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机;源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。

有一个 IP 的服务器监听了一个端口,它的 TCP 的最大连接数是多少?

服务器通常固定在某个本地端口上监听,等待客户端的连接请求。因此,客户端 IP 和 端口是可变的,其理论值计算公式如下:

最大 TCP 连接数 = 客户端的 IP 数 x 客户端的端口数

对 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数,约为 2 的 48 次方。当然,虽然理论上有这么多,但实际上服务端的最大并发数远达不到这么多,原因如下:

  • 首先主要是文件描述符限制,Socket 都是文件,所以首先要通过 ulimit 配置文件描述符的数目;
  • 另一个是内存限制,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的;

UDP 和 TCP 有什么区别呢?分别的应用场景是?

UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务,UDP 协议真的非常简单,头部只有 8 个字节( 64 位),其报文格式如下:

  • 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个主机上的哪个进程;
  • 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和;
  • 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计;

至于两者的区别有如下几点:

1)连接

  • TCP 是面向连接的传输层协议,传输数据前先要建立连接
  • UDP 是不需要连接,即刻传输数据

2)服务对象

  • TCP 是一对一的两点服务,即一条连接只有两个端点
  • UDP 支持一对一、一对多、多对多的交互通信

3)可靠性

  • TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按需到达
  • UDP 是尽最大努力交付,不保证可靠交付数据

4)拥塞控制、流量控制

  • TCP 有拥塞控制和流量控制机制,保证数据传输的安全性
  • UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率

5)首部开销

  • TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变得更长
  • UDP 首部只有 8 个字节,并且是固定不变的,开销较小

TCP 和 UDP 应用场景:

由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:

  • FTP 文件传输
  • HTTP / HTTPS

由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:

  • 包总量较少的通信,如 DNS、SNMP 等
  • 视频、音频等多媒体通信
  • 广播通信

为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢?

原因是 TCP 有「可变长」的「选项」字段,而 UDP 头部长度则是「不会变化」的,无需多一个字段去记录 UDP 的首部长度。

为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢?

先说说 TCP 是如何计算负载数据长度:

TCP 数据的长度 = IP 总长度 - IP 首部长度 - TCP 首部长度

其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的,TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。

这时可能有人就奇怪了:"UDP 也是基于 IP 层的呀,并且其首部是固定的,那 UDP 的数据长度也可以通过这个公式计算呀? 为何还要有「包长度」呢?"

这么一问,确实感觉 UDP 「包长度」是冗余的。因为为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。如果去掉 UDP 「包长度」字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以这可能是为了补全 UDP 首部长度是 4 字节的整数倍,才补充了「包长度」字段。

TCP 连接建立

TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手而进行的。整个过程的示意图如下(图中有一处写错了,应该是 SYN_RCVD,写成了 SYS_RCVD):

一开始,客户端和服务端都处于 CLOSED 状态,然后服务端主动监听某个端口,处于 LISTEN 状态。接下来就开始三次握手了,我们来解释一下每个步骤都干了什么事情。

1)客户端会随机初始化一个序列号(client_isn),并将其置于 TCP 首部的「序列号」字段中,同时把「SYN」标志位设置成 1,表示这是一个 「SYN」报文。然后将这个 SYN 报文发送给服务端,表示要和服务端建立连接,发送之后客户端处于「SYN_SENT」状态。注意:该报文不包含应用层数据。

2)服务端收到客户端的 SYN 报文之后,首先服务端也随机初始化自己的序列号(server_isn),并填入 TCP 首部的「序列号」字段中,其次把 TCP 首部的「确认应答号」设置为 client_isn + 1,把 SYN 和 ACK 标志位设置为 1。最后把报文发给客户端,该报文也不包含应用层数据,发送之后服务端处于 SYN_RCVD 状态。

3)客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文的 TCP 首部的 ACK 标志位设置为 1,其次「确认应答号」为 server_isn + 1,最后把报文发送给服务端,之后客户端处于 ESTABLISHED 状态。

服务端收到客户端的应答报文后,也进入 ESTABLISHED 状态。

从上面的过程可以发现「第三次握手是可以携带数据的,前两次握手是不可以携带数据的」,这也是面试常问的题。一旦完成三次握手,双方都处于 ESTABLISHED 状态,至此连接就已建立完成,客户端和服务端就可以相互发送数据了。

如何在 Linux 系统中查看 TCP 状态?

TCP 的连接状态查看,在 Linux 可以通过 netstat -napt 命令查看。

  • Proto:协议名
  • Local Address:源地址 + 端口
  • Foreign Address:目的地址 + 端口
  • State:连接状态
  • PID/Program name:Web 服务的进程 PID 和进程名称

为什么是三次握手?不是两次、四次?

相信大多数人的回答是:"因为三次握手才能保证双方具有接收和发送的能力",这种回答是没有问题的,但是比较片面,没有说出主要的原因。

在前面我们知道了什么是 TCP 连接:用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合(包括 socket、序列号、窗口大小)称为连接。所以重点是为什么要三次握手才可以初始化 socket、序列号、窗口大小并建议 TCP 连接。

接下来以三个方面分析三次握手的原因:

  • 三次握手才可以阻止重复历史连接的初始化(主要原因)
  • 三次握手才可以同步双方的初始序列号
  • 三次握手才可以避免资源浪费

 

原因一:避免历史连接

我们来看看 RFC 793 指出的 TCP 连接使用三次握手的「首要原因」:The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion。简单来说,三次握手的「首要原因」是为了「防止旧的重复连接初始化造成混乱」。

由于网络环境是错综复杂的,往往并不是如我们期望的一样,先发送的数据包,就先到达目标主机,反而它很骚,可能会由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何避免的呢?

客户端连续发送多次 SYN 建立连接的报文,在网络拥堵等情况下:

  • 一个「旧 SYN 报文」比「最新的 SYN 」 报文早到达了服务端;
  • 那么此时服务端就会回一个 SYN + ACK 报文给客户端;
  • 客户端收到后可以根据自身的上下文,判断这是一个历史连接(序列号过期或超时),那么客户端就会发送 RST 报文给服务端,表示中止这一次连接;

如果是两次握手连接,就不能判断当前连接是否是历史连接,三次握手则可以在客户端(发送方)准备发送第三次报文时,有足够的上下文来判断当前连接是否是历史连接:

  • 如果是历史连接(序列号过期或超时),则第三次握手发送的报文是 RST 报文,以此中止历史连接;
  • 如果不是历史连接,则第三次发送的报文是 ACK 报文,通信双方就会成功建立连接;

所以, TCP 使用三次握手建立连接的最主要原因是「防止历史连接初始化了连接」。

 

原因二:同步双方初始序列号

TCP 协议的通信双方, 都必须维护一个「序列号」, 序列号是可靠传输的一个关键因素,它的作用:

  • 接收方可以去除重复的数据;
  • 接收方可以根据数据包的序列号按序接收;
  • 可以标识发送出去的数据包中, 哪些是已经被对方收到的;

可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「序列号」的 SYN 报文的时候,需要服务端回一个 ACK 应答报文(其中的确认应答号必须等于 SYN 报文中的序列号 + 1),表示客户端的 SYN 报文已被服务端成功接收。那当服务端发送「序列号」给客户端的时候,依然也要得到客户端的应答回应,这样一来一回,才能确保双方的初始序列号能被可靠的同步。

  • 客户端先发送 SYN 报文,序列号为 client_isn
  • 服务端发送 ACK 应答报文,确认应答号为 client_isn + 1
  • 服务端也发送 SYN 报文,序列号为 server_isn
  • 客户端收到之后同样发送 ACK 应答报文,确认应答号为 server_isn + 1

咦,这貌似变成了四步啊,不是三次握手吗?没错,只是第二步和第三步合在一起了,服务端在收到 SYN 报文后发送是 SYN + ACK 报文。

四次握手其实也能够可靠的同步双方的序列号,但由于「第二步和第三步可以优化成一步」,所以就成了「三次握手」。而两次握手只保证了一方的序列号能被对方成功接收,没办法保证双方的序列号都能被确认接收。

 

原因三:避免资源浪费

如果只有「两次握手」,那么当客户端的 SYN 请求连接在网络中阻塞,一直没有接收到来自服务端的 ACK 报文,就会重新发送 SYN。由于没有第三次握手,服务器不清楚客户端是否收到了自己发送的建立连接的 ACK 确认信号,所以每收到一个 SYN 就只能先主动建立一个连接。因此,如果客户端的 SYN 阻塞了,重复发送多次 SYN 报文,那么服务器在收到请求后就会「建立多个冗余的无效链接,造成不必要的资源浪费」。

我们举个栗子,如果是两次握手,看看会出现什么情况,由于比较简单,就不画图了。

  • 1. 客户端发送 SYN 报文,但此时出现了网络拥堵阻塞
  • 2. 因为超时,客户端再次重发 SYN 报文,此时网络比较顺畅
  • 3. 服务端收到 SYN 报文之后,返回 SYN + ACK 报文,此时分配资源建立连接,但客户端此时是否收到服务端是不知道的,因为没有第三次握手,我们就假设客户端收到了,也分配了资源、建立了连接
  • 4. 然后一段时间过后,第一次发送的 SYN 报文成功抵达了服务端,由于是两次握手,服务端又建立了连接,然后返回给客户端 SYN + ACK 报文

所以两次握手会在消息滞留情况下,造成服务器重复接受无用的连接请求(SYN 报文),而重复分配资源。

所以 TCP 建立连接时,通过三次握手「能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号」。序列号能够保证数据包不重复、不丢弃和按序传输。而不使用「两次握手」和「四次握手」的原因:

  • 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
  • 「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数;

序列号 ISN 是如何随机产生的?

第一次产生的 ISN 也被成为初始序列号,它是基于时钟的,每 4 毫秒 + 1,转一圈要 4.55 个小时。RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法:

ISN = M + F (localhost, localport, remotehost, remoteport)

M 是一个计时器,这个计时器每隔 4 毫秒加 1;F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值,并且好要保证 Hash 算法不能被外部轻易推算得出。

并且客户端和服务端的初始序列号 ISN 是不相同的,因为网络中的报文「会延迟、会复制重发、也有可能丢失」,这样会造成的不同连接之间产生互相影响,所以为了避免互相影响,客户端和服务端的初始序列号是随机且不同的。

既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢?

我们先来认识下 MTU 和 MSS:

  • MTU:一个网络包的最大长度,以太网中一般为 1500 字节;
  • MSS:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度;

如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢?

当 IP 层有一个超过 MTU 大小的报文(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,在交给上一层 TCP 传输层。

这看起来井然有序,但其实存在隐患,如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。当接收方发现 TCP 报文(头部 + 数据)的某一片丢失后,则不会响应 ACK 给对方,那么发送方的 TCP 在超时后,就会重发「整个 TCP 报文(头部 + 数据)」。

因此,可以得知由 IP 层进行分片传输,是非常没有效率的。所以,为了达到最佳的传输效能,TCP 协议在建立连接的时候通常要协商双方的 MSS 值,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU ,自然也就不用 IP 分片了。

经过 TCP 层分片后,如果一个 TCP 分片丢失后,进行重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

什么是 SYN 攻击?如何避免 SYN 攻击?

我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,服务端每接收到一个 SYN 报文,就进入SYN_RCVD 状态,但服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。

那如何避免呢?可以通过如下两种方式。

避免 SYN 攻击方式:一

其中一种解决方式是通过修改 Linux 内核参数,控制队列大小和当队列满时应做什么处理。

  • 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数:net.core.netdev_max_backlog
  • SYN_RCVD 状态连接的最大个数:net.ipv4.tcp_max_syn_backlog
  • 超出处理能力时,对新的 SYN 直接回报 RST,丢弃连接:net.ipv4.tcp_abort_on_overflow

避免 SYN 攻击方式:二

我们先来看下 Linux 内核的 SYN(未完成连接建立)队列与 Accpet(已完成连接建立)队列是如何工作的?

正常流程:

  • 当服务端接收到客户端的 SYN 报文时,会将其加入到内核的「SYN 队列」;
  • 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
  • 服务端接收到 ACK 报文后,将连接从「SYN 队列」移除并放入到「Accept 队列」;
  • 应用通过调用 socket 接口 accpet(),从「Accept 队列」取出连接;

如果不断受到 SYN 攻击,就会导致「SYN 队列」被占满。

我们知道当收到客户端的 ACK 报文之后会将连接从 「SYN 队列」移除并放入到 「Accept 队列」,但是 SYN 攻击的特点就是在发送完 SYN 报文之后故意不发 ACK 报文,因此最终 SYN 队列会被塞满,Accept 队列会为空。

同理,如果应用程序处理的太慢,迟迟不从 Accept 队列中取连接的话,那么会导致 Accept 队列被塞满。

那么如何解决这一点呢?答案是启动 cookie。

通过设置 net.ipv4.tcp_syncookies = 1 实现。

  • 当 「SYN 队列」满之后,后续服务器收到 SYN 包,不进入「SYN 队列」;
  • 而是计算出一个 cookie 值,再以 SYN + ACK 中的「序列号」的形式返回客户端;
  • 服务端接收到客户端的应答报文时,服务器会检查这个 ACK 包的合法性。如果合法,直接放入到「Accept 队列」;
  • 最后应用通过调用 socket 接口 accpet(),从「 Accept 队列」取出连接;

tcp_syncookies 参数的取值有三种,值为 0 时表示关闭该功能,2 表示无条件开启功能,而 1 则表示仅当 SYN 半连接队列放不下时,再启用它。由于 syncookie 仅用于应对 SYN 泛洪攻击(攻击者恶意构造大量的 SYN 报文发送给服务器,造成 SYN 半连接队列溢出,导致正常客户端的连接无法建立),毕竟这种方式建立的连接,许多 TCP 特性都无法使用。所以,应当把 tcp_syncookies 设置为 1,仅在队列满时再启用。

以上就是 TCP 连接建立所需要的三次握手,下面来看看四次挥手。

TCP 连接断开

天下没有不散的宴席,对于 TCP 连接也是这样, TCP 断开连接是通过「四次挥手」的方式实现的。而双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。

我们来看一下 TCP 四次挥手过程以及状态变迁。

  • 客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态;
  • 服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态;
  • 客户端收到服务端的 ACK 应答报文后,进入 FIN_WAIT_2 状态;
  • 等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态;
  • 客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态;
  • 服务器收到了 ACK 应答报文后,就进入了 CLOSED 状态,至此服务端已经完成连接的关闭;
  • 客户端在经过 2MSL 后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭。关于这里为什么要等 2MSL,后面会详细解释;

可以看到,每个方向都需要「一个 FIN」和「一个 ACK」,因此通常被称为「四次挥手。这里一点需要注意是:主动关闭连接的,才有 TIME_WAIT 状态。

为什么挥手需要四次?

再来回顾下四次挥手双方发 FIN 包的过程,就能理解为什么需要四次了?

  • 关闭连接时,客户端向服务端发送 FIN 时,仅仅表示客户端不再发送数据了但是还能接收数据;
  • 服务器收到客户端的 FIN 报文时,先回一个 ACK 应答报文,表示同意断开连接,但自己还有数据需要处理和发送,需要客户端再等等,此时客户端的状态从 FIN_WAIT_1 变成 FIN_WAIT_2。等服务端不再发送数据时,再发送 FIN 报文给客户端来表示正式关闭连接;

从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 ACK 和 FIN 一般都会分开发送,从而比三次握手导致多了一次。所以如果面试官问你:"为什么握手要三次,挥手要四次",你就可以这么回答它,「因为通过三次握手建立连接时尚未涉及数据的传输,因此服务端的 ACK 和 SYN 是一起发送的;而四次挥手断开连接时,服务端可能还有数据没发送完,因此需要先回复 ACK 表示同意断开连接,等到数据传输完毕时,再发送 FIN 真正断开连接,这两步是分开的。所以握手要三次,挥手要四次」。

为什么需要 TIME_WAIT 状态?

主动发起关闭连接的一方,才会有 TIME_WAIT 状态,而需要 TIME_WAIT 状态,主要基于两个原因:

  • 防止具有相同「四元组」的「旧」数据包被收到;
  • 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭;

原因一:防止旧连接的数据包

假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢?

如上图黄色框框显示的那样,服务端在关闭连接之前发送的 SEQ = 301 报文,被网络延迟了,这时有相同端口的 TCP 连接被复用后,被延迟的 SEQ = 301 抵达了客户端,那么客户端是有可能正常接收这个过期的报文,这就会产生数据错乱等严重的问题。

所以,TCP 就设计出了这么一个机制,经过 2MSL(关于这个时间是怎么来的,一会说),足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。

原因二:保证连接正确关闭

其实在四次挥手示意图中应该就能发现端倪,我们说服务端在传输完数据之后会发送 FIN 表示正式关闭连接,然后处于 LAST_ACK 状态,等待客户端的最后一次确认。如果客户端再发送一次 ACK 给服务端,那么服务端收到之后就会进入 CLOSED 状态,但问题是这最后一次 ACK 报文如果在网络中丢失了该怎么办?

如果没有 TIME_WAIT,那么客户端把 ACK 报文发送之后就进入 CLOSED 了,但 ACK 报文并没有到达服务端,这时服务端就会一直处于 LAST_ACK 状态,那么如果后续客户端再发起新的建立连接的 SYN 报文后,服务端就不会再返回 SYN + ACK 了,而是直接发送 RST 报文表示终止连接的建立。

因此客户端在发送完 ACK 之后不能直接 CLOSED,而是要等一段时间,如果服务端在发「 FIN 关闭连接报文」之后的 2MSL 没有收到来自客户端的 ACK 报文(发 FIN 加上收 ACK 最多 2MSL),那么服务端就知道这个 ACK 报文在网络中丢失了,此时会重新给客户端发送 FIN 报文。所以客户端要等待(此时处于 TIME_WAIT 状态),因为它不知道自己最后发送的 ACK 报文是否成功抵达服务端,它只知道服务端收不到 ACK 报文会再度给自己发送 FIN 报文,因此只能默默等待 2MSL(发送 ACK 加上当服务端收不到时返回 FIN 最多 2MSL)。如果再次收到服务端的 FIN,那么它要再次发送 ACK,但如果等了 2MSL 后,服务端没有再次发送 FIN,那么它就知道自己上一次发送的 ACK 被服务端成功接收了,此时也会进入 CLOSED 状态。至此,四次挥手完成,客户端和服务端之间连接断开。

为什么 TIME_WAIT 等待的时间是 2MSL?

MSL 是 Maximum Segment Lifetime,「报文最大生存时间」,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 TTL 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。

MSL 与 TTL 的区别: MSL 的单位是时间,而 TTL 是经过的路由跳数。所以 「MSL 应该要大于等于 TTL 消耗为 0 的时间」,以确保报文已被自然消亡。

TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是: 网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以「一来一回需要等待 2 倍的时间」。比如,如果被动关闭方没有收到主动断开连接的一方最后发送的 ACK 报文,就会触发超时重发 FIN 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方, 一来一去正好 2 个 MSL。

另外 2MSL 的时间是从客户端接收到 FIN 后发送 ACK 开始计时的,如果在 TIME_WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 2MSL 时间将重新计时。

那么问题来了,这个 2MSL 到底是多长时间呢?在 Linux 系统里 2MSL 默认是 60 秒,那么一个 MSL 就是 30 秒,所以 Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒。

TIME_WAIT 在 Linux 中是一个宏,位于 include/net/tcp.h 中,如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。

TIME_WAIT 过多有什么危害?

如果服务器有处于 TIME_WAIT 状态的 TCP,则说明是由服务器方主动发起的断开请求。而过多的 TIME_WAIT 状态的主要危害有两种:

  • 第一是内存资源占用;
  • 第二是对端口资源的占用,一个 TCP 连接至少消耗一个本地端口;

第二个危害是会造成严重的后果的,要知道端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过参数 net.ipv4.ip_local_port_range 设置指定。

总之,如果服务端 TIME_WAIT 状态过多,占满了所有端口资源,则会导致无法创建新连接。

如何优化 TIME_WAIT?

这里给出优化 TIME_WAIT 的几个方式,都是有利有弊:

  • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项
  • net.ipv4.tcp_max_tw_buckets
  • 程序中使用 SO_LINGER,应用强制使用 RST 关闭

方式一:打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps

下面这个 Linux 内核参数开启后,则可以让处于 TIME_WAIT 的 socket 为新的连接所用。

net.ipv4.tcp_tw_reuse = 1

使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即:

net.ipv4.tcp_timestamps=1(默认即为 1)

这个时间戳的字段是在 TCP 头部的「选项」里,用于记录 TCP 发送方的当前时间戳和从另一端接收到的最新时间戳。由于引入了时间戳,我们在前面提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。

但 net.ipv4.tcp_tw_reuse 要慎用,因为使用了它就必然要打开时间戳的支持 net.ipv4.tcp_timestamps,但如果客户端与服务端主机时间不同步,那么客户端的发送的消息会被直接拒绝掉。

方式二:net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将所有的 TIME_WAIT 连接状态重置,我们可以改变这个值。但这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。

方式三:程序中使用 SO_LINGER

我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。

struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));

如果 l_onoff 为非 0, 且 l_linger 值为 0,那么调用 close 后,会立该发送一个 RST 标志给另一端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。虽然这为跨越 TIME_WAIT 状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

struct linger 结构体非常简单,只有 l_onoff 和 l_linger 两个成员,其定义在 /Users/admin/Downloads/linux-5.9.16/include/linux/socket.h 中。

如果已经建立了连接,但是客户端突然出现故障了怎么办?

TCP 有一个机制是「保活机制」,这个机制的原理是这样的:定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。

在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:

# 表示保活时间是 7200 秒(2 小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制
net.ipv4.tcp_keepalive_time=7200
# 表示每次检测间隔 75 秒
net.ipv4.tcp_keepalive_intvl=75  
# 表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接
net.ipv4.tcp_keepalive_probes=9

也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。

显然这个时间是有点长的,我们也可以根据实际的需求,对以上的保活相关的参数进行设置。如果开启了 TCP 保活,需要考虑以下几种情况:

  • 第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给另一端, 另一端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来
  • 第二种,对端程序崩溃并重启。当 TCP 保活的探测报文发送给另一端后,另一端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置
  • 第三种,是对端程序崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给另一端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡

Socket 编程

socket 是什么我们已经说过了,下面来看看如何使用 socket 进行编程。

  • 服务端初始化 socket,此时会得到「主动套接字」;
  • 服务端调用 bind,将套接字绑定在 IP 地址和端口上;
  • 服务端调用 listen 进行监听,此时「主动套接字」会变成「监听套接字」;
  • 服务端调用 accept,等待客户端连接,此时服务端会阻塞在这里(调用的是阻塞的 API);
  • 客户端同样初始化 socket,得到主动套接字;
  • 客户端调用主动套接字的 connect,向服务器端发起连接请求,一旦连接成功,后续就用这个主动套接字进行数据的传输;
  • 一旦客户端连接,那么服务端的 accept 将不再阻塞,并返回「已连接套接字」,后续服务端便用这个已连接套接字和客户端进行数据的传输;
  • 如果客户端断开连接,那么服务端 read 读取数据的时候就会出现 EOF,知道客户端断开连接了。待数据处理完毕后,服务端也要调用 close 来关闭连接;

我们使用 Python 来演示一下这个过程,首先是服务端:

import socket

# 返回「主动套接字」,socket.AF_INET 表示使用 IPv4,socket.SOCK_STREAM 表示建立 TCP 连接
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 端口释放后一般等两分钟才能继续使用,这里设置成端口释放后立刻就能再次使用
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True)
# 将「主动套接字」绑定在某个 IP 和端口上
server.bind(("localhost", 12345))
# 监听,此时「主动套接字」会变成「监听套接字」
# 里面的参数表示 backlog,而该参数代表的含义后面说
server.listen(5)
# 调用 accept,等待客户端连接,此时会阻塞在这里
# 如果客户端连接到来,那么会返回「已连接套接字」,也就是这里的 conn
# 至于 addr 则保存了客户端连接的信息
conn, addr = server.accept()

while True:
    # 我们就通过 conn 和客户端进行消息的收发
    # 在 Python 里面收消息使用 recv、发消息使用 send,和 read、write 本质是一样的
    try:
        msg = conn.recv(1024).decode("utf-8")
        # 然后我们加点内容之后,再给客户端发过去
        conn.send(f"服务端收到, 你发的消息是: '{msg}'".encode("utf-8"))
    except Exception:
        print("客户端已退出, 断开连接")
        conn.close()
        break

接下来编写客户端:

import socket

# 返回主动套接字
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 连接服务端
client.connect(("localhost", 12345))
while True:
    # 发送消息
    data = input("请输入内容: ")
    if data.strip().lower() in ("q", "quit", "exit"):
        client.close()
        print("Bye~~~")
        break
    client.send(data.encode("utf-8"))
    print(client.recv(1024).decode("utf-8"))

启动服务端和客户端进行测试:

还是比较简单的,当然我们这里的服务端每次只能服务一个客户端,如果想服务多个客户端的话,那么需要为已连接套接字单独开一个线程和客户端进行通信,然后主线程继续执行 accept 等待下一个客户端。

listen 时候的参数 backlog 的意义?

Linux 内核中会维护两个队列:

  • 未完成连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态,其大小可以通过 net.ipv4.tcp_max_syn_backlog 参数进行修改;
  • 已完成连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态,其大小可以通过 backlog 参数指定;

在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,所以现在通常认为 backlog 是 accept 队列。

accept 发送在三次握手的哪一步?

回顾一下三次握手的规则:

  • 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 client_isn,客户端进入 SYNC_SENT 状态;
  • 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn + 1,表示对 SYN 包 client_isn 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务器端进入 SYNC_RCVD 状态;
  • 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 server_isn + 1;
  • 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态;

从上面的描述过程,我们可以得知客户端 connect 成功返回是在第二次握手之后,服务端 accept 成功返回是在第三次握手成功之后。

客户端调用 close 了,连接是断开的流程是什么?

过程分为如下:

  • 客户端调用 close,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态;
  • 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,应用程序可以通过 read 调用来感知这个 FIN 包。这个 EOF 会被放在已排队等候的其他已接收的数据之后,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态;
  • 接着,当处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字,这会使得会发出一个 FIN 包,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSED 状态;
  • 客户端经过 2MSL 时间之后,也进入 CLOSED 状态

如何提升三次握手的性能

上面了解了 TCP 在三次握手建立连接、四次握手关闭连接时是怎样的过程,这两个步骤中 TCP 连接经历了复杂的状态变化,既容易导致编程出错,也有很大的优化空间。这一讲我们看看在 Linux 操作系统下,如何优化 TCP 的三次握手流程,提升握手速度,顺便回顾一下三次握手。

TCP 是一个可以双向传输的全双工协议,所以需要经过三次握手才能建立连接。三次握手在一个 HTTP 请求中的平均时间占比在 10% 以上,在网络状况不佳、高并发或者遭遇 SYN 泛洪攻击等场景中,如果不能正确地调整三次握手中的参数,就会对性能有很大的影响。而 TCP 协议是由操作系统实现的,调整 TCP 必须通过操作系统提供的接口和工具,这就需要理解 Linux 是怎样把三次握手中的状态暴露给我们,以及通过哪些工具可以找到优化依据,并通过哪些接口修改参数。

因此接下来我们将介绍 TCP 握手过程中各状态的意义,并以状态变化作为主线,看看如何调整 Linux 参数才能提升握手的性能。

 

客户端的优化

客户端和服务端都可以针对三次握手优化性能,相对而言,主动发起连接的客户端优化相对简单一些,而服务端需要在监听端口上被动等待连接,并保存许多握手的中间状态,优化方法更为复杂一些。我们首先来看如何优化客户端。

我们知道三次握手建立连接的首要目的就是同步序列号,只有同步了序列号才有可靠的传输,TCP 协议的许多特性都是依赖序列号实现的,比如流量控制、消息丢失后的重发等等,这也是三次握手中的报文被称为 SYN 的原因,因为 SYN 的全称就叫做 Synchronize Sequence Numbers。

客户端会先发起 SYN 报文请求建立连接,然后服务端返回 SYN+ACK。正常情况下,服务端会在几毫秒内返回,但如果客户端迟迟没有收到来自服务端的 ACK 会怎么样呢?那么客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次。

net.ipv4.tcp_syn_retries = 6

第 1 次重试发生在 1 秒钟后,接着会以翻倍的方式在第 2、4、8、16、32 秒共做 6 次重试,最后一次重试会等待 64 秒,如果仍然没有返回 ACK,才会终止三次握手。所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。

因此,如果客户端连接的是一台有明确任务的服务器,你可以根据网络的稳定性和目标服务器的繁忙程度修改重试次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。

 

服务端的优化

当服务端收到 SYN 报文后,服务端会立刻回复 SYN+ACK 报文,既确认了客户端的序列号,也把自己的序列号发给了对方,此时服务端出会变成 SYN_RCVD 状态。但是注意:当收到 SYN 报文时,服务端必须建立一个 SYN 半连接队列来维护未完成的握手信息,当这个队列溢出后,服务端将无法再建立新连接。

因此,如果 SYN 半连接队列已满,那么后续 SYN 会被丢弃,可以通过 netstat -s | grep "SYNs to LISTEN" 查看队列溢出导致 SYN 被丢弃的个数。注意这是一个累计值,如果数值在持续增加,则应该调大 SYN 半连接队列。而修改半连接队列大小的方法,是设置 Linux 的 tcp_max_syn_backlog 参数:

net.ipv4.tcp_max_syn_backlog = 1024

但是问题来了,如果服务端在发送完 SYN+ACK 之后却又一直收不到客户端的 ACK,那么会怎么样呢?显然和客户端采取的策略一样,也是进行重试,会一直重发 SYN+ACK 报文。当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数,反之则可以调小重发次数。修改重发次数的方法是,调整 tcp_synack_retries 参数:

net.ipv4.tcp_synack_retries = 5

tcp_synack_retries 的默认重试次数是 5 次,与客户端重发 SYN 类似,它的重试会经历 1、2、4、8、16 秒,最后一次重试后等待 32 秒,若仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。如果服务端收到 ACK 后连接建立成功,此时,内核会把连接从 SYN 半连接队列中移出,再移入 Accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。

但实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1:

net.ipv4.tcp_abort_on_overflow = 0

通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。举个例子,当 Accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次重发。如果服务器上的进程只是短暂的繁忙造成 Accept 队列满,那么当 Accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 Accept 队列会长期溢出时,才能设置为 1 以尽快通知客户端。

那么怎样才能调整 Accept 队列的长度呢?listen 函数的 backlog 参数就可以设置 Accept 队列的大小。事实上,backlog 参数还受限于 Linux 系统级的队列长度上限,当然这个上限阈值也可以通过 somaxconn 参数修改。

net.core.somaxconn = 128

当下各监听端口上的 Accept 队列长度可以通过 ss -ltn 命令查看,但 Accept 队列长度是否需要调整该怎么判断呢?可以通过 netstat -s | grep "listen queue" 命令给出的统计结果,看到究竟有多少个连接因为队列溢出而被丢弃。如果持续不断地有连接因为 Accept 队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。

 

通过 TFO 技术绕过三次握手

以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。

首先我们说三次握手建立连接造成的后果就是,HTTP 请求必须在一次 RTT(Round Trip Time,从客户端到服务器一个往返的时间)后才能发送,Google 对此做的统计显示,三次握手消耗的时间,在 HTTP 请求完成的时间占比在 10% 到 30% 之间。因此,Google 提出了 TCP fast open 方案(简称 TFO),客户端可以在首个 SYN 报文中就携带请求,这节省了 1 个 RTT 的时间。接下来我们就来看看,TFO 具体是怎么实现的。

为了让客户端在 SYN 报文中携带请求数据,必须解决服务器的信任问题。因为此时服务器的 SYN 报文还没有发给客户端,客户端是否能够正常建立连接还未可知,但此时服务器需要假定连接已经建立成功,并把请求交付给进程去处理,所以服务器必须能够信任这个客户端。

那么 TFO 到底怎样达成这一目的呢?它把通讯分为两个阶段,第一阶段为首次建立连接,这时走正常的三次握手,但在客户端的 SYN 报文会明确地告诉服务器它想使用 TFO 功能,这样服务器会把客户端 IP 地址用只有自己知道的密钥加密(比如 AES 加密算法),作为 Cookie 携带在返回的 SYN+ACK 报文中,客户端收到后会将 Cookie 缓存在本地。之后,如果客户端再次向服务器建立连接,就可以在第一个 SYN 报文中携带请求数据,同时还要附带缓存的 Cookie。很显然,这种通讯方式下不能再采用经典的 "先 connect 再 write 请求" 这种编程方法,而要改用 sendto 或者 sendmsg 函数才能实现。

服务器收到后,会用自己的密钥验证 Cookie 是否合法,验证通过后连接才算建立成功,再把请求交给进程处理,同时给客户端返回 SYN+ACK。虽然客户端收到后还会返回 ACK,但服务器不等收到 ACK 就可以发送 HTTP 响应了,这就减少了握手带来的 1 个 RTT 的时间消耗。当然,为了防止 SYN 泛洪攻击,服务器的 TFO 实现必须能够自动化地定时更新密钥。

Linux 下怎么打开 TFO 功能呢?这要通过 tcp_fastopen 参数。由于只有客户端和服务器同时支持时,TFO 功能才能使用,所以 tcp_fastopen 参数是按比特位控制的。其中,当第 1 个比特位为 1 时,表示作为客户端时支持 TFO;当第 2 个比特位为 1 时,表示作为服务器时支持 TFO;当 tcp_fastopen 的值为 3 时(比特为 0x11)就表示完全支持 TFO 功能。

net.ipv4.tcp_fastopen = 3

如何提升四次挥手的性能

介绍完了建立连接时的优化方法,我们再来看四次挥手关闭连接时,如何优化性能,顺便回顾一下四次挥手。

首先在 Linux 中 close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。close 函数会让连接变为孤儿连接,shutdown 函数则允许在半关闭的连接上长时间传输数据。TCP 之所以具备这个功能,是因为它是全双工协议,但这也造成四次挥手非常复杂。

四次挥手中你可以用 netstat 命令观察到 6 种状态,其中你应该多半看到过 TIME_WAIT 状态。网上有许多文章介绍怎样减少 TIME_WAIT 状态连接的数量,也有文章说 TIME_WAIT 状态是必不可少、不能优化掉的。这两种看似自相矛盾的观点之所以存在,就在于优化连接关闭时,不能仅基于主机端的视角,还必须站在整个网络的层次上,才能给出正确的解决方案。

Linux 为四次挥手提供了很多控制参数,有些参数的名称与含义并不相符。例如 tcp_orphan_retries 参数中有 orphan 孤儿,却同时对非孤儿连接也生效。而且,错误地配置这些参数,不只无法针对高并发场景提升性能,还会降低资源的使用效率,甚至引发数据错误。那么下面我们就基于四次挥手的流程,介绍 Linux 下的优化方法。

我们说过建立连接是三次握手,关闭连接需要四次挥手。这是因为 TCP 不允许连接处于半打开状态时就单向传输数据,所以在三次握手建立连接时,服务器会把 ACK 和 SYN 放在一起发给客户端,其中 ACK 用来打开客户端的发送通道,SYN 用来打开服务端的发送通道。这样,原本的四次握手就降为三次握手了。

但是当连接处于半关闭状态时,TCP 是允许单向传输数据的。为便于下文描述,接下来我们把先关闭连接的一方叫做主动方,后关闭连接的一方叫做被动方。当主动方关闭连接时,被动方仍然可以在不调用 close 函数的状态下,长时间发送数据,此时连接处于半关闭状态。这一特性是 TCP 的双向通道互相独立所致,却也使得关闭连接必须通过四次挥手才能做到。

互联网中往往服务端才是主动关闭连接的一方,这是因为 HTTP 消息是单向传输协议,服务端接收完请求才能生成响应,发送完响应后就会立刻关闭 TCP 连接,这样及时释放了资源,能够为更多的用户服务。但这也使得服务器的优化策略变得复杂起来。一方面,由于被动方有多种应对策略,从而增加了主动方的处理分支。另一方面,服务器同时为成千上万个用户服务,任何错误都会被庞大的用户数放大。所以对主动方的关闭连接参数调整时,需要格外小心。

 

主动方的优化

关闭连接有多种方式,比如进程异常退出时,针对它打开的连接,内核就会发送 RST 报文来关闭。RST 的全称是 Reset 复位的意思,它可以不走四次挥手强行关闭连接,但当报文延迟或者重复传输时,这种方式会导致数据错乱,所以这是不得已而为之的关闭连接方案。

安全关闭连接的方式必须通过四次挥手,它由进程调用 close 或者 shutdown 函数发起,这二者都会向对方发送 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN),区别在于 close 调用后,哪怕对方在半关闭状态下发送的数据到达主动方,进程也无法接收。

此时这个连接叫做孤儿连接,如果你用 netstat -p 命令,会发现连接对应的进程名为空。而 shutdown 函数调用后,即使连接进入了 FIN_WAIT1 或者 FIN_WAIT2 状态,它也不是孤儿连接,进程仍然可以继续接收数据。主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态下,该状态通常应在数十毫秒内转为 FIN_WAIT2。只有迟迟收不到对方返回的 ACK 时,才能用 netstat 命令观察到 FIN_WAIT1 状态。此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0,但是代表 8 次。

net.ipv4.tcp_orphan_retries = 0

如果 FIN_WAIT1 状态连接有很多,你就需要考虑降低 tcp_orphan_retries 的值,当重试次数达到 tcp_orphan_retries 时,连接就会直接关闭掉。对于正常情况来说,调低 tcp_orphan_retries 已经够用,但如果遇到恶意攻击,FIN 报文根本无法发送出去。这是由 TCP 的 2 个特性导致的:

  • 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没发送时,FIN 报文也不能提前发送。
  • 其次,TCP 有流控功能,当接收方将接收窗口设为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过将接收窗口设为 0,导致 FIN 报文无法发送,进而导致连接一直处于 FIN_WAIT1 状态。

解决这种问题的方案是调整 tcp_max_orphans 参数:net.ipv4.tcp_max_orphans = 16384。

顾名思义,tcp_max_orphans 定义了孤儿连接的最大数量。当进程调用 close 函数关闭连接后,无论该连接是在 FIN_WAIT1 状态,还是确实关闭了,这个连接都与该进程无关了,它变成了孤儿连接。Linux 系统为防止孤儿连接过多,导致系统资源长期被占用,就提供了 tcp_max_orphans 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。

当连接收到 ACK 进入 FIN_WAIT2 状态后,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。这时,如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态。但对于 close 函数关闭的孤儿连接,这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长。

net.ipv4.tcp_fin_timeout = 60

它的默认值是 60 秒。这意味着对于孤儿连接,如果 60 秒后还没有收到 FIN 报文,连接就会直接关闭。这个 60 秒并不是拍脑袋决定的,它与接下来介绍的 TIME_WAIT 状态的持续时间是相同的,我们稍后再来回答 60 秒的由来。

TIME_WAIT 是主动方四次挥手的最后一个状态,当收到被动方发来的 FIN 报文时,主动方回复 ACK,表示确认对方的发送通道已经关闭,连接随之进入 TIME_WAIT 状态,等待 60 秒后关闭,为什么呢?我们必须站在整个网络的角度上,才能回答这个问题。TIME_WAIT 状态的连接,在主动方看来确实已经关闭了。然而,被动方没有收到 ACK 报文前,连接还处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。

如果主动方不保留 TIME_WAIT 状态,会发生什么呢?此时连接的端口恢复了自由身,可以复用于新连接了。然而,被动方的 FIN 报文可能再次到达,这既可能是网络中的路由器重复发送,也有可能是被动方没收到 ACK 时基于 tcp_orphan_retries 参数重发。这样,正常通讯的新连接就可能被重复发送的 FIN 报文误关闭。保留 TIME_WAIT 状态,就可以应付重发的 FIN 报文,当然,其他数据报文也有可能重发,所以 TIME_WAIT 状态还能避免数据错乱。

我们再回过头来看看,为什么 TIME_WAIT 状态要保持 60 秒呢?这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间(报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。

为什么是 2 MSL 的时长呢?这其实是相当于至少允许报文丢失一次。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。

虽然 TIME_WAIT 状态的存在是有必要的,但它毕竟在消耗系统资源,比如 TIME_WAIT 状态的端口就无法供新连接使用。怎样解决这个问题呢?Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭。

net.ipv4.tcp_max_tw_buckets = 5000

当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 tcp_max_tw_buckets 参数,减少不同连接间数据错乱的概率。当然,tcp_max_tw_buckets 也不是越大越好,毕竟内存和端口号都是有限的。有没有办法让新连接复用 TIME_WAIT 状态的端口呢?如果服务器会主动向上游服务器发起连接的话,就可以把 tcp_tw_reuse 参数设置为 1,它允许作为客户端的新连接,在安全条件下使用 TIME_WAIT 状态下的端口。

net.ipv4.tcp_tw_reuse = 1

当然,要想使 tcp_tw_reuse 生效,还得把 timestamps 参数设置为 1,它满足安全复用的先决条件(对方也要打开 tcp_timestamps ):

net.ipv4.tcp_timestamps = 1

老版本的 Linux 还提供了 tcp_tw_recycle 参数,它并不要求 TIME_WAIT 状态存在 60 秒,很容易导致数据错乱,不建议设置为 1。

net.ipv4.tcp_tw_recycle = 0

所以在 Linux 4.12 版本后,直接取消了这一参数。

 

被动方的优化

当被动方收到 FIN 报文时,就开启了被动方的四次挥手流程。内核自动回复 ACK 报文后,连接就进入 CLOSE_WAIT 状态,顾名思义,它表示等待进程调用 close 函数关闭连接。

但内核没有权力替代进程去关闭连接,因为若主动方是通过 shutdown 关闭连接,那么它就是想在半关闭连接上接收数据,因此 Linux 并没有限制 CLOSE_WAIT 状态的持续时间。

当然,大多数应用程序并不使用 shutdown 函数关闭连接,所以,当你用 netstat 命令发现大量 CLOSE_WAIT 状态时,要么是程序出现了 Bug,read 函数返回 0 时忘记调用 close 函数关闭连接,要么就是程序负载太高,close 函数所在的回调函数被延迟执行了。此时,我们应当在应用代码层面解决问题。

由于 CLOSE_WAIT 状态下,连接已经处于半关闭状态,所以此时进程若要关闭连接,只能调用 close 函数(再调用 shutdown 关闭单向通道就没有意义了),内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。

如果迟迟等不到 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动方重发 FIN 报文的优化策略一致。

至此,由一方主动发起四次挥手的流程就介绍完了。需要你注意的是,如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。

我们再来看一种特例,如果连接双方同时关闭连接,会怎么样?

此时,上面介绍过的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。

接下来,双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态。此时,内核回复 ACK 确认对方发送通道的关闭,仅己方的 FIN 报文对应的 ACK 还没有收到。所以,CLOSING 状态与 LAST_ACK 状态下的连接很相似,它会在适时重发 FIN 报文的情况下最终关闭。

小结

四次挥手的主动方,为了应对丢包,允许在 tcp_orphan_retries 次数内重发 FIN 报文。当收到 ACK 报文,连接就进入了 FIN_WAIT2 状态,此时系统的行为依赖这是否为孤儿连接。

如果这是 close 函数关闭的孤儿连接,那么在 tcp_fin_timeout 秒内没有收到对方的 FIN 报文,连接就直接关闭,反之 shutdown 函数关闭的连接则不受此限制。毕竟孤儿连接可能在重发次数内存在数分钟之久,为了应对孤儿连接占用太多的资源,tcp_max_orphans 定义了最大孤儿连接的数量,超过时连接就会直接释放。

当接收到 FIN 报文,并返回 ACK 后,主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟,为了防止 TIME_WAIT 状态占用太多的资源,tcp_max_tw_buckets 定义了最大数量,超过时连接也会直接释放。当 TIME_WAIT 状态过多时,还可以通过设置 tcp_tw_reuse 和 tcp_timestamps 为 1 ,将 TIME_WAIT 状态的端口复用于作为客户端的新连接。

被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等来 ACK 时,会在 tcp_orphan_retries 参数的控制下重发 FIN 报文。

TCP 重传、滑动窗口、流量控制、拥塞控制

我们说 TCP 是一个可靠传输的协议,而为了实现可靠性传输,需要考虑很多事情,例如数据的破坏、丢包、重复以及分片顺序混乱等问题。如不能解决这些问题,也就无从谈起可靠传输。

而解决这些问题,依赖的就是序列号、确认应答、重发控制、连接管理以及窗口控制等机制,下面我们来分别重点介绍。

重传机制

TCP 实现可靠传输的方式之一,是通过序列号与确认应答号。在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。

但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢?所以 TCP 针对数据包丢失的情况,会用重传机制解决。

接下来说说常见的重传机制,总共有四种:

  • 超时重传
  • 快速重传
  • SACK
  • D-SACK

超时重传

重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 ACK 确认应答报文,就会重发该数据,也就是我们常说的超时重传

TCP 会在以下两种情况发生超时重传:

  • 数据包丢失
  • 确认应答丢失

那么问题来了,超时时间应该设置为多少呢?

我们需要先来了解一下什么是 RTT(Round-Trip Time 往返时延),从下图我们就可以知道:

RTT 就是数据从网络一端传送到另一端所需的时间,也就是包的往返时间。

而超时重传时间是以 RTO(Retransmission Timeout 超时重传时间)表示,假设在重传的情况下,超时时间 RTO「较长或较短」时,会发生什么事情呢?

上图中有两种超时时间不同的情况:

  • 当超时时间 RTO 较大时,重发就慢,丢了老半天才重发,没有效率,性能差;
  • 当超时时间 RTO 较小时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发;

精确的测量超时时间 RTO 的值是非常重要的,这可让我们的重传机制更高效。而根据上述的两种情况,我们可以得知,超时重传时间 RTO 的值应该略大于报文往返 RTT 的值

至此,可能大家觉得超时重传时间 RTO 的值计算,也不是很复杂嘛。好像就是在发送端发包时记下 t0 ,然后接收端再把这个 ack 回来时再记一个 t1,于是 "RTT = t1 – t0"。其实没那么简单,这只是一个采样,不能代表普遍情况

因为我们的网络也是时常变化的,导致「报文往返 RTT 的值」 也是经常波动变化的,所以「超时重传时间 RTO 的值」也应该是一个动态变化的值

我们来看看 Linux 是如何计算 RTO 的。

估计往返时间,通常需要采样以下两个:

  • 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化;
  • 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况;

RFC6289 建议使用以下的公式计算 RTO:

其中 SRTT 是计算平滑的 RTT ,DevRTR 是计算平滑的 RTT 与最新 RTT 的差距。在 Linux 下,α = 0.125,β = 0.25, μ = 1,∂ = 4。别问怎么来的,问就是大量实验中调出来的。

如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是超时间隔加倍

也就是每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍,因为两次超时,就说明网络环境差,不宜频繁反复发送

超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?于是就有了「快速重传」机制,来解决超时重发的时间等待。

快速重传

TCP 还有另外一种快速重传(Fast Retransmit)机制,它不以时间为驱动,而是以数据驱动进行重传

快速重传机制,是如何工作的呢?其实很简单,一图胜千言。

在上图,发送方发出了 1,2,3,4,5 份数据:

  • 第一份 Seq1 先送到了,于是就 Ack 回 2;
  • 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2;
  • 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到;
  • 发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2;
  • 最后,接收到收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6 ;

所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。因此快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是重传的时候,是重传之前的一个,还是重传所有的问题

比如对于上面的例子,是重传 Seq2 呢?还是重传 Seq2、Seq3、Seq4、Seq5 呢?因为发送端并不清楚这连续的三个 Ack 2 是谁传回来的。而根据 TCP 不同的实现,以上两种情况都是有可能的,可见这是一把双刃剑。为了解决不知道该重传哪些 TCP 报文,于是就有了 SACK 方法。

SACK 方法

还有一种实现重传机制的方式叫:SACK(Selective Acknowledgment 选择性确认)。

这种方式需要在 TCP 头部「选项」字段里加一个 SACK 的东西,它可以将缓存的数据信息发送给发送方,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以只重传丢失的数据

如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

Duplicate SACK

Duplicate SACK 又称 D-SACK,其主要使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。下面举例两个栗子,来说明 D-SACK 的作用。

 

栗子一号:ACK 丢包

如果要支持 SACK,必须双方都要支持。在 Linux 下,可以通过 net.ipv4.tcp_sack 参数打开这个功能(Linux 2.4 后默认打开)。

  • 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499)
  • 于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000~3500,告诉「发送方」 3000~3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 D-SACK。
  • 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。

 

栗子二号:网络延时

  • 数据包(1000~1499) 被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。
  • 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」。
  • 所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包
  • 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。

可见,D-SACK 有这么几个好处:

  • 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了;
  • 可以知道是不是「发送方」的数据包被网络延迟了;
  • 可以知道网络中是不是把「发送方」的数据包给复制了;

在 Linux 下可以通过 net.ipv4.tcp_dsack 参数开启/关闭这个功能(Linux 2.4 后默认打开)。

滑动窗口

我们都知道 TCP 是每发送一个数据,都要进行一次确认应答,也就是当上一个数据包收到了应答了, 再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话呢?很显然这不现实。

所以,这样的传输方式有一个缺点:数据包的往返时间越长,通信的效率就越低。为解决这个问题,TCP 引入了窗口这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。

那么有了窗口,就可以指定窗口大小,窗口大小就是指无需等待确认应答,而可以继续发送数据的最大值

窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。假设窗口大小为 3 个 TCP 段,那么发送方就可以「连续发送」3 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图:

图中的 ACK 600 确认应答报文丢失也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。只不过 ACK 600 这个报文在返回的时候迷失在了网络中,但接收方确实收到了,而这个模式就叫累计确认或者累计应答

窗口大小由哪一方决定?

TCP 头里有一个字段叫 Window,也就是窗口大小。这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。

所以,通常窗口的大小是由接收方的决定的。发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。

发送方的滑动窗口

我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口:

  • #1 是已发送并收到 ACK确认的数据:1~31 字节
  • #2 是已发送但未收到 ACK确认的数据:32~45 字节
  • #3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51字节
  • #4 是未发送但总大小超过接收方处理范围(接收方没有空间):52字节以后

在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。

然后假设收到之前发送的数据(32~36,共 5 字节)的 ACK 确认应答,如果发送窗口的大小没有变化,则滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认,接下来 52~56 字节又变成了可用窗口,那么后续也就可以发送 52~56 这 5 个字节的数据了。

程序是如何表示发送方的四个部分的呢?

TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中每一个类别的字节,其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。

  • SND.WND:表示发送窗口的大小(大小是由接收方指定的)
  • SND.UNA:是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节
  • SND.NXT:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节
  • 指向 #4 的第一个字节是个相对指针,它需要 SND.UNA 指针加上 SND.WND 大小的偏移量,就可以指向 #4 的第一个字节了

那么可用窗口大小的计算就可以是:可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)

接收方的滑动窗口

接下来我们看看接收方的窗口,接收窗口相对简单一些,根据处理的情况划分成三个部分:

  • #1 + #2 是已成功接收并确认的数据(等待应用进程读取);
  • #3 是未收到数据但可以接收的数据;
  • #4 未收到数据并不可以接收的数据;

其中三个接收部分,使用两个指针进行划分:

  • RCV.WND:表示接收窗口的大小,它会通告给发送方
  • RCV.NXT:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节
  • 指向 #4 的第一个字节是个相对指针,它需要 RCV.NXT 指针加上 RCV.WND 大小的偏移量,就可以指向 #4 的第一个字节了

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

并不是完全相等,接收窗口的大小是约等于发送窗口的大小的。

因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。

流量控制

发送方不能无脑的发数据给接收方,要考虑接收方处理能力。如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。

为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制

下面举个栗子,为了简单起见,假设以下场景:

  • 客户端是接收方,服务端是发送方
  • 假设接收窗口和发送窗口相同,都为 200
  • 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响

根据上图的流量控制,说明下每个过程:

  • 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口
  • 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 Usable 减少为 120 字节,同时 SND.NXT 指针也向右偏移 80 字节后,指向 321,这意味着下次发送数据的时候,序列号是 321
  • 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节,RCV.NXT 也就指向 321,这意味着客户端期望的下一个报文的序列号是 321,接着发送确认报文给服务端
  • 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法在继续发送数据
  • 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节,RCV.NXT 也就指向 441,接着发送确认报文给服务端
  • 服务端收到对 80 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 321,于是可用窗口 Usable 增大到 80
  • 服务端收到对 120 字节数据的确认报文后,SND.UNA 指针往右偏移后指向 441,于是可用窗口 Usable 增大到 200
  • 服务端可以继续发送了,于是发送了 160 字节的数据后,SND.NXT 指向 601,于是可用窗口 Usable 减少到 40
  • 客户端收到 160 字节后,接收窗口往右移动了 160 字节,RCV.NXT 也就是指向了 601,接着发送确认报文给服务端
  • 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 SND.UNA 指针偏移了 160 后指向 601,可用窗口 Usable 也就增大至了 200

操作系统缓冲区与滑动窗口的关系

前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会被操作系统调整

当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。

那操心系统的缓冲区,是如何影响发送窗口和接收窗口的呢?

我们先来看看第一个例子,当应用程序没有及时读取缓存时,发送窗口和接收窗口的变化。考虑以下场景:

  • 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 360
  • 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据

根据上图的流量控制,说明下每个过程:

  • 客户端发送 140 字节数据后,可用窗口变为 220 (360 - 140)
  • 服务端收到 140 字节数据,但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260 (360 - 100),最后发送确认信息时,将窗口大小通过给客户端
  • 客户端收到确认和窗口通告报文后,发送窗口减少为 260
  • 客户端发送 180 字节数据,此时可用窗口减少到 80
  • 服务端收到 180 字节数据,但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80 (260 - 180),并在发送确认信息时,通过窗口大小给客户端
  • 客户端收到确认和窗口通告报文后,发送窗口减少为 80
  • 客户端发送 80 字节数据后,可用窗口耗尽
  • 服务端收到 80 字节数据,但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,于是接收窗口收缩到了 0,并在发送确认信息时,通过窗口大小给客户端
  • 客户端收到确认和窗口通告报文后,发送窗口减少为 0

可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会说,这里先简单提一下。

我们再来看看第二个例子,当服务端系统资源非常紧张的时候,操心系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。

说明下每个过程:

  • 客户端发送 140 字节的数据,于是可用窗口减少到了 220
  • 服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 100 字节,当收到 对 140 数据确认报文后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100,最后发送确认信息时,通告窗口大小给对方
  • 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40
  • 服务端收到了 180 字节数据时,发现数据大小超过了接收窗口的大小,于是就把数据包丢失了
  • 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值

所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。

为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间在减少缓存,这样就可以避免了丢包情况。

窗口关闭

在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。

 

窗口关闭潜在的危险

接收方向发送方通告窗口大小时,是通过 ACK 报文来通告的。那么当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。

这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不不采取措施,这种相互等待的过程,会造成了死锁的现象。

 

TCP 是如何解决窗口关闭时,潜在的死锁现象呢?

为了解决这个问题,TCP 为每个连接设有一个持续定时器,只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。如果持续计时器超时,就会发送窗口探测 ( Window probe ) 报文,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。

  • 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器
  • 如果接收窗口不是 0,那么死锁的局面就可以被打破了

窗口探查探测的次数一般为 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 RST 报文来中断连接。

糊涂窗口综合症

如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。到最后如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症

要知道,我们的 TCP + IP 头有 40 个字节,为了传输那几个字节的数据,要达上这么大的开销,这太不经济了。就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。

现举个糊涂窗口综合症的栗子,考虑一个场景,接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下:

  • 接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据
  • 在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节

每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。所以,糊涂窗口综合症的现象是可以发生在发送方和接收方:

  • 接收方可以通告一个小的窗口
  • 而发送方可以发送小数据

于是,要解决糊涂窗口综合症,就解决上面两个问题就可以了:

  • 让接收方不通告小窗口给发送方
  • 让发送方避免发送小数据

 

怎么让接收方不通告小窗口呢?

接收方通常的策略如下:

当「窗口大小」小于 min(MSS,缓存空间 / 2),也就是小于 MSS 和 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 0,也就阻止了发送方再发数据过来。等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。

 

怎么让发送方避免发送小数据呢?

发送方通常的策略是使用 Nagle 算法,该算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:

  • 要等到窗口大小 >= MSS 或是 数据大小 >= MSS
  • 收到之前发送数据的 ack 回包

只要没满足上面条件中的一条,发送方一直在囤积数据,直到满足上面的发送条件。另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。可以在 Socket 设置 TCP_NODELAY 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)。

setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int));

拥塞控制

为什么要有拥塞控制呀,不是有流量控制了吗?前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。

在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大….

所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发送拥塞时,TCP 会自我牺牲,降低发送的数据量。于是,就有了拥塞控制,控制的目的就是避免「发送方」的数据填满整个网络

为了在「发送方」调节所要发送数据的量,定义了一个叫做「拥塞窗口」的概念。

 

什么是拥塞窗口?和发送窗口有什么关系呢?

拥塞窗口 cwnd是发送方维护的一个 的状态变量,它会根据网络的拥塞程度动态变化的

我们在前面提到过发送窗口 swnd 和接收窗口 rwnd 是约等于的关系,那么由于入了拥塞窗口的概念后,此时发送窗口的值是 swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。

拥塞窗口 cwnd 变化的规则:

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

 

那么怎么知道当前网络是否出现了拥塞呢?

其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是发生了超时重传,就会认为网络出现了用拥塞

 

拥塞控制有哪些控制算法?

拥塞控制主要是四个算法:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

慢启动

TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗?

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

这里假定拥塞窗口 cwnd 和发送窗口 swnd 相等,下面举个栗子:

  • 连接建立完成后,一开始初始化 cwnd = 1,表示可以传一个 MSS 大小的数据
  • 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个
  • 当收到 2 个的 ACK 确认应答后, cwnd 增加 2,于是就可以比之前多发2 个,所以这一次能够发送 4 个
  • 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1, 4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个

可以看出慢启动算法,发包的个数是指数性的增长

那慢启动涨到什么时候是个头呢?有一个叫慢启动门限 ssthresh(slow start threshold)状态变量。

  • 当 cwnd < ssthresh 时,使用慢启动算法
  • 当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」

拥塞避免算法

前面说道,当拥塞窗口 cwnd「超过」慢启动门限 ssthresh 就会进入拥塞避免算法。一般来说 ssthresh 的大小是 65535 字节,那么进入拥塞避免算法后,它的规则是:每当收到一个 ACK 时,cwnd 增加 1/cwnd

接上前面的慢启动的栗子,现假定 ssthresh 为 8。当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 MSS 大小的数据,变成了线性增长

所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。当触发了重传机制,也就进入了「拥塞发生算法」。

拥塞发生

当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种:

  • 超时重传
  • 快速重传

这两种使用的拥塞发送算法是不同的,接下来分别来说说。

1)发生超时重传的拥塞发生算法

当发生了「超时重传」,则就会使用拥塞发生算法。这个时候,sshresh 和 cwnd 的值会发生变化:

  • ssthresh 设为 cwnd/2
  • cwnd 重置为 1

接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。就好像本来在秋名山高速漂移着,突然来个紧急刹车,轮胎受得了吗。。。

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

还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 ssthresh 和 cwnd 变化如下:

  • cwnd = cwnd/2 ,也就是设置为原来的一半
  • ssthresh = cwnd
  • 进入快速恢复算法

快速恢复

快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 RTO 超时那么强烈。正如前面所说,进入快速恢复之前,cwnd 和 ssthresh 已被更新了:

  • cwnd = cwnd/2 ,也就是设置为原来的一半
  • ssthresh = cwnd

然后,进入快速恢复算法如下:

  • 拥塞窗口 cwnd = ssthresh + 3( 3 的意思是确认有 3 个数据包被收到了)
  • 重传丢失的数据包
  • 如果再收到重复的 ACK,那么 cwnd 增加 1
  • 如果收到新数据的 ACK 后,设置 cwnd 为 ssthresh,接着就进入了拥塞避免算法

也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。

posted @ 2020-05-22 13:13  古明地盆  阅读(1540)  评论(0编辑  收藏  举报