目录

前言

一、为什么 TCP 需要建立连接

1.1 TCP 是面向连接的协议

1.2 连接到底是什么

1.3 建立连接时必须要解决的问题

1.3.1 确认双方都具备通信条件

1.3.2 同步双方的初始序号

1.3.3 协商 TCP 通信所需要的参数

1.4 小结

二、TCP 三次握手

2.1 第一次握手

2.2 第二次握手

2.3 第三次握手

2.4 为什么需要三次握手

2.5 三次握手过程中的 TCP 状态变化

2.6 listen()、connect()、accept() 系统调用

三、TCP 四次挥手

3.1 为什么 TCP 需要四次挥手

3.2 第一次挥手

3.3 第二次挥手

3.4 第三次挥手

3.5 第四次挥手

3.6 为什么需要 TIME_WAIT

3.7 TIME_WAIT 的等待时间

思考:四次握手和三次挥手

四、TCP 的其他特性与应用

4.1 面向字节流

4.2 粘包问题

4.3 TCP 异常情况

4.4 基于 TCP 的应用层协议

4.5 用 UDP 实现可靠传输(经典面试题)


前言

在前两篇文章中,我们分别从 TCP 报文格式TCP 数据传输机制两个方面,对 TCP 协议进行了深入介绍。

在第一篇文章中,我们从 TCP 报头入手,介绍了 TCP 报文中的各个字段,以及序号、确认序号、窗口大小、标志位等重要字段的作用。

在第二篇文章中,我们进一步研究了 TCP 建立连接之后的数据传输过程,介绍了确认应答、超时重传、滑动窗口、流量控制、拥塞控制、延迟应答、捎带应答等机制,理解了 TCP 是如何在可靠性与传输效率之间进行平衡的。

但是,到目前为止,我们讨论的都是:

TCP 连接建立之后,数据是如何可靠、高效地传输的。

而在真正进行数据传输之前,还有一个问题需要解决:

TCP 连接究竟是如何建立起来的?

TCP 是一个面向连接的传输层协议。通信双方在正式传输数据之前,需要先建立连接;当数据传输完成后,也需要按照一定的流程关闭连接。

因此,本篇文章将作为 TCP 系列的最后一篇,重点介绍 TCP 的连接管理机制,深入理解:

  • TCP 为什么需要三次握手?
  • 三次握手具体做了什么?
  • TCP 连接建立过程中,双方的状态如何变化?
  • TCP 为什么需要四次挥手?
  • 为什么 TCP 关闭连接后还需要等待 TIME_WAIT
  • TCP 的各种连接状态分别代表什么?

通过本篇文章,我们将从连接建立、数据传输到连接关闭,完整串起 TCP 的整个生命周期。

一、为什么 TCP 需要建立连接

1.1 TCP 是面向连接的协议

TCP 是一个面向连接的、可靠的字节流传输协议

所谓面向连接,指的是 TCP 在正式传输数据之前,通信双方需要先建立连接。

例如客户端需要向服务器发送数据:

客户端                              服务器

        建立 TCP 连接
        ────────────────→
        ←────────────────
        ────────────────→

        TCP 连接建立完成

        发送数据
        ────────────────→
        ←────────────────

只有当 TCP 连接建立完成之后,双方才会正式进行数据传输。

而当通信完成之后,双方也不能简单地认为“数据发完了,就结束了”,而是需要按照 TCP 规定的流程关闭连接,释放连接所占用的资源。

因此,TCP 的一次完整通信可以简单理解为:

建立连接 -> 数据传输 -> 关闭连接

这也是 TCP 与 UDP 一个非常重要的区别:UDP 是无连接的。

1.2 连接到底是什么

所谓“面向连接”并不是说 TCP 在通信双方之间建立了一条真实存在的物理线路。而网络中的数据依然是通过 IP 网络进行转发的,中间经过路由器、交换机等网络设备到达目标主机。

直接抛出结论:TCP 所建立的“连接”,本质上是一种由通信双方操作系统维护的、有状态的通信关系。

我们可以先从最简单的角度理解。

假设客户端:

IP:192.168.1.10
端口:50000

服务器:

IP:192.168.1.20
端口:8080

那么客户端与服务器之间的 TCP 通信可以通过下面四个信息确定:

源 IP       源端口       目的 IP       目的端口
192.168.1.10  50000      192.168.1.20  8080

这四个信息被称为 TCP 连接的四元组

可以表示为:

192.168.1.10:50000
        ↓
192.168.1.20:8080

通过这个四元组,操作系统能够确定:

哪一台主机的哪个进程,正在与哪一台主机的哪个进程进行 TCP 通信。

