从零入门计算机网络系列(十八):TCP连接管理详解——从三次握手与四次挥手到TIME_WAIT机制

上一篇文章开始,我们正式进入了TCP原理的深入理解阶段,围绕序列号、确认应答以及丢包重传等机制,进一步分析了TCP是如何保证数据可靠传输的。

不过,TCP的可靠性不仅体现在数据传输过程中,连接是如何建立的,又是如何安全、有序地断开的,同样值得我们深入研究。

还记得之前讲过的三次握手和四次挥手吗?当时我们只是简单介绍了它们的基本流程,但如果继续往深处思考,就会发现其中还有不少值得琢磨的细节。

为什么建立连接需要三次握手?两次不行吗?为什么断开连接通常需要四次挥手?在这个过程中,客户端和服务器的连接状态又是如何变化的?

本文就接着上一篇的内容,带大家深入理解TCP三次握手与四次挥手的工作原理,不只是记住它们的流程,更要弄清楚这些设计背后的原因。

目录

一、深入理解TCP连接管理:从三次握手到四次挥手

[1.1 三次握手与操作系统:TCP连接是如何建立的?](#1.1 三次握手与操作系统:TCP连接是如何建立的?)

[1.2 为什么TCP需要三次握手:从通信确认到连接建立](#1.2 为什么TCP需要三次握手:从通信确认到连接建立)

[1.2.1 三次握手的核心意义:通信能力确认与初始序列号同步](#1.2.1 三次握手的核心意义:通信能力确认与初始序列号同步)

[1.2.2 握手过程的本质:SYN与ACK为什么能够合并发送?](#1.2.2 握手过程的本质:SYN与ACK为什么能够合并发送?)

[1.3 四次挥手与半关闭机制:TCP连接如何优雅终止](#1.3 四次挥手与半关闭机制:TCP连接如何优雅终止)

[1.3.1 四次挥手的本质:全双工通信与单向关闭](#1.3.1 四次挥手的本质:全双工通信与单向关闭)

[1.3.2 ACK与FIN为什么有时能够合并发送?](#1.3.2 ACK与FIN为什么有时能够合并发送?)

[1.4 CLOSE_WAIT状态详解:从连接未关闭到资源泄漏](#1.4 CLOSE_WAIT状态详解:从连接未关闭到资源泄漏)

[1.4.1 CLOSE_WAIT的产生原因与长期滞留的危害](#1.4.1 CLOSE_WAIT的产生原因与长期滞留的危害)

[1.4.2 shutdown与close:半关闭机制与资源释放的区别](#1.4.2 shutdown与close:半关闭机制与资源释放的区别)

[1.5 TIME_WAIT状态与SO_REUSEADDR:服务器重启为何出现端口占用?](#1.5 TIME_WAIT状态与SO_REUSEADDR:服务器重启为何出现端口占用?)

[1.5.1 TIME_WAIT带来的实际问题:端口绑定失败与服务重启](#1.5.1 TIME_WAIT带来的实际问题:端口绑定失败与服务重启)

[1.5.2 SO_REUSEADDR详解:地址复用的原理与使用条件](#1.5.2 SO_REUSEADDR详解:地址复用的原理与使用条件)

[1.6 深入理解TIME_WAIT:为什么需要等待2MSL?](#1.6 深入理解TIME_WAIT:为什么需要等待2MSL?)

[1.6.1 MSL与2MSL:等待时间的含义及设计目的](#1.6.1 MSL与2MSL:等待时间的含义及设计目的)

[1.6.2 历史报文的干扰:从网络中的"流弹"理解连接安全](#1.6.2 历史报文的干扰:从网络中的“流弹”理解连接安全)

[1.6.3 TIME_WAIT的核心作用:确保连接可靠终止并隔离旧报文](#1.6.3 TIME_WAIT的核心作用:确保连接可靠终止并隔离旧报文)

[1.6.4 随机初始序列号ISN:降低历史报文干扰的辅助机制](#1.6.4 随机初始序列号ISN:降低历史报文干扰的辅助机制)

二、TCP连接状态转换:从建立连接到彻底关闭

[2.1 服务端状态转换:从LISTEN到CLOSED](#2.1 服务端状态转换:从LISTEN到CLOSED)

[2.2 客户端状态转换:从SYN_SENT到CLOSED](#2.2 客户端状态转换:从SYN_SENT到CLOSED)


一、深入理解TCP连接管理:从三次握手到四次挥手

1.1 三次握手与操作系统:TCP连接是如何建立的?

在正式分析三次握手的过程之前,我们先来思考一个问题:TCP连接需不需要被操作系统管理?

答案当然是需要!大家还记得之前讲过的操作系统管理思想吗?先描述,再组织!

既然TCP连接需要被管理,那么操作系统就必须先通过相应的数据结构描述连接,再将这些连接组织起来,进行统一管理。

为什么呢?因为建立连接并不是没有成本的,它既需要时间成本,也需要空间成本。

比如,操作系统需要维护连接的状态、发送和接收序号、缓冲区、定时器等信息。这些都需要内核数据结构和相应的系统资源来支撑。

在Linux内核中,我们可以从struct sock、struct tcp_sock等结构中看到TCP连接管理的相关信息。

换句话说,TCP连接并不是一个抽象的概念,它在操作系统内部有对应的状态和资源需要维护。

那么问题来了:既然建立连接需要操作系统参与,三次握手究竟是谁完成的?是我们编写的客户端和服务器程序吗?

其实不是。TCP三次握手的具体协议处理,主要由客户端和服务器各自的操作系统内核自动完成,应用程序并不需要手动发送SYN、SYN + ACK和ACK这三个握手报文段。

这里有两个我们非常熟悉的Socket接口:

复制代码
connect();
accept();

它们分别负责什么呢?

connect()用于让客户端主动发起连接,而accept()则用于让服务器应用程序获取已经建立好的连接。

当客户端调用connect()时,内核会按照TCP协议发起三次握手。如果使用阻塞式Socket,connect()通常会等待连接成功或失败后再返回;如果使用非阻塞Socket,连接过程则可能还没有完成,接口就已经返回了。

而服务器这边呢?只要服务器已经通过listen()进入监听状态,内核就可以处理客户端发来的连接请求,并推进TCP三次握手。

当连接成功建立后,内核会将它放入相应的已完成连接队列,等待应用程序调用 accept() 获取。

注意,accept()本身并不负责执行TCP三次握手,它只是从监听Socket对应的队列中取出已经完成建立的连接,并返回用于后续通信的新Socket。

这也意味着,即使服务器应用程序暂时没有调用accept(),只要相关队列还有容量,操作系统内核仍然可以完成客户端的TCP三次握手。

所以,我们要把这两个概念区分清楚:

三次握手是操作系统内核完成的TCP协议过程,而connect()和accept()是应用程序与内核交互的 Socket 接口。

理解了这一点,我们再去分析三次握手的过程,就不应该只盯着三个报文段看了。

更重要的是,要理解每次握手背后,客户端和服务器的内核究竟在维护什么连接状态,又为什么需要这样设计。

1.2 为什么TCP需要三次握手:从通信确认到连接建立
1.2.1 三次握手的核心意义:通信能力确认与初始序列号同步

在正式回答这个问题之前,我们先举一个生活中的例子。

假设男女双方准备结婚,需要满足什么条件呢?至少双方得达成共识吧?不能男方认为两个人要结婚了,女方却完全不知道这回事。除此之外,还需要满足一些必要的外部条件。

其实,TCP建立连接也是类似的道理:连接不能只靠一方说了算,通信双方需要交换信息,确认彼此的连接状态,并完成必要的参数协商。

那么,为什么TCP偏偏选择三次握手,而不是两次或者四次呢?

我们先从两个角度来理解。

第一,让通信双方以较少的交互次数完成连接建立所需要的确认。

客户端想要建立连接,就向服务器发送SYN;服务器收到后,返回SYN + ACK;客户端收到服务器的响应,再回复ACK。

通过这三次交互,双方不仅表达了建立连接的意图,还能够同步各自的初始序列号(ISN),为后续的可靠传输做好准备。

注意,这里不能简单理解为三次握手能够100%保证双方永远对连接状态保持一致。

因为网络中的报文仍然可能丢失,客户端和服务器进入ESTABLISHED状态的时间也不完全相同。

三次握手真正要解决的,是如何通过必要的交互完成序列号同步,并尽可能避免历史重复连接请求造成错误连接。

第二,为TCP的全双工通信建立必要的双向通信基础。

我们知道,TCP是全双工协议,客户端能够向服务器发送数据,服务器也能够向客户端发送数据。

那么,在正式通信之前,双方至少需要通过握手报文,确认对方能够接收并回应自己的连接请求。

大家想一想:

客户端发送SYN,服务器能够收到,说明这个方向的连接请求成功到达了对方。

服务器回复SYN + ACK,客户端能够收到,说明反方向的响应也成功到达了客户端。

而客户端再发送一次ACK,服务器收到后,就能够确认自己发出的SYN已经得到了对方的回应。

你看,三次握手不仅让双方完成了必要的序列号同步,也通过双向报文交互,为后续的全双工数据传输建立了基础。

不过,这并不代表三次握手能够证明网络以后一定畅通,更不能保证后续的数据传输不会发生丢包。

所以,我们可以先得到一个结论:

TCP选择三次握手,并不是随意规定的次数,而是为了让双方完成初始序列号同步、确认必要的连接信息,并减少历史重复连接请求带来的问题。

至于为什么两次握手不够,而第四次握手通常又没有必要,我们还需要结合每一次握手的具体过程,继续往下分析。

1.2.2 握手过程的本质:SYN与ACK为什么能够合并发送?

我们一直在说TCP建立连接需要三次握手,可大家有没有想过一个问题:

三次握手,真的就只有三次交互吗?为什么偏偏是三次,而不是四次呢?

这里有一个很有意思的地方。

如果从双方需要交换的控制信息来看,三次握手实际上可以拆成四个逻辑动作,只不过其中两个动作被合并到了同一个TCP报文段中。

什么意思呢?

我们知道,TCP是全双工协议,客户端和服务器都需要同步自己的初始序列号,并确认对方的初始序列号。

那么,从这个角度来看,双方实际上需要完成四件事情:

  1. 客户端向服务器发送SYN,告诉服务器自己的初始序列号。

  2. 服务器向客户端回复ACK,确认客户端的SYN。

  3. 服务器向客户端发送SYN,告诉客户端自己的初始序列号。

  4. 客户端向服务器回复ACK,确认服务器的SYN。

你看,如果把这些动作完全拆开,不就是四个逻辑步骤吗?

但问题来了:既然服务器收到客户端的SYN后,既需要确认客户端的请求,又需要向客户端同步自己的初始序列号,为什么还要把ACK和SYN分开发送呢?

完全没有这个必要!

于是,在正常的TCP连接建立过程中,服务器可以将这两部分控制信息放在同一个报文段中,同时设置SYN = 1和ACK = 1。

这就是我们熟悉的SYN + ACK。

这样一来,整个连接建立过程就变成了:

复制代码
客户端                         服务器
   |                              |
   | -------- SYN --------------> |
   |                              |
   | <------- SYN + ACK --------- |
   |                              |
   | -------- ACK --------------> |
   |                              |

原本可以分开理解的四个逻辑动作,最终通过三个TCP报文段就完成了。

所以,我们常说的三次握手,准确来说是三次报文交互,而不是只能拆出三个逻辑动作。

不过,这里一定要区分清楚。

三次握手并不意味着TCP原本规定了四次握手,只是后来为了提高效率,硬生生把两次拼在了一起。

TCP协议本身就允许一个报文段同时携带多种控制信息。服务器在回复客户端时,既可以确认对方的SYN,也可以同步自己的初始序列号,这两件事情并不冲突。

但我们也不能把SYN + ACK理解为程序执行层面不可分割的"原子操作",它只是一个同时携带SYN和ACK控制信息的TCP报文段。

说到底,四个逻辑动作解释的是双方需要交换哪些信息,而三次握手描述的是正常建立连接时实际需要进行多少次报文交互。

这也是为什么我们会看到:TCP在建立连接时,通常只需要三个报文段,就能够完成双方初始序列号的同步与确认。

理解了这一点,我们再去思考"为什么建立连接通常是三次握手,而断开连接通常是四次挥手",是不是就更容易找到其中的区别了?

1.3 四次挥手与半关闭机制:TCP连接如何优雅终止
1.3.1 四次挥手的本质:全双工通信与单向关闭

前面我们讲过,TCP建立连接通常需要三次握手,而断开连接通常需要四次挥手。

不过,大家有没有想过:既然三次握手能够把SYN和ACK合并发送,为什么四次挥手通常不能把FIN和ACK也合并起来呢?

这个问题其实很有意思,也是面试中经常被问到的一个知识点。

我们先回顾一下TCP的一个重要特性------全双工通信。

所谓全双工,就是客户端和服务器可以同时向对方发送数据。我们可以把它理解为两个方向的数据传输通道:

cpp 复制代码
客户端发送缓冲区 → 服务器接收缓冲区

服务器发送缓冲区 → 客户端接收缓冲区

那么,当客户端决定断开连接,向服务器发送一个FIN报文段时,究竟意味着什么呢?

注意,这并不意味着整个TCP连接立即断开,而是客户端告诉服务器:"我这边已经没有数据需要继续发送了。"

可问题是,客户端不发送数据了,服务器就一定没有数据要发送了吗?显然不是!

比如,客户端向服务器发送了一个请求,随后关闭自己的发送方向。但服务器可能还在处理请求,甚至还有大量响应数据没有发送完。

总不能因为客户端不想继续发送数据了,就要求服务器立刻停止自己的数据传输吧?

这显然不合理。所以,服务器收到客户端的FIN后,可以先回复一个ACK,确认已经收到对方结束发送的通知。

但此时,服务器自己的发送方向仍然可以保持正常工作,继续向客户端传输尚未发送完的数据。

这种一个方向已经结束发送,而另一个方向仍然能够继续传输数据的状态,我们称为半关闭(Half-Close)。

等服务器把剩余数据发送完毕,自己也不再需要继续发送时,才会向客户端发送FIN,表示服务器的发送方向也准备关闭了。

客户端收到这个FIN后,再回复ACK。这样一来,我们就得到了TCP四次挥手的典型过程:

cpp 复制代码
客户端                         服务器
   |                              |
   | -------- FIN --------------> | ①
   |                              |
   | <------- ACK --------------- | ②
   |                              |
   |      服务器仍可继续发送数据     |
   | <------- 数据 --------------- |
   |                              |
   | <------- FIN --------------- | ③
   |                              |
   | -------- ACK --------------> | ④
   |                              |

看到了吗?第二次挥手的ACK和第三次挥手的FIN,虽然都是服务器发送的,但它们表达的含义完全不同。

ACK表示:"我已经收到你结束发送的通知。"

FIN表示:"我自己也已经没有数据要继续发送了。"

这两件事情并不一定会同时发生,所以通常需要拆开处理。

这就是四次挥手通常需要四个阶段的根本原因。

1.3.2 ACK与FIN为什么有时能够合并发送?

讲到这里,可能有人会问:

既然第二次挥手的ACK和第三次挥手的FIN都由服务器发送,那它们是不是绝对不能合并呢?

当然不是!假设服务器收到客户端的FIN时,自己恰好也没有数据需要继续发送,并且应用程序也已经决定关闭对应的发送方向。

那么,服务器完全有可能在同一个TCP报文段中,同时设置ACK = 1和FIN = 1。

这样一来,整个关闭过程就可能只需要三个报文段:

复制代码
客户端                         服务器
   |                              |
   | -------- FIN --------------> |
   |                              |
   | <------- FIN + ACK --------- |
   |                              |
   | -------- ACK --------------> |
   |                              |

你看,四次挥手不就变成三次了吗?

所以,准确来说,TCP连接关闭通常需要四个逻辑动作,但不一定要发送四个独立的报文段。

这里还需要纠正一个容易产生误解的地方。

ACK和FIN能否合并,并不是简单地由"是否开启延迟确认机制"决定的。

延迟确认确实可能影响ACK的发送时机,但真正决定能否携带FIN的关键,在于服务器是否已经准备结束自己的发送方向,以及相关的发送时机是否允许合并。

如果服务器还有数据没有发送完,即使已经确认了客户端的FIN,也不能仅仅为了减少一次挥手,就提前发送自己的FIN。

反过来,如果服务器也已经准备关闭发送方向,就可能将FIN和ACK放在同一个报文段中发送。这并不是什么极其罕见的特殊情况。

那么,为什么三次握手通常能够合并SYN和ACK,而四次挥手却经常需要分开呢?

关键就在于建立连接和断开连接时,双方需要协调的事情不一样。

建立连接时,服务器收到客户端的SYN后,通常可以在确认客户端初始序列号的同时,发送自己的 SYN,完成初始序列号同步。

而断开连接时,服务器收到客户端的FIN,只能说明客户端已经结束了自己的发送方向,并不意味着服务器也已经完成了数据发送。

换句话说,建立连接时,服务器通常能够同时完成SYN和ACK的发送;而断开连接时,确认对方的 FIN,与决定何时发送自己的FIN,是两个相对独立的事情。

所以,面试官如果问:

"为什么TCP建立连接通常是三次握手,而断开连接通常是四次挥手?"

我们就可以这样回答:

TCP是全双工协议,两个方向的数据发送可以独立结束。建立连接时,服务器通常可以把SYN和ACK合并发送;但断开连接时,一方收到FIN,并不意味着自己也已经发送完数据,所以通常需要先确认对方的FIN,等自身数据发送完成后再发送FIN。

这才是四次挥手通常无法像三次握手那样直接合并的根本原因。

1.4 CLOSE_WAIT状态详解:从连接未关闭到资源泄漏
1.4.1 CLOSE_WAIT的产生原因与长期滞留的危害

前面我们已经知道,TCP是全双工协议,客户端和服务器可以分别关闭自己的发送方向。

那么,我们再来考虑一种情况:

如果客户端已经发起断开连接,但服务器就是迟迟不关闭自己的连接,会发生什么呢?

假设客户端主动发送FIN,告诉服务器自己已经没有数据需要继续发送了。

服务器收到FIN后,会回复ACK,确认客户端的关闭请求。此时,服务器的TCP连接就会进入一个非常重要的状态------CLOSE_WAIT。

这个状态是什么意思呢?

其实很简单,CLOSE_WAIT表示对方已经结束了发送,而本端还没有关闭自己的发送方向,正在等待应用程序进一步处理。

为什么需要等待应用程序呢?

因为服务器可能还有数据没有发送完,操作系统不能仅仅因为收到了客户端的FIN,就擅自结束服务器的发送方向。

那么问题来了:

如果服务器的应用程序一直不调用close(),也不通过其他方式关闭发送方向,会怎么样?

这时候,连接就可能长期停留在CLOSE_WAIT状态。

比如,我们的服务器在处理客户端断开连接的事件时,由于代码逻辑存在问题,忘记释放对应的Socket资源。客户端虽然已经断开,但服务器仍然保留着相应的文件描述符和连接资源。如果这样的连接越来越多,就可能造成文件描述符和内核资源持续被占用。

更严重的是,如果文件描述符不断累积,最终耗尽了进程能够使用的描述符数量,服务器就可能无法继续接受新的连接。

所以,在Linux服务器开发中,如果通过ss等工具发现大量连接长期处于CLOSE_WAIT状态,就需要检查应用程序有没有正确处理连接关闭和资源释放。不过,这里要注意:

出现 CLOSE_WAIT,并不意味着一定发生了文件描述符泄漏。

因为服务器确实可能还需要发送剩余数据,短时间处于CLOSE_WAIT完全正常。

真正值得警惕的是,大量连接长时间停留在CLOSE_WAIT,且应用程序始终没有完成关闭操作。

这通常意味着服务器的连接管理逻辑可能存在问题。

1.4.2 shutdown与close:半关闭机制与资源释放的区别

那么,在实际开发中,我们是不是需要自己手动完成TCP四次挥手呢?

当然不需要!还记得前面讲三次握手时,我们提到的一个重要思想吗?

应用程序负责提出通信需求,具体的TCP协议处理则交给操作系统内核完成。

其实,四次挥手也是一样的。当我们不再需要使用某个Socket时,可以调用:

cpp 复制代码
close(sockfd);

在正常关闭的情况下,应用程序释放文件描述符,后续的TCP连接终止过程则由操作系统内核按照协议规则处理。

不过,这里有一个细节需要注意。

调用close(),并不意味着TCP发送缓冲区中的数据一定已经全部到达对方,更不代表对方的应用程序已经读取了这些数据。

为什么呢?因为我们平时调用的write(),本身并不是直接把数据发送到网络中。

例如:

cpp 复制代码
write(sockfd, buffer, strlen(buffer));

对于常规的TCP Socket发送路径,write()主要负责将应用程序的数据交给内核管理。操作系统接收到这些数据后,再根据TCP的发送机制,完成分段、流量控制、拥塞控制以及必要的重传等操作。

注意,write()也不保证一次调用就能够写入请求的全部字节,实际还需要检查返回值。

这就是一种典型的解耦思想:应用程序负责提供数据,操作系统负责管理数据的可靠传输。

因此,在正常关闭Socket时,内核通常可以继续处理已经排队等待发送的数据,并完成后续的连接终止流程。

但如果网络发生故障,或者应用程序采用了特殊的关闭配置,这些数据仍然可能无法成功送达。

比如,SO_LINGER就可能影响close()的行为,某些设置甚至会导致连接被直接复位。

所以,不能简单地认为调用了close(),剩余数据就一定不会丢失。

除了close(),Linux还提供了另一个非常有意思的接口------shutdown()。

它和close()有什么区别呢?

我们直接来看:

cpp 复制代码
#include <sys/socket.h>

shutdown(sockfd, SHUT_RD);   // 关闭接收方向
shutdown(sockfd, SHUT_WR);   // 关闭发送方向
shutdown(sockfd, SHUT_RDWR); // 同时关闭收发方向

与close()不同,shutdown()可以针对Socket的接收方向和发送方向分别进行关闭。

比如,我们调用:

cpp 复制代码
shutdown(sockfd, SHUT_WR);

就表示本端不再继续发送新的数据。

对于TCP而言,内核会在正常发送完已经排队的数据后,通过FIN通知对方,本端已经结束发送。

但此时,接收方向仍然可以继续工作,我们依然能够通过read()或recv(),读取对方后续发送过来的数据。

这不就是前面讲过的半关闭吗?举个例子。

客户端向服务器发送完一个请求后,调用shutdown(sockfd, SHUT_WR),告诉服务器自己不会再发送新的请求数据。但客户端并没有关闭接收方向,仍然可以继续等待服务器返回处理结果。

也就是说,我们把原本双向都能发送数据的TCP连接,变成了只允许服务器继续向客户端发送数据的半关闭状态。

不过要特别注意,shutdown()并不会像close()那样直接释放文件描述符。

如果后续已经不再需要这个Socket,应用程序仍然需要适时调用close(),释放相应资源。

所以,我们可以这样理解:

close()主要用于释放应用程序持有的文件描述符,而shutdown()则用于控制Socket哪些通信方向不再继续使用。

至于具体的数据传输、FIN发送、确认应答以及状态转换,仍然由操作系统内核按照TCP协议完成。

这也再次说明了一个问题:

TCP的复杂性被操作系统封装在了底层,应用程序不需要亲自实现四次挥手,但必须正确管理Socket的生命周期,否则照样可能出现CLOSE_WAIT堆积、文件描述符泄漏等问题。

1.5 TIME_WAIT状态与SO_REUSEADDR:服务器重启为何出现端口占用?
1.5.1 TIME_WAIT带来的实际问题:端口绑定失败与服务重启

前面我们已经讲过,TCP在断开连接时,通常需要经历四次挥手。

不过,这里还有一个很有意思的细节。

主动断开连接的一方,在完成四次挥手之后,真的会立即进入CLOSED状态吗?

答案是:不一定,更准确地说,在正常的主动关闭流程中,通常不会!主动关闭方在发送最后一次ACK后,还需要进入一个非常重要的状态------TIME_WAIT。

那么,为什么四次挥手都已经结束了,TCP还不愿意立即释放这条连接呢?其实,TIME_WAIT的存在主要是为了处理两个问题:保证最后一次ACK有机会被对方收到,以及避免旧连接中残留的报文干扰后续的新连接。

比如,主动关闭方发送最后一次ACK后,如果这个ACK恰好丢失了,被动关闭方就可能因为迟迟得不到确认而重新发送FIN。

这时候,如果主动关闭方早就把连接状态彻底清除了,又该如何处理这个重传的FIN呢?所以,TCP需要保留一段时间的连接状态,以便在必要时重新回复ACK。

另外,网络中还可能存在之前传输的延迟报文。如果旧连接刚刚结束,就立即建立具有相同连接标识的新连接,这些迟到的报文就可能造成干扰。

TIME_WAIT就是为了给旧连接一个必要的缓冲期,尽可能避免这些问题。

按照TCP的经典设计,TIME_WAIT的等待时间为2MSL,其中MSL表示报文段在网络中的最大生存时间。

不过,这个时间并不是所有操作系统都完全一样。例如,在Linux内核中,常见的TIME_WAIT保留时间约为60秒。

那么问题来了:

TIME_WAIT明明是为了保证TCP连接的正确关闭而设计的,为什么在实际开发中,它有时反而会给我们带来麻烦呢?

我们举个比较贴近生产环境的例子。

假设双十一期间,一台服务器正在处理大量用户请求,结果程序突然出现异常,需要尽快重启服务。

对于线上业务而言,每多中断一秒,都可能带来不小的损失。

而服务器重新启动时,通常还需要绑定原来的IP地址和端口,因为客户端仍然会通过原来的地址访问服务。可偏偏这个时候,程序调用bind(),却发现绑定失败了!

cpp 复制代码
bind: Address already in use

奇怪,旧服务器不是已经退出了吗?为什么端口还是不能正常绑定?

这里就可能涉及TIME_WAIT。

如果旧服务器曾经主动关闭了一批TCP连接,那么这些连接可能仍然保留在TIME_WAIT状态。由于它们占用过相应的本地地址和端口,在某些绑定条件下,就可能影响新服务器重新绑定原来的监听地址。

不过,需要注意:服务器异常退出,并不意味着一定会产生TIME_WAIT;端口绑定失败,也不一定就是TIME_WAIT导致的。

例如,如果旧的监听Socket仍然存在,或者其他进程已经占用了目标地址和端口,同样可能出现Address already in use。

而且,TIME_WAIT记录的是具体的TCP连接,并不是简单地把某个端口完全锁死。

所以,我们真正需要解决的问题是:

如何在不破坏TCP正常连接管理机制的前提下,让服务器能够尽可能顺利地重新绑定原来的地址和端口?

1.5.2 SO_REUSEADDR详解:地址复用的原理与使用条件

其实,Linux已经为我们提供了一种常用的解决办法------SO_REUSEADDR。

还记得我们之前学习Socket编程时,调用bind()绑定监听地址的过程吗?

正常情况下,操作系统会检查当前地址和端口是否满足绑定条件。如果存在冲突,bind() 就可能失败。

而SO_REUSEADDR的作用,就是适当放宽 Socket 地址绑定时的检查规则,允许在符合条件的情况下重新使用本地地址。

我们直接来看代码:

cpp 复制代码
int opt = 1;

setsockopt(
    listenfd,
    SOL_SOCKET,
    SO_REUSEADDR,
    &opt,
    sizeof(opt)
);

其中,SOL_SOCKET表示操作的是Socket层选项,SO_REUSEADDR表示启用地址复用,而opt = 1则表示开启这个选项。

注意,这段代码通常需要放在 **bind()**之前执行。

例如:

cpp 复制代码
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

int opt = 1;
if (setsockopt(listenfd, SOL_SOCKET,
               SO_REUSEADDR, &opt, sizeof(opt)) < 0)
{
    perror("setsockopt");
    close(listenfd);
    return -1;
}

// 后续继续进行 bind()、listen() 等操作

这样一来,即使之前的部分TCP连接仍然处于TIME_WAIT状态,新服务器也有机会重新绑定原来的监听地址和端口,而不必单纯等待所有TIME_WAIT连接结束。

这对于需要频繁重启、重新部署的TCP服务器来说,还是非常实用的。

不过,这里一定要理解清楚:

SO_REUSEADDR并不是无条件强制抢占端口!

如果已经有其他Socket正在监听相同的地址和端口,那么即使设置了SO_REUSEADDR,新的服务器通常也不能直接绑定并取代它。

另外,在Linux中,要满足相关地址复用条件,通常要求之前绑定该端口的程序和新程序都设置过SO_REUSEADDR。因此,养成在服务器初始化时设置这个选项的习惯非常重要。

还有一点,SO_REUSEADDR和SO_REUSEPORT并不是同一个东西。

SO_REUSEADDR主要用于放宽地址绑定限制,帮助解决部分地址复用问题;而SO_REUSEPORT则可以在满足条件时,允许多个Socket绑定相同的地址和端口,常用于多进程、多线程服务器的连接处理和负载分配。

这两个选项虽然名字相似,但解决的问题并不完全相同,不能混为一谈。说到这里,我们再回过头来看TIME_WAIT。

TIME_WAIT本身并不是一个应该被随意消除的状态,它是 TCP 正常连接管理机制的一部分。

它既能为最后一次ACK的重传处理提供机会,也能降低旧连接残留报文对后续通信的影响。

而SO_REUSEADDR的意义,并不是强行取消TIME_WAIT,而是在符合操作系统绑定规则的情况下,让服务器能够更加灵活地复用本地地址。

所以,实际开发中,我们既要理解TIME_WAIT为什么存在,也要学会利用SO_REUSEADDR等Socket选项,解决服务重启过程中可能遇到的端口绑定问题。

TCP的协议机制负责维护连接的正确性,而Socket选项则为应用程序提供了更灵活的资源管理方式。两者并不冲突,而是相互配合。

1.6 深入理解TIME_WAIT:为什么需要等待2MSL?
1.6.1 MSL与2MSL:等待时间的含义及设计目的

前面我们已经知道,TCP主动关闭连接的一方,在正常完成四次挥手后,并不会立即进入CLOSED状态,而是需要经历一段TIME_WAIT。

那么问题来了:为什么要等待?又为什么偏偏是2MSL呢?

这里先介绍一个概念------MSL(Maximum Segment Lifetime,最大报文段生存时间)。

MSL可以理解为TCP协议在设计时,对一个报文段在网络中可能存活的最长时间所作的假设。

而2MSL,顾名思义,就是两个MSL的时间。

按照TCP的经典设计,主动关闭连接的一方需要在TIME_WAIT状态中等待2MSL,才会最终进入CLOSED状态。

这段等待时间可不是随便规定的,它主要解决两个问题:让旧连接中残留的报文有时间从网络中消失,以及为最后一次ACK的丢失提供补救机会。

大家想一想,TCP连接虽然已经完成四次挥手,但这是否意味着之前发送的所有报文都已经从网络中彻底消失了?当然不一定!

有些报文可能因为网络拥塞、传输延迟等原因,还滞留在网络中的某个位置。如果旧连接刚刚关闭,我们就立即建立一条具有相同四元组的新连接,那么这些迟到的旧报文,就可能干扰新连接的正常通信。

TIME_WAIT的意义,就是通过保留旧连接状态,并等待足够长的时间,降低这些历史报文造成混乱的风险。

不过需要注意,2MSL是TCP协议中的经典等待规则,实际操作系统可能采用具体的实现策略。例如,Linux常见的TIME_WAIT保留时间约为60秒。

1.6.2 历史报文的干扰:从网络中的"流弹"理解连接安全

TIME_WAIT究竟是如何避免旧报文干扰新连接的呢?

我们不妨通过一个比较极端的场景来理解。假设客户端和服务器已经建立TCP连接,双方正在正常传输数据。客户端发送了一个数据报文段,可这个报文段在网络中遇到了拥塞,迟迟没有到达服务器。

注意,它并没有立即丢失,而是一直滞留在网络中。

由于客户端长时间没有收到对应的确认应答,TCP可能触发超时重传,再次发送相同序列号范围的数据。

这次重传的数据成功到达服务器,服务器也正常回复了ACK。于是,双方继续通信,最终完成了连接关闭。

但问题来了:之前那个在网络中滞留了很久的旧报文段,真的已经消失了吗?

不一定!只要它还没有超过网络允许的生存时间,就仍有可能继续向目标主机传输。

为了方便理解,我们把这种迟迟没有到达目的地的旧报文,称为网络中的一颗**"流弹"**。

现在我们再假设一种情况:客户端和服务器刚刚关闭旧连接,紧接着又建立了一条新连接,而且新旧连接恰好使用了完全相同的四元组:

源IP地址

源端口号

目的IP地址

目的端口号

就在新连接开始传输数据的时候,那颗迟到的"流弹"突然到达了服务器。

你说,这个时候服务器应该把它当成旧连接的数据,还是新连接的数据呢?

如果没有额外的保护机制,而且旧报文的序列号又恰好落入新连接能够接受的序列号范围内,就可能发生新旧数据混淆。

当然,这并不是说旧报文只要到达,就一定能够破坏新连接。TCP 还会根据序列号、接收窗口等信息检查报文是否合法。

真正危险的是:旧报文不仅使用了相同的四元组,还碰巧满足了新连接的相关接收条件。

虽然这种情况发生的概率不高,但它揭示了一个问题:

连接关闭,并不等于属于这条连接的所有历史报文都已经从网络中消失。

所以,TCP才需要TIME_WAIT。在经典的2MSL等待机制下,操作系统会继续保留旧连接的相关状态,为那些可能迟到的报文留出消失的时间,同时避免过早复用相同的连接标识。

这样一来,就能够大幅降低旧连接中的"流弹"干扰新连接的风险。

不过,这里还需要区分一点:TIME_WAIT保护的是正常关闭过程中保留下来的连接状态,并不意味着系统异常重启后,原来的TIME_WAIT状态一定还存在。

如果操作系统崩溃并丢失了连接状态,就还需要依靠初始序列号选择等机制,降低历史报文造成混淆的可能性。这也正好引出了我们后面要讲的ISN机制。

1.6.3 TIME_WAIT的核心作用:确保连接可靠终止并隔离旧报文

讲到这里,我们已经知道,TIME_WAIT的一个重要作用,就是防止旧连接中的延迟报文干扰后续连接。

那么,它又是如何发挥这个作用的呢?

在TCP中,一条连接通常由以下四个信息共同标识:源IP、源端口、目的IP、目的端口。

也就是说,只有这四个信息组合起来,才能准确区分一条TCP连接。如果旧连接还处于TIME_WAIT状态,操作系统就可以根据保留的连接信息,对具有相同四元组的新连接建立进行限制或检查。

这并不是简单地禁止某个IP或端口被使用,而是要避免新旧连接之间产生冲突。

所以,不能认为只要服务器存在TIME_WAIT,就一定无法使用原来的监听端口重新启动。

前面讲过的SO_REUSEADDR,就可以在符合系统绑定规则的情况下,帮助服务器重新绑定地址和端口。

那么,TIME_WAIT除了防止网络"流弹",还有没有其他作用呢?

当然有!而且,第二个作用其实更加容易理解------为最后一次 ACK 的丢失提供补救机会。

我们再回顾一下四次挥手。

假设客户端主动关闭连接,服务器最后向客户端发送FIN。客户端收到FIN后,回复最后一次ACK,并进入TIME_WAIT。可如果这个ACK在返回途中丢失了呢?服务器由于没有收到确认,就可能在超时后重新发送FIN。

如果客户端发送完ACK就立即进入CLOSED,彻底删除了旧连接的状态,那么服务器重传的FIN到达后,客户端就无法再按照原来的连接状态正常处理它。

所以,客户端需要在TIME_WAIT中继续保留一段时间的连接信息。

如果在等待期间收到服务器重传的FIN,就可以再次回复ACK,帮助服务器完成连接关闭。

从经典设计的角度看,2MSL为最后一次ACK的传输,以及可能发生的FIN重传及其处理,提供了一个保护时间窗口。

不过,这里不能简单地理解为两个报文的传输时间一定恰好等于2MSL。MSL也不是网络的往返时间RTT。

另外,还有一个比较有意思的地方:

如果客户端处于TIME_WAIT状态,却一直没有收到服务器重传的FIN,是不是就能证明最后一次ACK一定成功到达了?

其实不能!

因为服务器没有重传FIN,或者客户端没有收到FIN,并不只有"ACK成功到达"这一种可能。

比如,服务器可能因为超时而放弃重传,或者重传的FIN本身也发生了丢失。所以,更准确的理解应该是:TIME_WAIT让主动关闭方保留处理重传FIN的能力,而不是用"有没有收到消息"来百分之百判断最后一次ACK是否成功到达。

理解了这两个作用,我们就能发现,TIME_WAIT看起来只是一个等待状态,实际上却在为TCP连接的正确终止提供保护。

1.6.4 随机初始序列号ISN:降低历史报文干扰的辅助机制

那么问题又来了:

既然TIME_WAIT这么重要,难道我们每次重新建立连接,都只能老老实实等待旧连接的报文彻底消失吗?

当然也不是。

TCP还有其他机制,可以进一步降低历史报文造成混乱的风险。

其中一个非常重要的机制,就是初始序列号(Initial Sequence Number,简称ISN)的选择。

还记得我们之前讲序列号时提到过,TCP会对字节流中的每一个字节进行编号吗?

但这里有一个问题:每次建立新连接时,序列号都是从0或者1开始的吗?

显然不是!TCP建立连接时,客户端和服务器会分别选择自己的初始序列号,再通过三次握手完成同步。

比如,我们用一个简单的例子来理解:

cpp 复制代码
第一次建立连接:

客户端初始序列号ISN = 1000

第二次建立连接:

客户端初始序列号ISN = 1428

注意,这里的数字只是为了方便说明,并不意味着TCP的初始序列号必须按照固定步长增加。

现代TCP通常采用包含时间变化因素和不可预测因素的算法生成ISN,以降低序列号被预测以及旧报文被误接收的风险。

这时候,我们再来考虑网络中的"流弹"。

假设旧连接中的某个数据报文段,携带的序列号是7000。但新连接已经建立完成,接收方当前期望的下一个字节序列号为1000,并且假设当前接收窗口大小为5000字节。

那么,在这个简化的例子中,当前允许接收的数据序列号范围就是:

cpp 复制代码
接收方期望序列号:1000

接收窗口大小:5000字节

有效接收范围:1000 ~ 5999

如果这时突然收到一个Seq = 7000、且数据范围完全处于接收窗口之外的报文段,会怎么样?

接收方就不会把这段数据直接当作当前可以接收的有效数据。

因为它的序列号不在当前允许接收的窗口范围内。

通过这种方式,TCP就能够识别并拒绝一部分不符合当前连接状态的报文。

不过,大家一定要注意:

ISN不同,并不意味着旧报文就绝对不可能落入新连接的有效接收窗口。

因为TCP的序列号是32位的,存在回绕问题。随着数据不断传输,序列号范围也会持续变化。

所以,初始序列号的选择只能降低新旧数据混淆的风险,不能简单地认为只要每次使用不同的ISN,就能够完全替代TIME_WAIT。

在现代高速网络中,TCP还可以借助时间戳选项以及PAWS(Protection Against Wrapped Sequence Numbers,防止序列号回绕)等机制,进一步识别可能存在的旧报文。

说到底,TIME_WAIT、初始序列号选择以及TCP时间戳等机制,并不是彼此孤立的设计,而是在不同层面共同降低历史报文干扰连接的风险。

前者通过保留连接状态和等待时间进行保护,后者则通过序列号和时间信息,帮助接收方识别不属于当前有效通信范围的报文。

这也是TCP在连接管理上考虑得非常细致的地方。

讲到这里,我们对TCP的连接管理机制就算有了比较完整的认识。

从三次握手建立连接,到四次挥手结束通信,再到CLOSE_WAIT、TIME_WAIT等状态背后的设计原因,这些看似复杂的机制,其实都是为了让TCP能够更加可靠地管理通信双方的连接状态。

前面我们重点分析了CLOSE_WAIT和TIME_WAIT这两个比较容易出问题、也经常出现在面试中的状态。至于TCP连接管理过程中其他状态的变化,我们再通过下面的状态转换图,统一梳理一遍。

二、TCP连接状态转换:从建立连接到彻底关闭

前面我们已经详细分析了TCP的三次握手、四次挥手,以及CLOSE_WAIT、TIME_WAIT等状态背后的设计原因。

不过,TCP的连接状态可不止这几个。

从建立连接到正常传输数据,再到最终断开连接,TCP都需要通过不同的状态来管理整个通信过程。

那么,这些状态究竟是如何转换的呢?我们结合上面的TCP状态转换图,以客户端主动建立并关闭连接、服务器被动接受并关闭连接为例,把整个过程梳理一遍。

2.1 服务端状态转换:从LISTEN到CLOSED

对于服务器而言,TCP连接通常会经历以下几个状态:

(1)CLOSED → LISTEN:准备接收连接

服务器创建Socket,完成地址绑定后,调用listen(),让监听Socket进入LISTEN状态。

此时,服务器已经做好了接受连接请求的准备,等待客户端发送SYN。

(2)LISTEN → SYN_RCVD:收到连接请求

当服务器收到客户端发送的SYN报文段后,内核会为这个尚未建立完成的连接维护相应状态,并向客户端回复SYN + ACK。

这时候,对应的连接进入SYN_RCVD(也常写作SYN_RECEIVED)状态。

注意,进入SYN_RCVD的是正在建立的连接,而不是原来的监听Socket。监听Socket仍然保持LISTEN状态,继续接收其他客户端的连接请求。

(3)SYN_RCVD → ESTABLISHED:连接建立成功

当服务器收到客户端第三次握手发送的有效ACK后,对应连接就会进入ESTABLISHED状态。

这说明TCP三次握手已经完成,双方可以正常进行数据通信。随后,服务器应用程序就可以通过accept()获取已经建立好的连接,并使用返回的 Socket 进行读写。

(4)ESTABLISHED → CLOSE_WAIT:对方主动关闭

假设客户端不再需要发送数据,主动向服务器发送 FIN。

服务器收到FIN后,会回复ACK,并进入CLOSE_WAIT状态。

这个状态表示客户端已经结束了自己的发送方向,但服务器仍然可以继续向客户端发送剩余数据。

至于服务器什么时候结束自己的发送方向,还需要由应用程序决定。

(5)CLOSE_WAIT → LAST_ACK:服务器准备关闭

当服务器完成剩余数据的处理,也决定关闭自己的发送方向时,就可以调用close()或shutdown(sockfd, SHUT_WR)。

在正常的TCP关闭流程中,内核会发送FIN,通知客户端服务器也已经结束发送。

此时,服务器进入LAST_ACK状态,等待客户端对这个FIN的最终确认。

(6)LAST_ACK → CLOSED:连接关闭完成

当服务器收到客户端返回的有效ACK后,就可以完成连接关闭,进入CLOSED状态。

至此,服务器这一侧的TCP关闭流程也就结束了。

2.2 客户端状态转换:从SYN_SENT到CLOSED

客户端的状态变化与服务器有所不同。因为在这个例子中,客户端既是主动建立连接的一方,也是主动断开连接的一方。

我们继续来看。

(1)CLOSED → SYN_SENT:发起连接请求

客户端创建Socket后,调用connect(),由操作系统内核向服务器发送SYN,主动发起TCP连接。

此时,客户端进入SYN_SENT状态,等待服务器返回SYN + ACK。

(2)SYN_SENT → ESTABLISHED:完成三次握手

客户端收到服务器返回的有效SYN + ACK后,会回复ACK,并进入ESTABLISHED状态。

到这里,客户端就认为TCP连接已经建立成功,可以开始正常通信。对于阻塞式Socket,connect()通常会在连接建立成功或发生错误后返回。

(3)ESTABLISHED → FIN_WAIT_1:主动发起关闭

假设客户端完成了数据传输,不再需要继续向服务器发送数据,于是调用close()。

在正常关闭流程中,客户端内核会发送FIN,并进入FIN_WAIT_1状态。

注意,发送FIN只表示客户端结束了自己的发送方向,并不意味着服务器也已经完成数据发送。

(4)FIN_WAIT_1 → FIN_WAIT_2:收到FIN的确认

当客户端收到服务器对自己FIN的有效ACK后,就会进入FIN_WAIT_2状态。

这时候,客户端已经知道服务器收到了自己的关闭通知。

但服务器可能还有数据需要发送,所以客户端还需要等待服务器的 FIN。

(5)FIN_WAIT_2 → TIME_WAIT:收到服务器的FIN

当服务器也完成数据发送,向客户端发送FIN时,客户端收到后会回复最后一次ACK,并进入TIME_WAIT状态。

这里有一个特别容易写错的地方:

客户端发送的是ACK,而不是LAST_ACK。LAST_ACK是被动关闭方等待最终确认时所处的状态。

主动关闭方在正常的四次挥手流程中,收到对方FIN并回复ACK后,进入的才是TIME_WAIT。

(6)TIME_WAIT → CLOSED:等待结束

客户端进入TIME_WAIT后,并不会立即彻底结束这条 TCP 连接的状态管理。

按照TCP的经典设计,它还需要等待2MSL,为最后一次ACK可能丢失后的重传处理,以及网络中残留的旧报文提供保护时间。

等待结束后,连接才会进入CLOSED状态。至此,客户端的TCP连接关闭流程才算真正完成。

上面这张图,实际上就是对TCP连接整个生命周期的一次汇总。

其中,实线主要表示主动打开、主动关闭一方的状态变化,虚线则用于表示被动打开、被动关闭等相关路径。

不过,大家需要注意,客户端和服务器并不是天然对应固定的一套状态。

客户端也可以被动关闭连接,服务器同样可以主动发送FIN。究竟会经历哪些状态,取决于连接双方实际执行的操作,以及收到的TCP报文段。

另外,除了我们上面讲解的常规关闭流程,TCP还存在CLOSING等状态。

比如,当双方几乎同时主动关闭连接,彼此都发送了FIN,就可能进入同时关闭的处理流程,而不再是前面那种典型的四次挥手顺序。

至于CLOSED,它在TCP状态机中表示当前不存在有效连接。从协议模型来看,它是一个概念性状态,并不意味着内核必须始终为每条已经关闭的连接保留相应的数据结构。

所以,学习TCP状态转换,最重要的不是死记每个状态的名字,而是弄明白:当前收到了什么报文,发送了什么报文,以及为什么需要从一个状态切换到另一个状态。

只要把三次握手、四次挥手和这张状态转换图联系起来理解,整个TCP连接管理过程其实就清晰多了。


文章就写到这里啦!如果觉得这篇文章对你有所帮助,别忘了点赞、收藏、关注,支持一下! 你的每一次三连,都是我持续更新的动力。我们下篇文章再见!

相关推荐
Felven31 分钟前
华为鲲鹏920s芯片serdestool工具使用说明
linux·运维·服务器·华为·鲲鹏920·serdestool
江湖有缘37 分钟前
Docker实战 | 使用Docker部署lemonitor镜像站点列表程序
运维·docker·容器
LabVIEW开发1 小时前
Omron PLC:接 LabVIEW 常踩的 5 个坑
服务器·网络·labview·labview知识·labview功能·labview程序
莫晓燚1 小时前
mall 电商项目完整部署---实战
linux·运维·docker·容器
南京码讯光电技术有限公司1 小时前
工业5G/4G路由器怎么选?核心参数解析与工业级网关选购指南
网络·智能路由器
爱和冰阔落1 小时前
【Linux网络】JsonCpp 怎么实现序列化?从 Request/Response 到网络计算器消息设计
linux·运维·网络·php
Decay__1 小时前
01_SQLServer 体系架构全解 —— 实例进程、关系引擎、SQLOS 与事务日志深入
运维·数据库·sql·mysql·架构
自信150413057592 小时前
重生之从0开始学习c++之——vector-中篇
c++·学习·算法
ShineWinsu5 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial