一、TCP 报文段简介

在介绍三次握手和四次挥手之前,先来简单认识一下 TCP 报文段的结构-
-
TCP 首部包含以下内容,请留意其中的控制位,在三次握手和四次挥手过程中会频繁出现:

  • 端口号 (Source Port and Destination Port):每个 TCP 报文段都包含源端和目的端的端口号,用于寻找发送端和接收端应用进程。这两个值加上 IP 首部中的源端 IP 地址和目的端 IP 地址就可以确定一个唯一的 TCP 连接。

  • 序号 (Sequence Number):这个字段的主要作用是用于将失序的数据重新排列。TCP 会隐式地对字节流中的每个字节进行编号,而 TCP 报文段的序号被设置为其数据部分的第一个字节的编号。序号是 32 bit 的无符号数,取值范围是0到 232 - 1。

  • 确认序号 (Acknowledgment Number):接收方在接受到数据后,会回复确认报文,其中包含确认序号,作用就是告诉发送方自己接收到了哪些数据,下一次数据从哪里开始发,因此,确认序号应当是上次已成功收到数据字节序号加 1。只有 ACK 标志为 1 时确认序号字段才有效。

  • 首部长度 (Header Length):首部中的选项部分的长度是可变的,因此首部的长度也是可变的,所以需要这个字段来明确表示首部的长度,这个字段占 4 bit,4 位的二进制数最大可以表示 15,而首部长度是以 4 个字节为一个单位的,因此首部最大长度是 15 * 4 = 60 字节。

  • 保留字段 (Reserved):占 6 位,未来可能有具体用途,目前默认值为0.

  • 控制位 (Control Bits):在三次握手和四次挥手中会经常看到 SYN、ACK 和 FIN 的身影,一共有 6 个标志位,它们表示的意义如下:

    • URG (Urgent Bit):值为 1 时,紧急指针生效
    • ACK (Acknowledgment Bit):值为 1 时,确认序号生效
    • PSH (Push Bit):接收方应尽快将这个报文段交给应用层
    • RST (Reset Bit):发送端遇到问题,想要重建连接
    • SYN (Synchronize Bit):同步序号,用于发起一个连接
    • FIN (Finish Bit):发送端要求关闭连接
  • 窗口大小 (Window): TCP的流量控制由连接的每一端通过声明的窗口大小来提供。窗口大小为字节数,起始于确认序号字段指明的值,这个值是接收端正期望接收的字节。窗口大小是一个 16 bit 字段,单位是字节, 因而窗口大小最大为 65535 字节。

  • 检验和 (Checksum):功能类似于数字签名,用于验证数据完整性,也就是确保数据未被修改。检验和覆盖了整个 TCP 报文段,包括 TCP 首部和 TCP 数据,发送端根据特定算法对整个报文段计算出一个检验和,接收端会进行计算并验证。

  • 紧急指针 (Urgent Pointer):当 URG 控制位值为 1 时,此字段生效,紧急指针是一个正的偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。 TCP 的紧急方式是发送端向另一端发送紧急数据的一种方式。

  • 选项 (Options):这一部分是可选字段,也就是非必须字段,最常见的可选字段是“最长报文大小 (MSS,Maximum Segment Size)”。

  • 有效数据部分 (Data):这部分也不是必须的,比如在建立和关闭 TCP 连接的阶段,双方交换的报文段就只包含 TCP 首部。

二、TCP 的连接控制

2.1 建立连接

2.1.1 三次握手

这个问题简直太经典了,如果你在面试中只被问到了一个关于 TCP 的问题,那大概率就是关于三次握手的问题。TCP 的重要特性之一就是面向连接,连接双方在发送数据之前必须经历握手的阶段,那具体的过程是怎样的呢?先来看图,大家最好可以动手简单画画这个图,当然还有后文四次挥手的图,帮助加深记忆。

三次握手过程

