深度解密 TCP 三次握手与四次挥手

本文涉及的产品
容器服务 Serverless 版 ACK Serverless,317元额度 多规格
容器服务 Serverless 版 ACK Serverless,952元额度 多规格
简介: 深度解密 TCP 三次握手与四次挥手

楔子



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

所以如果想成为一个高手的话,那么 TCP/IP 协议是必须要了解的。特别是大厂,相比框架,面试官更喜欢问底层的 TCP/IP 协议。那么接下来我们就来看看 TCP/IP 中的 TCP,以及三次握手和四次挥手到底是怎么一回事。


啥是 TCP



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

这里面有三个关键词,分别解释一下。

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

那么 TCP 传输的数据格式长什么样子呢?

以上就是 TCP 的报文格式,相比 HTTP 报文(Header + Body),TCP 报文就显得复杂许多。里面有几个字段很重要:

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

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

3)控制位,有以下几个可选值。

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

这些字段在后续介绍三次握手的时候,就会彻底明白是做什么用的。

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

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

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

什么是 TCP 连接?

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

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

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

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

TCP 四元组可以唯一确定一个连接,四元组包括:源地址、源端口、目的地址、目的端口。其中源地址目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机;源端口目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 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 个字节,并且是固定不变的,开销较小。

6)应用场景:由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于 FTP 文件传输、HTTP / HTTPS;由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于包总量较少的通信(如 DNS、SNMP )、视频和音频等多媒体通信、广播通信。

再来思考一个问题,我们知道报文由首部(或者说头部)+ 数据组成,但如果对比 TCP 和 UDP 报文,会发现 TCP 头部里面包含了一个首部长度字段,而 UDP 却没有,这是为什么呢?其实很简单,原因是 TCP 有可变长的「选项」字段,所以 TCP 报文的头部长度不固定,因此需要额外记录;但 UDP 的头部长度则是不会变化的,因此无需多一个字段去记录。

还有一个问题,为什么 TCP 里面没有包长度这个字段,而 UDP 却有呢?要回答这个问题,需要先说说 TCP 如何计算负载数据的长度:

数据从应用层开始自上而下传输的时候,每一层都会加上该层的头部信息,比如 TCP 报文在经过 IP 层传输的时候,会加上一个 IP 头部,得到 IP 报文,就是一个不断封装的过程。

所以 IP报文总长 - IP头部长度,得到的就是 TCP 报文长度(TCP头部+TCP数据)。如果再减去 TCP 头部长度,就是 TCP 数据长度,显然它又是上一层的报文长度(头部+数据)。

由于 IP 报文长度 和 IP 首部长度,在 IP 首部是已知的,而 TCP 首部长度,在 TCP 首部也是已知的,所以就可以求得 TCP 数据的长度。既然可以求得,那完全没必要用一个字段,单独保存它。

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

其实 UDP 包长度确实是冗余的,但为了网络设备硬件设计和处理方便,首部长度需要是 4 字节的整数倍。如果去掉包长度字段,那 UDP 首部长度就不是 4 字节的整数倍了,所以补充了包长度字段。


TCP 连接是如何建立的



TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而建立连接是通过三次握手进行的。整个过程的示意图如下:

注:图中有一处写错了,右侧的 SYS_RCVD 属于笔误,应该是 SYN_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 是不相同的,因为网络中的报文「会延迟、会复制重发、也有可能丢失」,这样会造成的不同连接之间产生互相影响。所以为了避免互相影响,客户端和服务端的初始序列号是随机且不同的。

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

我们先来认识下 MTU 和 MSS:

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

当我们想发送一些 TCP 数据时,它要和 TCP 头部组合起来,形成 TCP 报文。然后 TCP 报文再进入网络层,和 IP 头部组合起来,形成 IP 报文,因为每一层都会加上自己的头部信息。而网络层每次发送的最大数据量是 1500 字节(被称为 MTU),MTU 减去 IP 和 TCP 的头部长度之后,就是 TCP 数据的最大长度(被称为 MSS)。

假设现在要传输的 TCP 数据的长度大于 MSS,会有什么结果呢?显然此时网络层要传输的 IP 报文会大于 MTU,那么 IP 层就要进行分片,把数据分成若干片,保证每一个分片都小于 MTU。将一个个分片传过去之后,再由目标主机的网络层来进行重新组装,并交给上一层的 TCP 传输层。

这看起来井然有序,但其实存在隐患,如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传。因为 IP 层本身没有超时重传机制,当 IP 分片出现丢失时,那么接收方拿到的 TCP 报文也是不完整的,因此不会响应 ACK 给对方。而发送方就要一直等待,直到 TCP 超时,而超时的后果就是重发整个 TCP 报文。

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

并且 TCP 是有超时重传机制的,如果一个 TCP 分片丢失,重发时也是以 MSS 为单位,而不用重传所有的分片,大大增加了重传的效率。

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

我们都知道 TCP 建立连接需要三次握手,假设攻击者短时间伪造不同 IP 地址的 SYN 报文,而服务端每接收到一个 SYN 报文,就会回复一个 ACK + SYN 报文,并进入SYN_RCVD 状态。

但如果服务端发送出去的 ACK + SYN 报文,无法得到未知 IP 主机的 ACK 应答,久而久之就会占满服务端的 SYN 接收队列(未连接队列),使得服务器不能为正常用户服务。

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

避免 SYN 攻击方式第一种:

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

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

避免 SYN 攻击方式第二种:

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

正常流程:

  • 当服务端接收到客户端的 SYN 报文时,会将连接加入到内核的「SYN 队列」;
  • 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文;
  • 服务端接收到 ACK 报文后,将连接从「SYN 队列」移除并放入到「Accept 队列」;
  • 应用通过调用 socket 接口 accpet(),从「Accept 队列」里面取出连接(所以 socket 连接建立成功一定是发生在 TCP 三次握手之后);

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

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