但是要注意:

四元组只是标识一条 TCP 连接,并不等于 TCP 连接本身。

因为 TCP 是一个有状态的协议,连接建立之后,操作系统还需要维护大量与连接相关的信息。

例如我们前面已经学习过的:

TCP 当前状态
发送序号
确认序号
发送窗口
接收窗口
重传相关信息
拥塞控制相关信息
发送缓冲区
接收缓冲区
...

这些信息共同描述了:

当前这条 TCP 通信进行到了什么程度,以及接下来应该如何继续进行。

因此从操作系统的角度来看,一条 TCP 连接实际上对应着内核维护的一组连接状态和相关资源。

后面我们学习 TCP 状态转换时,会看到:

LISTEN
   ↓
SYN_SENT
   ↓
ESTABLISHED
   ↓
FIN_WAIT_1
   ↓
TIME_WAIT
   ↓
CLOSED

这些状态实际上就是 TCP 在整个连接生命周期中维护的不同状态。

所以,可以把 TCP 连接理解为:

通信双方围绕一次 TCP 通信建立起来的一套有状态的通信关系。

而连接管理,就是负责维护这套通信关系的生命周期:

建立
 ↓
维护
 ↓
关闭

1.3 建立连接时必须要解决的问题

理解了“面向连接”和“TCP 连接”之后,我们再思考一个问题:

为什么 TCP 不能直接开始传输数据,而必须先建立连接?

因为 TCP 是一个可靠的、有状态的字节流协议

在真正发送应用层数据之前,通信双方需要先完成一些必要的信息同步。

1.3.1 确认双方都具备通信条件

首先,客户端需要告诉服务器:

“我想和你建立 TCP 通信。”

服务器收到之后,也需要明确告诉客户端:

“我知道你想和我建立连接,并且我也准备好了。”

客户端还需要进一步确认:

“我知道你已经准备好了。”

只有经过这样的交互,双方才能真正建立起一致的连接状态。

这实际上就是后面三次握手要解决的问题之一。

所以三次握手并不是简单的:“你好 → 你好 → 你好”

而是在通过三次报文交互,让双方逐渐建立起对这条 TCP 连接的共同认知。

1.3.2 同步双方的初始序号

这个问题与上一篇文章中的序号和确认序号直接相关。

TCP 并不是随便给数据编号,而是在建立连接时为这次 TCP 通信确定一个初始序列号(ISN)

例如:

客户端初始序号:1000
服务器初始序号:5000

那么客户端后续发送的数据就会从自己的序号开始:

1000
1001
1002
1003
...

服务器发送的数据则从自己的序号开始:

5000
5001
5002
5003
...

因此,双方在正式传输数据之前,需要让对方知道自己的初始序号。这个过程就是序列号同步。而这也是三次握手中非常重要的一项工作。

后面我们分析三次握手的时候,会看到:

客户端 → 服务器:
SYN + 序号

服务器 → 客户端:
SYN + ACK + 序号 + 确认序号

客户端 → 服务器:
ACK + 确认序号

1.3.3 协商 TCP 通信所需要的参数

建立 TCP 连接时,双方还可以通过 TCP 报头中的选项字段交换一些通信参数。

例如:

  • MSS(最大报文段长度)
  • 窗口扩大因子
  • 时间戳等

这些参数会影响后续 TCP 数据传输。

例如我们前面学习过:TCP 的窗口大小与流量控制有关。

如果接收方能够接收的数据比较少,那么它就需要通过窗口相关信息告诉发送方:“我现在只能接收这么多数据。”

因此,在连接建立阶段,双方不仅是在“确认对方存在”,还会为后续的数据传输建立必要的通信参数和初始状态

1.4 小结

所以,TCP 在正式传输数据之前进行连接管理,并不是多此一举。

它需要通过连接建立过程完成:

              TCP连接建立
                   ↓
       ┌───────────┼───────────┐
       ↓           ↓           ↓
  确认通信双方   同步初始序号   协商通信参数
       ↓           ↓           ↓
       └───────────┼───────────┘
                   ↓
             建立连接状态
                   ↓
               数据传输

因此,我们可以把 TCP 建立连接理解为:

通信双方在正式传输数据之前,通过一系列报文交互,确认彼此的通信状态,并同步后续数据传输所需要的信息,从而建立一套双方都认可的 TCP 连接状态。

那么接下来最关键的问题就是:

TCP 到底是通过什么过程完成这些工作的?为什么偏偏是三次握手,而不是两次或者四次?

二、TCP 三次握手