如图所示,双方之间的三个蓝色箭头就表示了三次握手过程中所发生的数据交换:

  1. 第一次握手:客户端向服务器发送报文段1,其中的 SYN 标志位 (前文已经介绍过各种标志位的作用)的值为 1,表示这是一个用于请求发起连接的报文段,其中的序号字段 (Sequence Number,图中简写为seq)被设置为初始序号x (Initial Sequence Number,ISN),TCP 连接双方均可随机选择初始序号。发送完报文段1之后,客户端进入 SYN-SENT 状态,等待服务器的确认。
  2. 第二次握手:服务器在收到客户端的连接请求后,向客户端发送报文段2作为应答,其中 ACK 标志位设置为 1,表示对客户端做出应答,其确认序号字段 (Acknowledgment Number,图中简写为小写 ack) 生效,该字段值为 x + 1,也就是从客户端收到的报文段的序号加一,代表服务器期望下次收到客户端的数据的序号。此外,报文段2的 SYN 标志位也设置为1,代表这同时也是一个用于发起连接的报文段,序号 seq 设置为服务器初始序号y。发送完报文段2后,服务器进入 SYN-RECEIVED 状态。
  3. 第三次握手:客户端在收到报文段2后,向服务器发送报文段3,其 ACK 标志位为1,代表对服务器做出应答,确认序号字段 ack 为 y + 1,序号字段 seq 为 x + 1。此报文段发送完毕后,双方都进入 ESTABLISHED 状态,表示连接已建立。

常见面试题 1: TCP 建立连接为什么要三次握手而不是两次?

答:网上大多数资料对这个问题的回答只有简单的一句:防止已过期的连接请求报文突然又传送到服务器,因而产生错误,这既不够全面也不够具体。下面给出比较详细而全面的回答:

  1. 防止已过期的连接请求报文突然又传送到服务器,因而产生错误

    在双方两次握手即可建立连接的情况下,假设客户端发送 A 报文段请求建立连接,由于网络原因造成 A 暂时无法到达服务器,服务器接收不到请求报文段就不会返回确认报文段,客户端在长时间得不到应答的情况下重新发送请求报文段 B,这次 B 顺利到达服务器,服务器随即返回确认报文并进入 ESTABLISHED 状态,客户端在收到 确认报文后也进入 ESTABLISHED 状态,双方建立连接并传输数据,之后正常断开连接。此时姗姗来迟的 A 报文段才到达服务器,服务器随即返回确认报文并进入 ESTABLISHED 状态,但是已经进入 CLOSED 状态的客户端无法再接受确认报文段,更无法进入 ESTABLISHED 状态,这将导致服务器长时间单方面等待,造成资源浪费。

  2. 三次握手才能让双方均确认自己和对方的发送和接收能力都正常

    第一次握手:客户端只是发送处请求报文段,什么都无法确认,而服务器可以确认自己的接收能力和对方的发送能力正常;

    第二次握手:客户端可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;

    第三次握手:服务器可以确认自己发送能力和接收能力正常,对方发送能力和接收能力正常;

    可见三次握手才能让双方都确认自己和对方的发送和接收能力全部正常,这样就可以愉快地进行通信了。

  3. 告知对方自己的初始序号值,并确认收到对方的初始序号值

    TCP 实现了可靠的数据传输,原因之一就是 TCP 报文段中维护了序号字段和确认序号字段,也就是图中的 seq 和 ack,通过这两个字段双方都可以知道在自己发出的数据中,哪些是已经被对方确认接收的。这两个字段的值会在初始序号值得基础递增,如果是两次握手,只有发起方的初始序号可以得到确认,而另一方的初始序号则得不到确认。

常见面试题2: TCP 建立连接为什么要三次握手而不是四次?

答:相比上个问题而言,这个问题就简单多了。因为三次握手已经可以确认双方的发送接收能力正常,双方都知道彼此已经准备好,而且也可以完成对双方初始序号值得确认,也就无需再第四次握手了。

常见面试题3: 有一种网络攻击是利用了 TCP 建立连接机制的漏洞,你了解吗?这个问题怎么解决?

答:在三次握手过程中,服务器在收到了客户端的 SYN 报文段后,会分配并初始化连接变量和缓存,并向客户端发送 SYN + ACK 报文段,这相当于是打开了一个“半开连接 (half-open connection)”,会消耗服务器资源。如果客户端正常返回了 ACK 报文段,那么双方可以正常建立连接,否则,服务器在等待一分钟后会终止这个“半开连接”并回收资源。这样的机制为 SYN洪泛攻击 (SYN flood attack)提供了机会,这是一种经典的 DoS攻击 (Denial of Service,拒绝服务攻击),所谓的拒绝服务攻击就是通过进行攻击,使受害主机或网络不能提供良好的服务,从而间接达到攻击的目的。在 SYN 洪泛攻击中,攻击者发送大量的 SYN 报文段到服务器请求建立连接,但是却不进行第三次握手,这会导致服务器打开大量的半开连接,消耗大量的资源,最终无法进行正常的服务。

解决方法:SYN Cookies,现在大多数主流操作系统都有这种防御系统。SYN Cookies 是对 TCP 服务器端的三次握手做一些修改,专门用来防范 SYN 洪泛攻击的一种手段。它的原理是,在服务器接收到 SYN 报文段并返回 SYN + ACK 报文段时,不再打开一个半开连接,也不分配资源,而是根据这个 SYN 报文段的重要信息 (包括源和目的 IP 地址,端口号可一个秘密数),利用特定散列函数计算出一个 cookie 值。这个 cookie 作为将要返回的SYN + ACK 报文段的初始序列号(ISN)。当客户端返回一个 ACK 报文段时,服务器根据首部字段信息计算 cookie,与返回的确认序号(初始序列号 + 1)进行对比,如果相同,则是一个正常连接,然后分配资源并建立连接,否则拒绝建立连接。

2.2.2 同时打开

这是 TCP 建立连接的特殊情况,有时会出现两台机器同时执行主动打开的情况,不过概率非常小,这种情况大家仅作了解即可。在这种情况下就无所谓发送方和接收方了,双放都可以称为客户端和服务器,同时打开的过程如下:

同时打开的过程

如图所示,双方在同一时刻发送 SYN 报文段,并进入 SYN-SENT 状态,在收到 SYN 后,状态变为 SYN-RECEIVED,同时它们都再发送一个 SYN + ACK 的报文段,状态都变为 ESTABLISHED,连接成功建立。在此过程中双方一共交换了4个报文段,比三次握手多一个。

2.2 关闭连接

2.2.1 四次挥手

建立一个连接需要三次握手,而终止一个连接要经过 4次握手。这由 TCP 的半关闭( half-close) 造成的。既然一个 TCP 连接是全双工 (即数据在两个方向上能同时传递), 因此每个方向必须单独地进行关闭。这原则就是当一方完成它的数据发送任务后就能发送一个 FIN 来终止这个方向连接。当一端收到一个 FIN,它必须通知应用层另一端已经终止了数据传送。理论上客户端和服务器都可以发起主动关闭,但是更多的情况下是客户端主动发起。

四次挥手过程

四次挥手详细过程如下:

  1. 客户端发送关闭连接的报文段,FIN 标志位1,请求关闭连接,并停止发送数据。序号字段 seq = x (等于之前发送的所有数据的最后一个字节的序号加一),然后客户端会进入 FIN-WAIT-1 状态,等待来自服务器的确认报文。
  2. 服务器收到 FIN 报文后,发回确认报文,ACK = 1, ack = x + 1,并带上自己的序号 seq = y,然后服务器就进入 CLOSE-WAIT 状态。服务器还会通知上层的应用程序对方已经释放连接,此时 TCP 处于半关闭状态,也就是说客户端已经没有数据要发送了,但是服务器还可以发送数据,客户端也还能够接收。
  3. 客户端收到服务器的 ACK 报文段后随即进入 FIN-WAIT-2 状态,此时还能收到来自服务器的数据,直到收到 FIN 报文段。
  4. 服务器发送完所有数据后,会向客户端发送 FIN 报文段,各字段值如图所示,随后服务器进入 LAST-ACK 状态,等待来自客户端的确认报文段。
  5. 客户端收到来自服务器的 FIN 报文段后,向服务器发送 ACK 报文,随后进入 TIME-WAIT 状态,等待 2MSL(2 * Maximum Segment Lifetime,两倍的报文段最大存活时间) ,这是任何报文段在被丢弃前能在网络中存在的最长时间,常用值有30秒、1分钟和2分钟。如无特殊情况,客户端会进入 CLOSED 状态。
  6. 服务器在接收到客户端的 ACK 报文后会随即进入 CLOSED 状态,由于没有等待时间,一般而言,服务器比客户端更早进入 CLOSED 状态。