这是 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 半连接队列放不下时,再启用它。

另外 tcp_syncookie 仅用于应对 SYN 泛洪攻击(攻击者恶意构造大量的 SYN 报文发送给服务器,造成 SYN 半连接队列溢出,导致正常客户端的连接无法建立),因为这种方式建立的连接,许多 TCP 特性都无法使用。所以应当把 tcp_syncookies 设置为 1,仅在 SYN 队列满的时候启用。

以上就是 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 关闭连接报文」之后的规定时间内没有收到来自客户端的 ACK 报文,那么服务端就知道这个 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 报文给被动关闭方,最多需要 1MSL,但如果被动关闭方在 1MSL 内没有收到,就会触发超时并向主动断开连接方重发 FIN 报文,这又是 1MSL,总共正好 2 个 MSL。

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

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

TIME_WAIT 是一个宏,位于 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 关闭;

下面分别解释一下。

第一种方式:

下面这个 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 连接状态重置,我们可以改变这个值。但这个方法过于暴力,而且治标不治本,带来的问题远比解决的问题多,不推荐使用。

第三种方式:

我们可以通过设置 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 两个成员,其定义在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 保活,需要考虑以下几种情况:

1)对端程序是正常工作的。当 TCP 保活的探测报文发送给另一端,另一端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。

2)对端程序崩溃并重启。当 TCP 保活的探测报文发送给另一端后,另一端是可以响应的,但由于没有该连接的有效信息,会产生一个 RST 报文,这样很快就会发现 TCP 连接已经被重置。

3)对端程序崩溃,或对端由于其他原因导致报文不可达。当 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 的意义?

根据上面的 socket 流程图,我们可以得知 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,然后客户端进入 SYN_SENT 状态;
  • 服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn + 1,表示对 SYN 包 client_isn 的确认。同时服务端也发送一个 SYN 包,告诉客户端当前发送的序列号为 server_isn,然后服务端进入 SYN_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 状态;
  • 当然服务端在收到 FIN 报文后,也要返回一个 ACK,表示同意断开连接。只不过目前还没法断,因为还有数据没处理完,而客户端在收到 ACK 后,状态会变成 FIN_WAIT_2;
  • 接着当服务端处理完数据后,自然就会读到 EOF,于是也调用 close 关闭它的套接字。此时服务端会发出一个 FIN 包,之后处于 LAST_ACK 状态;
  • 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态;
  • 服务端收到 ACK 确认包后,就进入了最后的 CLOSED 状态;
  • 客户端经过 2MSL 时间之后,也进入 CLOSED 状态


小结



以上就是 TCP 的三次握手和四次挥手,在面试中是非常容易被问的点,我们要明白这背后的整个过程。此外三次握手和四次挥手还存在可以优化的地方,我们以后再说。


本文转载自公众号《小林coding》

相关实践学习
通过Ingress进行灰度发布
本场景您将运行一个简单的应用,部署一个新的应用用于新的发布,并通过Ingress能力实现灰度发布。
容器应用与集群管理
欢迎来到《容器应用与集群管理》课程,本课程是“云原生容器Clouder认证“系列中的第二阶段。课程将向您介绍与容器集群相关的概念和技术,这些概念和技术可以帮助您了解阿里云容器服务ACK/ACK Serverless的使用。同时,本课程也会向您介绍可以采取的工具、方法和可操作步骤,以帮助您了解如何基于容器服务ACK Serverless构建和管理企业级应用。 学习完本课程后,您将能够: 掌握容器集群、容器编排的基本概念 掌握Kubernetes的基础概念及核心思想 掌握阿里云容器服务ACK/ACK Serverless概念及使用方法 基于容器服务ACK Serverless搭建和管理企业级网站应用
相关文章
|
6月前
|
前端开发 网络协议 JavaScript
|
网络协议
一文彻底搞定TCP协议的三次握手和四次挥手
通过本章的探险,你将学会如何TCP协议的三次握手和四次挥手
|
网络协议 安全
网络:TCP协议三次握手与四次挥手
网络:TCP协议三次握手与四次挥手
79 0
|
6月前
|
缓存 网络协议 数据处理
TCP中的三次握手和四次挥手
我们知道TCP是运输层的面向连接的可靠的传输协议。**面向连接的**,指的就是在两个进程发送数据之前,必须先相互“握手”,确保两进程可以进行连接。并且这个传输是点对点的,即一个TCP连接中只有一个发送方和接收方;**可靠的**,指的是在任何网络情况下,在TCP传输中数据都将完整的发送到接收方。
69 0
TCP中的三次握手和四次挥手
|
6月前
|
网络协议 算法
TCP 三次握手和四次挥手
TCP 三次握手和四次挥手
82 0
TCP 三次握手和四次挥手
|
6月前
|
网络协议 网络架构
🔥🔥TCP协议:三次握手、四次挥手,你真的了解吗?
这篇面试文章主要介绍了TCP协议的网络分层以及TCP的三次握手和四次挥手的原理。TCP协议的分层结构和三次握手、四次挥手的原理确保了数据的可靠传输和连接的可靠建立与断开。这些概念和原理在网络工程中起着重要的作用,并且是网络面试中常见的考点。
125 1
|
6月前
|
存储 网络协议 算法
TCP协议的三次握手与四次挥手
TCP协议的三次握手与四次挥手
58 2
|
6月前
|
网络协议
【计算机网络】TCP 三次握手四次挥手
【1月更文挑战第10天】【计算机网络】TCP 三次握手四次挥手
|
6月前
|
网络协议
TCP三次握手,四次挥手策略
TCP三次握手,四次挥手策略
30 0