在上一节中,我们已经知道,TCP 在正式传输数据之前,需要先建立一套双方都认可的连接状态。那么问题来了:TCP 是如何建立这套连接状态的?

答案就是:三次握手

三次握手的宏观理解就是 TCP 在建立连接时,通信双方之间进行的三次 TCP 报文交互。

整个过程可以详细表示为:

2.1 第一次握手

客户端首先向服务器发送一个 SYN 报文,表示:客户端希望与服务器建立 TCP 连接。

注:SYN 报文是指 SYN 标志位被置为 1 的 TCP 报文。

此时 TCP 报头中的:SYN = 1 Seq = x

注:x 是客户端随机选择的初始序列号。

服务器收到客户端发送的 SYN 报文之后,可以明确知道:

1. 有一个客户端希望与自己建立 TCP 连接

2. 客户端的初始序列号是 x

同时,客户端发送 SYN 后,会进入:SYN_SENT 状态。

即表示客户端已经向服务器发送连接请求,等待服务器的应答。

所以第一次握手之后:

客户端:CLOSED -> SYN_SENT  服务器:LISTEN

此时,TCP 连接还没有建立完成,因为服务器收到了客户端的请求,但客户端还不知道:

服务器是否收到了自己的请求,以及服务器是否同意建立连接。

所以还需要第二次握手。

2.2 第二次握手

服务器收到客户端发送的 SYN 后,如果同意建立连接,就会向客户端发送一个 SYN + ACK 报文。

这个报文同时完成两件事情:

1. 确认客户端的 SYN

服务器通过:ACK = x + 1 来告诉客户端:你的 SYN 我已经收到了

2. 告诉客户端服务器自己的初始序列号

服务器在第二次握手中,需要告诉客户端:服务器的初始序列号

假设服务器选择的初始序列号是 y:Seq = y

因此第二次握手的 TCP 报文时:

SYN = 1 ACK = 1 Seq = y + 1 Ack = x + 1

当服务器发送完 SYN + ACK 后,会进入:SYN_RECV 状态

即表示服务器已经收到客户端的连接请求,并且已经向客户端回复了连接请求。

而此时客户端收到第二次握手之后,可以知道:

服务器确实收到了我的 SYN,并且服务器也愿意建立 TCP 连接。

同时,客户端也获得了服务器的初始序列号 y。

但是此时还有一个问题:

服务器并不知道客户端有没有收到自己的 SYN + ACK。

所以还需要第三次握手。

2.3 第三次握手

客户端收到服务器发送的 SYN + ACK 后,需要向服务器发送一个 ACK 报文。

ACK = 1 Seq:x + 1 Ack = y + 1

其中:Ack = y + 1 表示客户端已经收到服务器序号为 y 的 SYN。

当服务器收到这个 ACK 后,服务器就可以确认:

客户端已经收到了我的 SYN + ACK。

此时双方已经完成了必要的信息同步,TCP 连接正式建立。

服务器和客户端均进入:ESTABLISHED 状态

此时 TCP 三次握手完成,就可以开始进行正式的数据传输。

2.4 为什么需要三次握手

对于 TCP 来讲,为什么恰好需要三次握手呢,为什么不是两次以及其他次数呢?

对于这个问题,让我们从双方需要确认的信息来看。

第一次握手:客户端告诉服务器

客户端 → 服务器

SYN
Seq = x

客户端告诉服务器:“我想和你建立连接,我的初始序列号是 x。”

服务器收到后,可以确认:客户端具备发送能力

但是客户端此时并不知道服务器是否收到了自己的 SYN。

第二次握手:服务器告诉客户端

服务器 → 客户端

SYN + ACK
Seq = y
Ack = x + 1

服务器告诉客户端:“你的 SYN 我收到了,这是我的初始序列号 y。”

此时客户端可以确认:服务器具备接收能力和发送能力

但是服务器还不知道客户端有没有收到自己的 SYN + ACK。

第三次握手:客户端再次确认

客户端 → 服务器
ACK
Ack = y + 1

客户端告诉服务器:“你的 SYN + ACK 我收到了。”

此时服务器也可以确认:客户端具备接收能力

于是双方就完成了确认:

客户端知道:
服务器能够接收我的数据
服务器知道:
客户端能够接收我的数据

所以三次握手实际上完成了一个非常重要的过程:

让客户端和服务器都确认对方具备正常通信的能力,并完成双方初始序列号的同步。

为什么不能是两次握手过程?

假设只有两次:

客户端                         服务器

SYN ─────────────────────────→
    ←──────────────────── SYN + ACK

此时服务器认为:

“我已经把 SYN + ACK 发给客户端了。”

但是服务器无法确认客户端是否真正收到了这个 SYN + ACK

如果客户端因为网络问题根本没有收到第二次握手,那么:

客户端:我没有建立成功
服务器:我以为你建立成功了

双方对于连接状态就可能产生不一致。

因此,还需要第三次 ACK,让服务器确认:

客户端已经收到我的 SYN + ACK。

所以:两次握手无法让服务器确认客户端已经成功接收到自己的响应。

这也是三次握手相比两次握手的重要意义。

为什么不能是其他次握手呢?

对于 TCP 三次握手,双方都已经确认对方具备正常通信的能力,并已经完成双方序列号的同步,准备工作已经完成,所以不需要额外的次数来进行 TCP 连接的建立。

思考:对于 TCP 三次握手的过程,难道只建立序列号的共识吗?

对于 TCP 报头中的 16 位窗口字段?选项字段?

2.5 三次握手过程中的 TCP 状态变化

注:每个状态在内核中的实现本质就是一个宏。

结合上图,可以看到,客户端和服务器在三次握手过程中经历了不同的状态。

第一次握手:客户端进入 SYN_SENT

最开始,客户端处于 CLOSED 状态,服务器处于 LISTEN 状态。

当客户端希望与服务器建立连接时,客户端向服务器发送一个 SYN 报文。

发送 SYN 后,客户端不能认为连接已经建立,因为它还没有收到服务器的确认。

因此,客户端进入:SYN_SENT

SYN_SENT 可以理解为:已经发送 SYN,正在等待服务器确认。

此时服务器仍然处于 LISTEN 状态,等待客户端的连接请求。

第二次握手:服务器进入 SYN_RECV

服务器在 LISTEN 状态下收到客户端发送的 SYN 报文。

服务器确认客户端确实希望建立连接后,会向客户端发送 SYN + ACK 报文。

发送之后,服务器同样不能认为连接已经建立,因为它还需要确认:客户端是否收到了自己发送的 SYN + ACK?

所以服务器进入:SYN_RECV

SYN_RECV 可以理解为:已经收到客户端的 SYN,也已经回复 SYN + ACK,正在等待客户端最后的 ACK。

与此同时,客户端收到服务器发送的 SYN + ACK 后,就已经能够确认:服务器收到了我的 SYN,并且同意建立连接。

因此客户端可以进入:ESTABLISHED

也就是说,客户端在收到第二次握手后,就已经认为连接建立成功了。

第三次握手:服务器进入 ESTABLISHED

客户端进入 ESTABLISHED 后,会向服务器发送第三次握手的 ACK。

服务器处于 SYN_RECV 状态,收到这个 ACK 后,就可以确认:

客户端已经成功收到我的 SYN + ACK。

至此,双方关于连接建立所需要确认的信息已经完成。

此时服务器可以进入:ESTABLISHED

最终双方都进入:ESTABLISHED

ESTABLISHED 表示:TCP 连接正式建立,双方可以开始进行数据传输。

整个状态变化过程

因此,三次握手可以从状态变化的角度概括为:

客户端:
CLOSED
   ↓ 发送 SYN
SYN_SENT
   ↓ 收到 SYN + ACK
ESTABLISHED
服务器:
CLOSED
   ↓ listen()
LISTEN
   ↓ 收到 SYN,发送 SYN + ACK
SYN_RECV
   ↓ 收到 ACK
ESTABLISHED

最终:

        三次握手完成
             ↓
客户端 ESTABLISHED
             ↕
服务器 ESTABLISHED
             ↓
        正式传输数据

三次握手的本质不仅仅是三个 TCP 报文的交互,同时也是客户端和服务器 TCP 状态逐步同步的过程。

2.6 listen()、connect()、accept() 系统调用

前面我们从 TCP 协议角度分析了 TCP 三次握手的整个过程,但是在实际的 Linux 网络编程中,我们并不会手动编写代码去发送这三个报文,那么服务器与客户端是如何完成 TCP 三次握手的呢?

对于 TCP 服务器代码的编写,通常会执行:

socket();
bind();
listen();
accept();

对于 TCP 客户端代码的编写,通常会执行:

socket();
connect();

这些系统调用和 TCP 三次握手到底是什么关系呢?本小节将为你揭晓答案。

(1) listen() :让服务器进入监听状态

服务器创建 socket 并完成 bind() 后,还不能直接接收客户端的连接请求。

服务器需要调用:listen(sockfd, backlog);

告诉操作系统:这个 socket 用来监听客户端的 TCP 连接请求。调用 listen() 后,服务器对应的 TCP socket 就会进入监听状态,即 LISTEN。

注:listen() 本身并不是发送 TCP 报文,更不是执行三次握手,它只是让内核知道:这个 socket 是一个监听 socket,可以用来接收连接请求。

(2)connect() :客户端发起连接

当客户端调用:connect(sockfd, ...);

表示:客户端请求与服务器建立 TCP 连接。

注:connect() 只是应用程序请求内核建立 TCP 连接的入口,而 TCP 的三次握手则由操作系统内核中的 TCP 协议栈自动完成。这也就是为什么我们写 TCP 客户端代码时,不需要我们手动进行 TCP 三次握手。

(3) accpet() :获取已经建立的连接

当服务器调用:accept(listenfd, ...);

表示:应用层告诉操作系统,把 listenfd 已经建立好的连接交给我。

对于 accept(),它会返回一个新的 socket,这个 socket 负责和某一个已经建立连接的客户端进行数据通信,而 listenfd 继续负责监听新的连接。

注:accpet() 只是应用层用来获取 TCP 连接,它并不参与 TCP 三次握手的过程,即使服务器不进行 accept(),服务器和客户端的连接依旧正常建立。

总结一句话:

listen() 负责让服务器进入监听状态, connect() 负责让客户端请求建立 TCP 连接,而 accept()  负责让服务器应用程序获取已经建立好的 TCP 连接。三次握手由操作系统内核中的 TCP 协议栈自动完成,应用程序并不需要手动参与三个 TCP 报文的发送。

三、TCP 四次挥手

3.1 为什么 TCP 需要四次挥手

先回答一个核心问题:

TCP 为什么不能像 UDP 一样,通信结束后直接关闭,而是需要进行挥手?

因为 TCP 是面向连接的协议,通信双方的操作系统会为 TCP 连接创建相应的数据结构,对 TCP 连接进行管理。而 UDP 是无连接的,操作系统只需要将数据报交给网络协议栈进行发送即可。

因此,当 TCP 通信结束时,通信双方需要通过一定的机制通知对方关闭连接,并释放操作系统中维护的相关资源,这个过程就是TCP 的连接关闭过程,也就是四次挥手

但是,仅仅因为 TCP 是面向连接的,还不能解释为什么需要四次挥手。

这是因为 TCP 是一个全双工通信协议。一条 TCP 连接建立后,客户端和服务器之间实际上存在两个独立的数据传输方向:

客户端 ─────────────→ 服务器
       数据传输方向 1

客户端 ←───────────── 服务器
       数据传输方向 2

因此,TCP 连接的关闭并不是简单地 “一方关闭,整个连接立即关闭”,而是需要分别关闭两个方向的数据传输。

例如,客户端已经没有数据需要发送了,可以先关闭:

客户端 → 服务器

但此时服务器可能还有数据需要发送给客户端:

服务器 → 客户端

所以,服务器不能因为客户端关闭了发送方向,就立即关闭整个 TCP 连接。

这就是 TCP 通常需要通过多次报文交互完成连接关闭的根本原因。

TCP 是全双工的,连接的两个通信方向需要分别关闭,因此 TCP 的连接关闭过程通常需要四次挥手。

整个过程可以详细表示为:

3.2 第一次挥手

客户端和服务器在 TCP 协议中,地位是相同的。本节我们假设客户端主动关闭连接。

当客户端已经没有数据需要发送给服务器时,客户端会向服务器发送一个 FIN 报文。

FIN 报文:FIN 标志位被置为 1 的 TCP 报文。FIN 标志位表示:客户端已经没有数据需要发送,请求关闭 客户端 -> 服务器 这一方向的数据传输。

客户端发送 FIN 报文后,进入:FIN_WAIT_1 状态

FIN_WAIT_1表示:客户端已经发送 FIN 报文,正在等待服务器确认。

注:对于第一次挥手,TCP 连接并没有立即关闭,因为 TCP 是全双工的,此时只是表示:客户端 -> 服务器 这个方向已经关闭,但是 服务器 -> 客户端 这个方向依旧可以传输数据。所以客户端此时仍然需要保持连接。

3.3 第二次挥手

服务器收到客户端发送的 FIN 报文后,首先需要告诉客户端:你发送的 FIN 报文我已经收到。

因此服务器向客户端发送一个 ACK 报文:

客户端                         服务器

FIN  ───────────────────────→

     ←────────────────────── ACK

服务器收到 FIN 报文后,进入:CLOSE_WAIT 状态

而客户端收到服务器返回的 ACK 后,进入:FIN_WAIT_2 状态

CLOSE_WAIT 表示:客户端 -> 服务器 这个方向已经关闭,即客户端已经没有数据向服务器发送,但 服务器 -> 客户端 这个方向没有关闭,即服务器需要处理完客户端之前发送的数据。

FIN_WAIT_2 表示:正在等待服务器发送 FIN 报文。

对于这种连接状态,被称为半关闭状态。

注:对于第二次挥手,TCP 连接并没有完全关闭,而是处于半关闭状态。客户端发送 FIN 只代表客户端不再向服务器发送数据,但服务器仍可能需要处理已经接收到的数据,或者继续向客户端发送剩余数据,因此服务器不会立即关闭 TCP 连接。

3.4 第三次挥手

当服务器的数据也全部处理和发送完毕,并且服务器操作系统发现:服务器处于 CLOSE_WAIT 状态。此时服务器向客户端发送 FIN 报文:

客户端                         服务器

FIN  ───────────────────────→

     ←────────────────────── ACK

     ←────────────────────── FIN

服务器发送 FIN 报文后,进入:LAST_ACK 状态

LAST _ACK 表示:服务器已经发送 FIN 报文,等待客户端对这个 FIN 报文进行最后确认。

客户端收到服务器的 FIN 后,说明:服务器 -> 客户端这个方向的数据传输也结束了。

注:对于第三次挥手,TCP 连接依旧没有完全关闭。服务器发送 FIN 报文,只代表服务器已经没有数据需要发送,此时还需要等待客户端发送最后一个 ACK 报文,确认客户端已经收到服务器发送的 FIN 报文。

3.5 第四次挥手

客户端收到服务器的 FIN 后,向服务器发送 ACK:

客户端                         服务器

FIN  ───────────────────────→

     ←────────────────────── ACK

     ←────────────────────── FIN

ACK  ───────────────────────→

服务器收到这个 ACK 后,进入:CLOSED 状态

注:对于第四次挥手,服务器收到客户端发送的 ACK 后,进入 CLOSED 状态,此时服务器的 TCP 连接正式关闭,操作系统可以释放为该 TCP 连接维护的相关内核数据结构。

而客户端发送最后一个 ACK 报文后,并不会立即进入 CLOSED 状态,而是进入 TIME_WAIT 状态,需要等待一段时间后才会进入 CLOSED 状态。此时,客户端的 TCP 连接才正式关闭,操作系统可以释放为该 TCP 连接维护的相关内核数据结构。

3.6 为什么需要 TIME_WAIT

为什么服务器可以直接关闭,而客户端却需要等待?

这是因为客户端发送的最后一个 ACK 报文,虽然已经发出,但客户端无法确定这个 ACK 报文是否成功到达服务器。

假设最后一个 ACK 报文在网络传输过程中丢失,对于服务器来说,由于在一定时间内没有收到客户端的 ACK,就会重新发送 FIN 报文。

如果客户端发送 ACK 后立即进入 CLOSED 状态,那么当服务器重新发送 FIN 时,客户端已经没有对应的 TCP 连接来处理这个 FIN,也就无法再次向服务器发送 ACK。

而客户端进入 TIME_WAIT 状态后,会在一段时间内继续维护这条 TCP 连接。如果服务器重新发送 FIN,客户端仍然能够接收到该 FIN,并重新发送 ACK,从而保证服务器最终能够正常关闭连接。

如果客户端处于 TIME_WAIT 期间,只要收到服务器重传的 FIN,就会再次 ACK,但是如果网络本身持续异常,导致客户端发送的 ACK 始终无法到达服务器,那么对于客户端来讲,TIME_WAIT时间过后,会直接关闭 TCP 连接,而对于服务器来讲,服务器不可能无限等待,而是重传上限后,TCP 认为当前连接已经无法关闭,此时服务器会放弃重传,并关闭连接。

因此,客户端不能在发送最后一个 ACK 后立即进入 CLOSED,而需要通过 TIME_WAIT 保留一段时间的连接状态,以提高最后一个 ACK 成功到达服务器的可靠性。

除了保证最后一个 ACK 能够被服务器收到之外,TIME_WAIT 还可以避免旧 TCP 连接中的报文影响后续的新连接

TCP 报文在网络中传输时,并不一定能够在连接关闭后立即消失。

例如:

旧连接
客户端 ─────────────→ 服务器
          某个报文
             ↓
          网络延迟

如果 TCP 连接刚刚关闭,客户端又立即使用完全相同的四元组建立一条新的 TCP 连接,那么网络中残留的旧报文就有可能被新连接误认为是当前连接的数据。

因此,客户端进入 TIME_WAIT 后等待一段时间,可以让旧连接中残留的报文在网络中自然消失,从而降低对后续连接产生影响的可能性。

所以, TIME_WAIT 主要解决两个问题:

  1. 保证最后一个 ACK 丢失后,客户端仍然能够重新响应服务器的 FIN。
  2. 让旧连接中残留的报文在网络中消失,避免影响后续建立的相同连接。

因此:

TIME_WAIT 并不是 TCP 多余的等待,而是 TCP 为了保证连接能够可靠关闭,并避免旧连接报文影响新连接而设计的状态。

思考:服务器关闭后,为什么不能立即 bind 原来的端口号?

3.7 TIME_WAIT 的等待时间

前面我们已经知道,客户端发送最后一个 ACK 后,并不会立即进入 CLOSED,而是进入 TIME_WAIT 状态。

那么问题来了:TIME_WAIT 到底需要等待多长时间?

TCP 中通常使用 2MSL(Maximum Segment Lifetime,最长报文段寿命) 作为 TIME_WAIT 的等待时间。

这里的 MSL可以简单理解为:一个 TCP 报文在网络中允许存在的最长时间。

为什么是 2MSL,而不是 1MSL?

这是因为客户端发送最后一个 ACK 后,需要考虑一种情况:

客户端                         服务器

        ←──────────── FIN

ACK ────────────────→
          ↓
        网络延迟
          ↓
        服务器收到

如果 ACK 正常到达服务器,那么服务器就可以关闭连接。

但是客户端无法直接知道:服务器到底有没有收到这个 ACK。

因此客户端需要等待足够长的时间,使得:

  1. 客户端发送的最后一个 ACK 能够到达服务器;
  2. 如果 ACK 丢失,服务器重新发送的 FIN 也能够到达客户端;
  3. 客户端能够再次发送 ACK。

最极端情况下,可以理解为:

客户端 ───── ACK ─────→ 服务器
          最长 MSL

客户端 ←──── FIN ────── 服务器
          最长 MSL

两段时间加起来就是:

MSL + MSL = 2MSL

思考:四次握手和三次挥手

在理解 TCP 四次挥手之后,可能会产生一个有意思的问题:

既然 TCP 关闭连接需要四次挥手,那么为什么建立连接只需要三次握手,而不是四次握手?

其实,从信息交互的角度来看,TCP 三次握手也可以理解成需要完成四个动作:

客户端:我想建立连接
服务器:我收到了你的请求
服务器:我也想和你建立连接
客户端:我收到了你的请求

但是,服务器在回复客户端的连接请求时,可以将确认客户端 SYN 的 ACK服务器自己的 SYN放在同一个 TCP 报文中:

       SYN
        ↓
      SYN + ACK
        ↓
       ACK

因此,原本需要完成的四个动作,被压缩成了三个 TCP 报文。

所以,三次握手并不是少完成了一次确认,而是第二次握手通过 SYN + ACK 同时完成了两个动作。

那么问题来了:TCP 四次挥手是不是也可以通过这种方式减少一次,变成三次挥手?

答案是:可以

在正常的四次挥手中:

客户端 → 服务器:FIN
客户端 ← 服务器:ACK
客户端 ← 服务器:FIN
客户端 → 服务器:ACK

第二次挥手的 ACK 和第三次挥手的 FIN,在时间上并不一定能够同时发送。

因为服务器收到客户端的 FIN 后,服务器可能还有数据没有发送完成

例如:

客户端:我不发数据了
服务器:收到,但是我还有数据要发

所以服务器需要先发送 ACK,继续处理和发送自己的数据,等数据发送完成后,再发送 FIN。

但是,如果服务器收到客户端的 FIN 时:服务器也已经没有数据需要发送。

那么服务器就可以将:

确认客户端 FIN 的 ACK
+
服务器自己的 FIN

合并到同一个 TCP 报文中:

客户端                         服务器

FIN ───────────────────────→

     ←────────────────────── FIN + ACK

ACK ───────────────────────→

这样,原本的四次挥手就变成了三次报文交互。

所以可以总结为:

TCP 四次挥手是通常情况下的连接关闭过程,但并不意味着网络中一定会出现四个独立的 TCP 报文。如果服务器收到 FIN 后恰好没有数据需要发送,那么 ACK 和 FIN 可以合并,从而形成“三次挥手”。

四、TCP 的其他特性与应用

4.1 面向字节流

在 深入理解TCP协议(一):TCP报文格式详解 文章中,我们知道 TCP 报头字段中没有直接描述 TCP 数据长度的字段。

为什么 TCP 报头中不直接增加一个数据长度字段呢?

实际上,TCP 并不是无法知道一个 TCP 报文中携带了多少数据。

