前言

学习 TCP 协议,我们一定要理解一个概念我们如何在内核中理解连接

在实际的场景中, c l i e n t : s e r v e = N : 1 client : serve = N : 1 client:serve=N:1。也就是意味着,一个服务端中可能需要保持的连接是非常多的。而传输层协议是在内核中实现的,那么内核就需要为每一个上层服务程序管理这里建立的连接

  • 在每一次建立连接中,操作系统都会为每一个连接分配资源维持连接是会付出代价的

本章节的主题就是来理解TCP建立通信的过程

1. 基于 TCP 的通信过程

  • 总结 TCP 连接建立连接断开连接的过程如下图:

    在这里插入图片描述

    一般而言都是由客户端主动发起建立连接的请求。客户端仅仅是被动接受。而断开连接的过程双方都可以主动,下面我们以客户端主动断开连接为例

在进行 TCP 建立通信之前,一般都是由服务端进行 accpet 阻塞式获取下层建立好的连接。上图能够很好地说明 TCP Socket 和建立 TCP 通信之间的过程

1.1 TCP 三次握手过程

当然我们可以在进行三次握手的过程中就协商好 滑动窗口 的大小。

  • TCP 三次握手的过程:

    第一次握手(SYN):

    1. 客户端发送 SYN:当客户端调用函数 connect 向客户端发送连接请求,客户端会选择一个初始序列号 seq=x,同时发送 SYN 包给服务端。这个包进包含 SYN 标志位,并且表示客户端i请求建立连接

      客户端状态:在发送完成只会,客户端会进入 SYN_SENT,等待服务端进行响应。

    第二次握手(SYN-ACK):

    1. 服务端收到 SYN 并响应 SYN-ACK:服务器受到客户端的 SYN 包之后,会选择一个初始化序列号 seq=y,并发送一个 SYN-ACK 包给客户端。SYN-ACK 包中包含服务端的序列号 seq=y 以及对客户端的 SYN 的确认序号 x+1

      服务端状态服务端进入 SYN_RCVD 状态,等待客户端进行确认。实际上在服务端受到来自客户端的第一次 SYN 请求的时候,服务端就会为这次连接做出资源准备,构建一个半连接的状态的队列进行管理(这个半连接的状态不会长期维持)。

    第三次握手(ACK):

    1. 客户端确认连接:客户端收到服务器的 SYN-ACK 包之后,客户端会发送一个 ACK 包给服务端。ACK 包中包含对服务器序列号的确认序号 y+1,表示客户端已经收到了来自服务端的 SYN 包。

      客户端状态:当客户端收到服务端的 SYN-ACK 后,内核就会为该连接维护资源。同时客户端的状态会被更改为:ESTABLISHED

      服务端状态:当服务端最后收到了来自客户端的 ACK 应答之后,内核会将之前创建的连接资源移动从半连接队列至一个全连接状态队列,等待上层获取。同时服务端的状态会被更改为:ESTABLISHED

    双方状态都成为:ESTABLISHED。那么双方就可以开始进行通信了。


为什么需要进行三次握手?为什么不能是二次?

  • 三次挥手的核心目的

    1. 验证全双工。三次握手的过程能够确保客户端服务端双方都是“可靠”的发送和接受。

    2. 确认同步序号。序列号是保证数据能够顺序到达的可靠性手段。

    3. 建立连接保证资源不造成浪费。简单来说:为了防止在一切极端场景下的重复连接造成的资源浪费

其中最核心的一点就是为了避免资源在服务端造成浪费,在进行两次握手的情况下考虑下面一个场景:

客户端发送了一个 SYN 包,请求向服务端进行连接。但是由于网络原因导致,该请求在网络中滞留,没能及时送到服务端。由于TCP协议存在超时重传机制,当后面客户端又重新发送了一个 SYN 包,但是后发的一次很顺利,当服务端收到了 SYN 后马上就创建了连接资源(连接资源是需要长期维持的),然后通信,断开连接。那么问题在于:当之前的滞留的 SYN 请求现在来到了服务端,服务端以为客户端又要进行连接,于是就创建连接资源。但是这次的连接资源是被浪费的!