常见面试题1: 为什么 TCP 关闭连接为什么要四次而不是三次?

答:服务器在收到客户端的 FIN 报文段后,可能还有一些数据要传输,所以不能马上关闭连接,但是会做出应答,返回 ACK 报文段,接下来可能会继续发送数据,在数据发送完后,服务器会向客户单发送 FIN 报文,表示数据已经发送完毕,请求关闭连接,然后客户端再做出应答,因此一共需要四次挥手。

常见面试题2: 客户端为什么需要在 TIME-WAIT 状态等待 2MSL 时间才能进入 CLOSED 状态?

答:按照常理,在网络正常的情况下,四个报文段发送完后,双方就可以关闭连接进入 CLOSED 状态了,但是网络并不总是可靠的,如果客户端发送的 ACK 报文段丢失,服务器在接收不到 ACK 的情况下会一直重发 FIN 报文段,这显然不是我们想要的。因此客户端为了确保服务器收到了 ACK,会设置一个定时器,并在 TIME-WAIT 状态等待 2MSL 的时间,如果在此期间又收到了来自服务器的 FIN 报文段,那么客户端会重新设置计时器并再次等待 2MSL 的时间,如果在这段时间内没有收到来自服务器的 FIN 报文,那就说明服务器已经成功收到了 ACK 报文,此时客户端就可以进入 CLOSED 状态了。

TIME_WAIT状态的主要目的有两个:

  • 优雅的关闭TCP连接,也就是尽量保证被动关闭的一端收到它自己发出去的FIN报文的ACK确认报文;
  • 处理延迟的重复报文,这主要是为了避免前后两个使用相同四元组的连接中的前一个连接的报文干扰后一个连接。

为什么是2msl

  • 如果B没有收到自己的ACK,会超时重传FiN那么A再次接到重传的FIN,会再次发送ACK

  • 如果B收到自己的ACK,也不会再发任何消息,包括ACK
    无论是1还是2,A都需要等待,要取这两种情况等待时间的最大值,以应对最坏的情况发生,这个最坏情况是:

    去向ACK消息最大存活时间(MSL) + 来向FIN消息的最大存活时间(MSL)。

    这恰恰就是2MSL( Maximum Segment Life)。

2.2.2 同时关闭

之前在介绍 TCP 建立连接的时候会有一种特殊情况,那就是同时打开,与之对应地, TCP 关闭时也会有一种特殊情况,那就是同时关闭,这种情况仅作了解即可,流程图如下:

同时关闭过程

这种情况下,双方应用层同时发出关闭命令,这将导致双方各发送一个 FIN,两端均从 ESTABLISHED 变为 FIN_WAIT_1,两个 FIN 经过网络传送后分别到达另一端。收到 FIN 后,状态由 FIN_WAIT_1 变迁到 CLOSING,并发送最后的 ACK,当收到最后的 ACK 时,为确保对方也收到 ACK,状态变化为 TIME_WAIT,并等待 2MSL 时间,如果一切正常,随后会进入 CLOSED 状态。

3.网络连接存在大量time_wait和close_wait的原因以及解决方法

大量time_wait

问题原因

关于三次握手与四次挥手你要知道这些》中有关于“四次挥手释放连接时,等待2MSL的意义”的解释。正因为有2ML的存在,所以可能会发生大量time_wait存在的现象,从而影响服务器性能,甚至导致套接字数量达到服务器上限。

实际上,TIME_WAIT对于系统资源的消耗影响比较小,而真正需要考虑因为TIME_WAIT多而触碰到限制的是如下几个方面:

  1. 源端口数量 (net.ipv4.ip_local_port_range)
  2. TIME_WAIT bucket 数量 (net.ipv4.tcp_max_tw_buckets)
  3. 文件描述符数量 (max open files)

netstat -n | awk ‘/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}’

解决方法

解决思路很简单,就是让服务器能够快速回收和重用那些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

#对于一个新建连接,内核要发送多少个 SYN 连接请求才决定放弃,不应该大于255,默认值是5,对应于180秒左右时间
net.ipv4.tcp_syn_retries=2
#net.ipv4.tcp_synack_retries=2
#表示当keepalive起用的时候,TCP发送keepalive消息的频度。缺省是2小时,改为300秒
net.ipv4.tcp_keepalive_time=1200
net.ipv4.tcp_orphan_retries=3
#表示如果套接字由本端要求关闭,这个参数决定了它保持在FIN-WAIT-2状态的时间
net.ipv4.tcp_fin_timeout=30
#表示SYN队列的长度,默认为1024,加大队列长度为8192,可以容纳更多等待连接的网络连接数。
net.ipv4.tcp_max_syn_backlog = 4096
#表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭
net.ipv4.tcp_syncookies = 1

#表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭
net.ipv4.tcp_tw_reuse = 1
#表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭
net.ipv4.tcp_tw_recycle = 1

##减少超时前的探测次数
net.ipv4.tcp_keepalive_probes=5
##优化网络设备接收队列
net.core.netdev_max_backlog=3000

net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle的开启都是为了回收处于TIME_WAIT状态的资源。

net.ipv4.tcp_fin_timeout这个时间可以减少在异常情况下服务器从FIN-WAIT-2转到TIME_WAIT的时间。

net.ipv4.tcp_keepalive_*一系列参数,是用来设置服务器检测连接存活的相关配置。

服务器保持了大量CLOSE_WAIT状态

TIME_WAIT状态可以通过优化服务器参数得到解决,因为发生TIME_WAIT的情况是服务器自己可控的,要么就是对方连接的异常,要么就是自己没有迅速回收资源,总之不是由于自己程序错误导致的。

但是CLOSE_WAIT就不一样了,从上面的图可以看出来,如果一直保持在CLOSE_WAIT状态,那么只有一种情况,就是在对方关闭连接之后服务器程序自己没有进一步发出ack信号。换句话说,就是在对方连接关闭之后,程序里没有检测到,或者程序压根就忘记了这个时候需要关闭连接,于是这个资源就一直被程序占着。个人觉得这种情况,通过服务器内核参数也没办法解决,服务器对于程序抢占的资源没有主动回收的权利,除非终止程序运行。

TIME_WAIT

  表示客户端主动关闭socket。

  原因:

  • 大量的短连接存在
  • 特别是 HTTP 请求中,如果 connection 头部取值被设置为 close 时,基本都由服务端发起主动关闭连接
  • TCP 四次挥手关闭连接机制中,为了保证 ACK 重发和丢弃延迟数据,设置 time_wait 为 2 倍的 MSL(报文最大存活时间)

  后果:  

  • TCP 连接中,「主动发起关闭连接」的一端,会进入 time_wait 状态
  • time_wait 状态,默认会持续 2 MSL(报文的最大生存时间),一般是 2x2 mins
  • time_wait 状态下,TCP 连接占用的端口,无法被再次使用;TCP 端口数量,上限是 6.5w(65535,16 bit)
  • 大量 time_wait 状态存在,会导致新建 TCP 连接会出错,address already in use : connect 异常
  • 占用内存,但内存占用并不大,1万条TIME_WAIT的连接,也就多消耗1M
  • 耗CPU,每次找到一个随机端口,需要遍历一遍bound ports的吧,这必然需要一些CPU时间,但也还好,无需太担忧

  解决:

  • 服务器端允许 time_wait 状态的 socket 被重用
  • 缩减 time_wait 时间,设置为 1 MSL
  • 修改内核参数:

CLOSE_WAIT

  表示服务端被动关闭socket。

  根据TCP状态机,服务器端收到客户端发送的FIN,则按照TCP实现发送ACK,因此进入CLOSE_WAIT状态。但如果服务器端不执行close(),就不能由CLOSE_WAIT迁移到LAST_ACK,则系统中会存在很多CLOSE_WAIT状态的连接。

  原因:应用程序写的有问题,没有合适的关闭socket;要么是服务器CPU处理不过来(CPU太忙)或者应用程序一直睡眠到其它地方(锁,或者文件I/O等等),应用程序获得不到合适的调度时间,造成程序没法真正的执行close操作。

  后果:出现大量的CLOSE_WAIT后,服务无法继续正常服务,端口无法被复用,socket资源被耗尽。因为Linux分配给一个用户的文件句柄是有限的,而如果一直被保持,则文件句柄也就不能close,导致句柄资源达到上线,接着就会出现大量Too Many Open Files错误。