TCP 报文封装在 IP 数据报中,IP 层能够提供整个 IP 数据报的长度,而 TCP 报头中的 4 位首部长度字段可以确定 TCP 报头的长度。因此,TCP 可以计算出当前 TCP 报文携带的数据长度。

既然 TCP 能知道 TCP 报文携带的数据长度,那为什么不能像 UDP 那样以数据报的形式交付给应用层,而是以字节流的形式交付给应用层?

深入理解 TCP 协议(二):TCP 可靠传输与高效通信机制 滑动窗口机制中,我们知道 TCP 并不是将应用层一次交付的完整数据直接封装成一个 TCP 报文,而是将发送缓冲区中的连续字节划分成多个 TCP 报文进行传输。

因此,即使 TCP 能够明确知道每一个 TCP 报文携带了多少数据,也无法通过 TCP 报文的边界来确定应用层数据的边界

例如,应用层向 TCP 交付一段完整数据:

HelloWorld

TCP 可能根据当前网络状况将其划分为:

TCP 报文 1:Hello
TCP 报文 2:World

但对于接收方应用层而言,它并不会知道 Hello 和 World 分别来自两个 TCP 报文,也不会认为它们是两个独立的数据。

TCP 会将这些数据重新组织成连续的字节流交付给应用层:

HelloWorld

因此,TCP 报文 的边界并不是应用层数据的边界。TCP 只保证字节流能够可靠、有序地传输,而不会保留应用层数据之间的边界,这也是 TCP 面向字节流的本质。

4.2 粘包问题

既然 TCP 不负责维护应用层数据的边界,那么应用层连续发送多个数据,接收方又应该如何区分这些数据呢?

这就是 TCP 粘包问题

注:TCP 粘包问题中的包是指应用层的数据包。

避免粘包问题的核心思想:明确两个数据包的边界。

避免粘包问题的措施:

(1)制定定长的数据包,如果报文数据不足,以特殊字符填充

(2)对于变长的数据包,可以在数据包的起始位置,约定一个数据包总长度的字段

(3)对于变长的数据包,可以在数据包之间添加特殊分隔符

4.3 TCP 异常情况

进程终止:进程终止会自动关闭文件描述符,操作系统对其套接字自动进行四次挥手。

机器重启:和进程终止情况一样。

机器断电或者网线断开:接收端认为连接存在,一旦接收端有写入操作,接收端发现连接对端无响应,就会自动释放该 TCP 连接,即使没有写入操作,TCP 协议也内置了一个保活定时器,会定期询问对方是否存在。

4.4 基于 TCP 的应用层协议

HTTP

HTTPS

SSH

FTP

SMTP

Telnet

当然,还有你自己写的基于 TCP 的应用层协议

对比维度 TCP UDP
连接方式 面向连接 无连接
数据交付方式 面向字节流 面向数据报
可靠性 可靠传输 尽最大努力交付,不保证可靠
数据边界 不保留应用层消息边界 保留数据报边界
是否存在粘包/拆包 存在,需要应用层解决消息边界 不存在 TCP 意义上的粘包/拆包
连接管理 三次握手、四次挥手
传输效率 机制较多,开销相对较大 首部简单,开销较小
传输速度 通常更适合稳定可靠的数据传输 通常更适合低延迟、实时性要求高的场景
适用场景 HTTP/HTTPS、文件传输、数据库通信、SSH 等 DNS、DHCP、实时音视频、在线游戏、直播等

归根结底,TCP 和 UDP 之间没有优缺点,只是 TCP 和 UDP 的特点不同,所以采用什么传输层协议,是需要根据场景而定。

4.5 用 UDP 实现可靠传输(经典面试题)

参考 TCP 的可靠性机制回答:
例如:

引入序列号,保证数据顺序

引入确认应答机制,确认数据是否到达

引入超时重传机制

......

所以面试的时候,可以总结成一句话:

UDP 本身是不可靠的,如果希望基于 UDP 实现可靠传输,可以在应用层引入序列号、确认应答、超时重传、去重、滑动窗口等机制,从而解决 UDP 的丢包、乱序、重复以及传输效率等问题,本质上就是在 UDP 之上实现一套类似 TCP 的可靠传输机制。

不过这里有一个很重要的面试加分点

既然 UDP 上面最终又实现了这么多 TCP 的机制,那为什么不直接使用 TCP?

答案是:UDP + 自定义可靠传输并不一定是为了“替代 TCP”,而是为了在需要可靠性的同时,保留 UDP 的一些特性,例如可以由应用层自己控制重传策略、报文格式以及部分传输行为。

Logo

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

更多推荐