结果:服务端白白浪费了资源,空等一个永远不会进行通信的连接资源。如果这种情况大量发送就会导致服务端这边的资源浪费严重,导致无法处理新来临的连接。

结论

  1. 三次握手能够成功验证全双工通信信道的可靠性。这是最低成本的验证可靠性的次数。

  2. 三次握手能够保证服务端不会受到来自客户端旧的连接请求的干扰,从而避免造成资源的浪费

    (还有一些说法就是:连接状态不一致)

SYN Flood

上面小编谈到了:当进行 TCP 第二次握手的时候内核会申请资源维持半连接状态。这是一种利用 TCP 三次握手缺陷发起的 DDoS 攻击,其核心就是伪造大量的 SYN 请求,耗尽服务端的半连接队列的资源,从而导致正常连接无法成功建立

大致过程就是:伪造大量的虚假 IP 向服务端发起 SYN 请求。这些 SYN 请求服务端都会创建半连接资源,但是通常不是长久维持的(所以需要大量)。在一定的时间段类,正常的连接请求就会失败。

解决方案:

  1. SYN Cookie
  2. 调整半连接超时时间
  3. ……

1.2 TCP 四次挥手过程

我们以客户端主动断开连接为例。下面的过程我们就省略序列号的发送了。在进行四次挥手的过程我们仍然需要保证有序性

当我们的客户端进行 close(fd) 的时候,这个时候内核就知道客户端需要断开连接,那么就会进行 TCP 的四次挥手过程。

  • TCP 四次挥手的过程:

    第一次挥手(FIN):

    1. 客户端发送 FIN:客户端选择向服务端发送一个 FIN 包。这个包表示当前客户端已经没有数据需要发送,准备关闭连接

      客户端状态客户端会进入 FIN_WAIT_1 状态,等待服务器响应。

    第二次挥手(ACK):

    1. 服务端发送ACK:服务端收到来自客户端的 FIN 包之后,会发送一个 ACK 包给客户端表示已经收到 FIN 关闭客户端连接的请求了

      服务端状态:服务端会进入 CLOSE_WAIT 状态,这表示 服务器仍然可能有数据需要发送给客户端

      客户端状态:客户端收到来自服务端的 ACK 之后,就会进入 FIN_WAIT_2 状态。

    第三次挥手(FIN):

    1. 服务端发送FIN:当服务端将自己发送缓冲区中的数据发送给客户端之后服务端会向客户端发送一个 FIN 包表示服务端也需要关闭连接

      服务端状态:服务端会进入 LAST_ACK 状态,等待客户端确认,断开连接。

    第四次挥手(ACK):

    1. 客户端发送ACK:客户端收到服务端的 FIN 包之后,会发送一个 ACK 包告知服务端确认关闭连接

      客户端状态客户端在收到来自服务端的 FIN 之后,就会进入 TIME_WAIT 状态,在这个状态下,等待 2 ∗ M S L 2*MSL 2MSL(2 * 最大报文生存时间) 后关闭连接进入 CLOSE 状态,确保连接彻底断开。

      服务端状态:当服务端收到ACK包之后,就会进入 CLOSE 状态,连接断开。


为什么需要四次挥手,不能是三次?

  • 重要认识

    TCP 网络通信是全双工的这就意味着数据可以在两个方向上独立传输和关闭

    所以,在进行通信信道的关闭的时候,就需要通信的双方都主动发起一个 FIN。从而关闭自己到对方的数据通道

  • 为什么不能是三次?

    注意:和上面的例子都是一样的,小编是以客户端为先退出的一方。如果服务端先退出是同理的!

    其实小编在上面谈四次挥手的过程中也是强调了这一点。在服务端收到了来自客户端的 FIN 请求之后发出 ACK 并不能同时发送 FIN,这是因为服务端可能还有数据没有发送完成,这样就不能立即关闭服务端的信道。(并且在合理的范围内,客户端仍然可以收到这个数据)。

    • 所以:第二次挥手(ACK)和第三次挥手(FIN)之间,是服务端处理完自己的待发数据的时间。这两个报文时代表两个独立的时间,因此通常无法合并。只有在服务端没有任何数据要发送的极端情况下,它们才有可能进行合并,但是协议的设计需要考虑通用性

为什么需要TIME_WAIT状态?设计为2MSL原因?

在第四次会受到,客户端发送 ACK 之后,并不会立即关闭,而是需要进入 TIME_WAIT 状态等待 2 ∗ M S L 2*MSL 2MSL 的时间。这里主要有两个原因

  1. 可靠地终止连接:确保服务端能够受到最终的 ACK,如果客户端的 ACK 在网络中丢失了,那么服务端就可能触发超时重传机制,重新发送 FIN 请求。由于维持了 TIME_WAIT 状态,所以客户端在收到来自服务端的 FIN 请求之后还可以重新发送 ACK。这样就能保证可靠地终止连接。

  2. 让旧连接的数据得以消散:防止之前延迟的报文段(网络传输是有延迟的)干扰新的、相同的四元组(源IP、源端口号、目的IP、目的端口号)


2. Socket 与 TCP 连接

上面,我们宏观地了解了 TCP 的通信过程。下面我们主要从应用层的 Socket 使用上来说明和下层 TCP 的关系。

2.1 Socket 和 三次握手

  1. 下层建立 TCP 连接和上层的 accept没有关系的。

    socket 和 bind 都是基本操作。

    通过我们上面的分析,我们也可以看到,重要的连接函数在于:服务端的 listen 和 客户端的 connect。即使上层我们不使用 accept,我们客户端和服务端双方仍然可以能够维持连接。

    结论:建立连接的过程是由双方的操作系统自动完成的。accept 仅仅是供上层获取数据/写数据 的 handler/入口 。

  2. listen 函数的第二个参数 backlog 表示允许建立好的连接的最大队列长度 + 1。

    在这里插入图片描述

    int listen(int sockfd, int backlog) 该函数对于下层的连接时密切相关的。在操作系统 Linux 内部会为我们 TCP 连接维护两个队列:全连接队列、半连接队列。

    在这里插入图片描述
    而这个 backlog 参数就指明了:当前全连接队列的最长长度为 backlog + 1

    注意:accept 函数就相当于在这个队列中拿数据。

    • 为什么这个对了需要指明长度,同时不能太长也不能没有

      • 如果队列维护过长,并且服务端比较忙碌。也就意味着连接占用的资源较多,上层来不及使用下层的连接资源,从而造成资源的浪费

      • 如果上层比较空闲,但是下层没有维护已经连接的队列,这就导致上层的服务端的资源不能得到充分利用。

      这是出于出于效率和资源的考虑

2.2 Socket 与 四次挥手

  • 上面我们谈到了:四次挥手的过程,客户端会进入 TIME_WAIT 状态。那是因为是客户端首先发起了 FIN 请求。但是如果是服务端优先发起 FIN 请求。那么在服务端断开连接的时候,就会进入 TIME_WAIT 状态。

    这就导致了一个问题:如果服务端因为一些故障问题导致连接断开,这个时候服务端就会出于 TIME_WAIT 这就会导致第一时间无法成功重启服务端。因为原来使用的 ip 和 port 正在被使用中!

所以,为了解决这样的问题,我们通常会设置 socket 的连接属性

在这里插入图片描述
通常设置的方式是:

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

3. TCP 连接异常的问题

TCP 连接异常由很多情况,但是我们主要就谈两种(在正常已经建立连接之后的异常情况):

  1. 客户端进程出现故障

  2. 客户端主机出现故障

这些检测机制主要还是依赖于应用层的心跳检测

3.1 TCP 的 Keepalive 机制

在了解 TCP 连接异常之前,我们需要认识 TCP 的保活机制。

  • 核心基础:TCP的保活机制

    这是一个可选的 TCP 特性,允许在一个连接处于空闲状态一段时间后,定期向对端发送特殊的探测报文

    目的:不是为了保持连接“活跃”,而是为了探测对端是否还存在(进程、主机、网络是否正常)。

  • 工作原理(以Linux为例,参数可配置):

    1. 空闲期:连接在 tcp_keepalive_time(默认7200秒,即2小时)内没有任何数据交换。

    2. 探测期:此后,每隔 tcp_keepalive_intvl(默认75秒)发送一个 Keepalive 探测包这个包不包含真实数据,序列号是对方预期的下一个序列号减一,目的是为了引发对端返回一个ACK确认。

    3. 重试次数:如果连续发送 tcp_keepalive_probes(默认9次)个探测包都没有收到ACK回复,则判定连接已断开。

    4. 总耗时:默认情况下,从连接空闲到最终断开,大约需要 7200s + 75s * 9 = 7875秒(约2小时11分钟)。

注意:Keepalive 默认是关闭的,因为它消耗额外的网络资源。在长连接应用(如游戏、IM)中,通常会由应用层自己实现心跳包,因为2小时的探测周期太长了。

3.2 异常连接

  1. 场景一:客户端进程异常终止(如崩溃、被杀死)
    这是最常见的情况。关键在于,操作系统内核(特别是网络协议栈)是正常的。

    • 客户端行为:

      进程终止时,所有打开的文件描述符(包括socket)会被内核自动关闭(文件的生命周期是随进程的)。

      内核会完成 TCP 连接的标准四次挥手过程。客户端会发送 FIN 包给服务端,表示我方没有数据要发送了。

    • 服务端响应:

      服务端内核收到 FIN 包后,会回复 ACK,表明已收到断开请求。此时,服务端连接的状态由 ESTABLISHED 变为 CLOSE_WAIT。服务端内核会通知服务端应用程序(通过 read 或 select/epoll 等IO多路复用机制)对方已经关闭了连接。

      应用程序的读取操作:当应用程序尝试从该 socketfd 读取数据时,会立即收到一个 EOF(End-Of-File),通常表现为 read() 返回0。这是服务端应用程序感知到客户端已关闭的最直接、最可靠的方式。随后,服务端应用程序应该调用 close() 来关闭自己的socket,这会触发服务端发送 FIN 包,完成最后的挥手,连接彻底关闭。

    小结:进程异常但主机正常时,连接会通过标准的四次挥手优雅关闭,服务端能立即通过 read() 返回0感知到。

  2. 场景二:客户端主机故障(如断电、系统崩溃)

    这种情况比进程终止更严重,因为内核也停止了,无法发送任何TCP报文(如 FIN 或 RST)。

    服务端视角:服务端完全不知道客户端已经“消失”。它会认为连接仍然处于 ESTABLISHED 状态。如果服务端不主动发送数据,连接会一直僵死在那里

    服务端的发现机制

    • 机制A:服务端主动发送数据

      如果服务端尝试向这个“僵死”的连接发送数据,内核会持续重传。重传次数和间隔由系统参数决定(如 net.ipv4.tcp_retries2,默认约15次重试,总时长约13-30分钟)。在多次重传失败后,内核会判定连接已失效,将连接状态置为未完成队列中,并最终清除。应用程序会在下一次写操作时收到一个错误(如 ETIMEDOUT 或 EPIPE 伴随 SIGPIPE 信号)。

    • 机制B:启用TCP Keepalive

      如果启用了Keepalive,在连接空闲超过设定时间后,服务端内核会开始发送探测包。由于客户端主机宕机,不会回复任何响应(ACK或RST)。服务端在经过预设次数的重试后,会判定连接死亡,并通知应用程序。

    小结:客户端主机故障时,服务端无法主动感知,必须依赖主动发送数据或开启 Keepalive 机制才能发现,这个过程可能需要数分钟到数十分钟。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