首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >一文带你学会TCP协议:可靠数据传输的基石与艺术

一文带你学会TCP协议:可靠数据传输的基石与艺术

作者头像
海棠未眠
发布2025-10-22 16:40:59
发布2025-10-22 16:40:59
1600
举报

前言

我们昨天讲解了传输层的概念,端口,以及我们传输层的一个协议:UDP。

今天我们就来继续讲一下另外一个大名鼎鼎的传输层协议:TCP!

在我们日常的互联网体验中,无论是流畅的视频通话、精准的网页加载,还是稳定的文件传输,其背后都离不开一个默默无闻却又至关重要的功臣——传输控制协议(Transmission Control Protocol, TCP)。如果说IP协议定义了数据包如何跨越网络“寻路”,那么TCP则确保了这些数据包能够可靠、有序、不丢不重地抵达目的地。本文将深入TCP的内部世界,从核心概念到复杂机制,全面解析这一支撑现代互联网的基石协议。

一、 引言:为什么需要TCP?

互联网的基础是IP协议,但IP协议本身是一种“尽力而为”的无连接协议。这意味着:

  • 不保证交付:数据包可能在网络中丢失。
  • 不保证顺序:数据包可能通过不同路径,导致后发的先至。
  • 不保证完整性:数据包可能在传输中出错。

对于电子邮件、文件传输、网页浏览等应用来说,这种不确定性是无法接受的。我们无法想象下载一个软件安装包,结果内容错乱或缺失一半。因此,我们需要一个更上层的协议来弥补IP的不足,为应用程序提供一个可靠的、面向连接的、端到端的字节流服务。这就是TCP诞生的初衷。

TCP的核心目标:在不可靠的IP网络之上,建立一个可靠的“数据管道”,使得发送方发送的字节流能够原封不动、顺序正确地被接收方接收。

二、TCP协议段格式

要想学会,理解TCP,我们就需要先知道TCP协议的格式:

在这里插入图片描述
在这里插入图片描述

我先简单对这些字段进行解释,会在后面的小节对这些字段进行更加深层次的解释:

关键字段解释

  • 源/目的端口号:标识发送和接收应用程序的进程。与IP地址共同构成一个套接字,唯一确定一个连接。
  • 序列号:本报文段所发送的第一个字节在整个数据流中的编号。这是实现可靠传输和顺序性的核心。
  • 确认号:期望收到对方下一个报文段的第一个字节的序列号。表示该序号之前的所有数据均已正确接收。即 ACK = N 表示序列号 N-1 及之前的所有数据都已确认。
  • 数据偏移:指示TCP首部的长度(以4字节为单位),因为选项字段是可变长的。
  • 控制位(标志位)
    • URG:紧急指针有效。
    • ACK:确认号有效。一旦连接建立,该位通常总是1。
    • PSH:推送功能,提示接收方应立即将数据交付给应用层,而不是等缓冲区满。
    • RST:重置连接,通常表示连接出现严重错误,需要强制断开。
    • SYN:同步序列号,用于建立连接。
    • FIN:结束标志,用于释放连接。
  • 窗口大小:用于流量控制,表示接收方当前可接收的字节数量。即接收方缓冲区剩余空间。
  • 校验和:用于检测报文段在传输过程中是否发生错误,覆盖首部和数据。
  • 紧急指针:当URG=1时有效,指示本报文段中紧急数据的末尾位置。

三、TCP的报头问题

在这里插入图片描述
在这里插入图片描述

请看这个图,在我们的网络的信息传输中,TCP与UDp一样,也应该是在正式的数据前面添加自己的TCP报头。

可是,根据之前的TCP格式图片我们可以看见,TCP协议中,还存在了一个选项的区域,也就是说,TCP报头的数据大小是不一定的。那么我们应该怎么对TCP的报头进行解包呢?


首先,请大家知道,我们TCP的报头至少都是占20个字节的:

在这里插入图片描述
在这里插入图片描述

所以我们可以先提取20个字节 ,那我们是不是就得到如图的几个字段了?

正好,在这个字段中,有一个4位首部长度,它也叫做 「数据偏移」 字段。那么这个字段代表了什么呢?

在TCP首部有一个可变长度的部分——选项 字段。选项字段的长度可以是0字节、4字节、8字节等,这使得TCP首部的总长度不是固定的。

为了能让接收方准确地知道TCP首部在哪里结束,应用数据从哪里开始,就必须有一个字段来明确指示首部的边界。这就是「首部长度」字段的职责。

首部长度表示TCP的整体长度,也就是我们前面20个字节加上选项的长度!

首部长度是有基本的计算单位的:4字节。

所以我们总体的TCP报头长度,就是首部长度x4字节。

如果我们没有选项部分,TCP的长度就是20个字节,那么我们的首部长度就只需要是5就行了,也就是首部长度的值为0101。

首部长度的最大值是1111,也就是十进制的15,15x4=60,也就是说,我们TCP的最大长度为60字节,选项的长度取值范围为0-40字节!

正因如此,我们TCP的报头长度的取值范围为:[20-60]。


所以总的报头的解包过程就是,先固定解包20个字节,随后获取首部长度,再根据首部长度来解包选项。

那么我们是如何分用的呢?

因为字段中含有源端口号与目的端口号,所以我们可以进行分用。

四、TCP内核结构体

TCP协议在进行通信时,也是一个协议。如果双方操作系统在自己的TCP层都要有对应的协议的话,就意味着这里的TCP协议本质上也是双方操作系统约定好的一个结构化数据对象,就是结构体。

Linux内核中自然存在对应的一个核心结构体 —— struct tcphdr

这个结构体精确地映射了TCP报文段的格式,使得内核能够通过操作结构体的成员来构建发送的包,或解析接收到的包

代码语言:javascript
复制
struct tcphdr {
    __be16  source;     // 源端口号 (16 bits)
    __be16  dest;       // 目的端口号 (16 bits)
    __be32  seq;        // 序列号 (32 bits)
    __be32  ack_seq;    // 确认号 (32 bits)

/* 以下是16位的"数据偏移/保留/控制位/窗口"组合字段 */
#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u16   res1:4,     // 保留位 (4 bits)
            doff:4,     // 数据偏移 (4 bits),即首部长度
            fin:1,      // FIN 标志 (1 bit)
            syn:1,      // SYN 标志 (1 bit)
            rst:1,      // RST 标志 (1 bit)
            psh:1,      // PSH 标志 (1 bit)
            ack:1,      // ACK 标志 (1 bit)
            urg:1,      // URG 标志 (1 bit)
            ece:1,      // ECN-Echo (1 bit)
            cwr:1;      // Congestion Window Reduced (1 bit)
#elif defined(__BIG_ENDIAN_BITFIELD)
    __u16   doff:4,     // 数据偏移 (4 bits)
            res1:4,     // 保留位 (4 bits)
            cwr:1,      // Congestion Window Reduced (1 bit)
            ece:1,      // ECN-Echo (1 bit)
            urg:1,      // URG 标志 (1 bit)
            ack:1,      // ACK 标志 (1 bit)
            psh:1,      // PSH 标志 (1 bit)
            rst:1,      // RST 标志 (1 bit)
            syn:1,      // SYN 标志 (1 bit)
            fin:1;      // FIN 标志 (1 bit)
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif
    __be16  window;     // 窗口大小 (16 bits)
    __be16  check;      // 校验和 (16 bits)
    __be16  urg_ptr;    // 紧急指针 (16 bits)
};
在这里插入图片描述
在这里插入图片描述

这些东西叫做位断,其实就是我们结构体。

跟UDP一样,我们让我们的指针前移充报头大小的距离,然后填充相应字段。


五、TCP的可靠性理解

我们都说,TCP是可靠的,UDP是不可靠的。

我们该如何理解TCP的可靠性呢?我认为,要理解他的可靠性,主要应该先理解他的确认应答的机制。

确认应答(ACK)

这是TCP可靠性的最根本保障。

我们客户端给服务端发送一个消息:你好吗?

在这里插入图片描述
在这里插入图片描述

我们站在上帝视角知道,即便服务端把客户端发送的消息收到了,但是客户端自己是不能确认服务端是否收到的。

为了保证客户端作为发送方准确确认自己的报文被对方收到了,所以在TCP这里要求对方给我这个消息做一次应答,只要客户端收到了应答,就能保证刚刚那个“你好吗”被服务端收到了。

这种发消息,随后收到消息的一方需要给发送消息的一方进行应答的策略,我们称之为确认应答

在这里插入图片描述
在这里插入图片描述

光是这样讲肯定不够,因为站在服务器的角度,我给客户端的应答,本质上也是一个信息,你怎么能保证我这个应答就一定能让它收到呢?

所以我们要给应答要再次作应答?

在这里插入图片描述
在这里插入图片描述

如果这样干的话,应答操作就无休无止了!!

所以在长距离通信的时候,是没有百分百的可靠性的。

因为总有最新的一条消息是没有应答的。

所以TCP的可靠性谈的不是最新的这个数据。

如果客户端给服务端发送消息,服务端必须要给客户端应答,这个应答发没发过去你服务端需要关心吗?不需要啊!客户端才需要关心。

如果客户端收到了应答,客户端就能保证从客户端到服务端,从左向右的可靠性。所以可靠性是对历史报文的可靠性。

客户端如果没有收到应答,他还会去问服务端的!“总有最新的一条消息是没有应答的”代表的意思是老消息是有应答的,我们保证了老消息的可靠性。

同理,服务器给客户端发消息,客户端需要给服务端进行应答,同理,发出去后客户端就不要管这个应答了。服务端收到应答就保证了旧消息被客户端收到了,此时就能保证服务端到客户端的可靠性。

由于TCP是全双工的,双方的可靠性都能得到保证,所以就是可靠的,其核心原理就是确认应答。

这个发送应答的机制是双方操作系统自动做的,当你客户端发送消息到服务端后,服务端还没进行处理,操作系统就会自动发送一个应答回去。这个过程是操作系统自己完成的。我们也不需要对确认再做确认,我给你发一个消息你就给我一个确认就行。这个是TCP的一般模式。


所以,什么叫做应答,什么叫做报文呢?

如果你发送一个请求过来,你一定是TCP报头加上有效载荷:

在这里插入图片描述
在这里插入图片描述

所谓的确认就是发回去一个裸的TCP报头。

在这里插入图片描述
在这里插入图片描述

有了这个例子,我们就可以来谈论32位序号与确认序号了。

序号与确认序号

我们刚刚的例子中,说得是发一条消息,接受一个应答:

在这里插入图片描述
在这里插入图片描述

如果是这样发送一个后,我们还要等待应答,这样子串行,那么效率未免就太低了吧?

所以TCP在设计时,想的是能不能一次性发一批报文,随后服务器对这些报文进行统一的批量化的应答,这样子在时间上就有了重叠,才会提高效率(我们先不考虑异常,丢包问题):

在这里插入图片描述
在这里插入图片描述

在这个过程中,意味着客户端会收到很多的应答,站在客户端的角度,我怎么知道这些应答谁是谁的呢?如果我发送了四个报文,却只收到了三个应答,那怎么区分出来呢?

所以我们需要给每一个报文带上编号。

比如说10,20,30,40,而我们收到的对应应答,通常是发送的报文的序号再加1。

在这里插入图片描述
在这里插入图片描述

这样通过序号,就能进行区分了。

这个序号往往是发送的序号值加一,表示序号之前的内容已经全部被收到了。

这只是序号的一种作用。请大家注意,如果我们依次发送多个报文,如果服务端收到的顺序是乱的,这本身就是一种不可靠的表现。

所以怎么保证服务端在自己的接收缓冲区收到报文是按顺序的?带上序号!

所以序号也是保证报文按需到达的原因。

但是为什么要同时存在两个序号呢?一个序号一个确认序号。

这是因为:一个应答可能不单单只是应答,也会有对方的发的消息

客户端给服务端发送一个消息,对方不仅要给我的消息进行确认,还想要给我发消息,就会顺带一起发送给我,这个报文既是应答,又是数据,就会同时用到序号与确认序号,这种机制叫做捎带应答

如果你没有消息想发给我,就只发一个应答,如果有,就顺路发给我。这个工作模式更接近人类的工作模式: 比如别人问你:吃了吗? 你回答:吃了(这个是应答),你呢?(这个是消息)。

在这里插入图片描述
在这里插入图片描述

这里收到的2001代表不只是2000的数据收到了,包括1000之前的数据都收到了。


如何理解序列号?

不知道大家有没有发现,UDP报文里有着包括数据的总长度字段,但是为什么TCP里面没有呢?

因为TCP是面向字节流的,不对报文内容进行任何解释,它收到一个TCP报文,会把报头去掉,剩下的数据放到接收缓冲区里,随着数据增多,数据会混在一起形成流式结构。

而接收缓冲区都是有大小限制的,一般都是固定的:根据报头,16位窗口大小决定了接收缓冲区的大小是2^16次方。

从今天开始把发送与接收缓冲区想象成一个char类型的数组,每一个元素就是一个字节。当我们把应用层的指定长度数据拷贝到缓冲区里,放到数组里,那么对应的每一个字节的数据天然就有了编号,而这个编号就是上面序号的来源。

所以发送填的序号就是我们的数组下标。

在这里插入图片描述
在这里插入图片描述

所以发送时就是从一个char数组的内容拷贝到另外一个char类型的数组中,这个过程我们叫做字节流。

当我们调用系统调用比如write,read时,这个过程就是用户与系统的一个生产者消费者模型问题。

所以在读取数据时,如果底层接收缓冲区没数据,我们上层就会被阻塞,本质上就是在做生产者消费者模型的同步问题。


16位窗口大小

我们知道,TCP在发送方与接收双方都各自有一个发送缓冲区与接收缓冲区。

如果发送方发送了多条数据,但是接收方的接收缓冲区已经快满了,并且此时已经无法接收更多信息了。多余的数据只会被丢弃。虽然我们后面说没收到应答,会有重新发送的机制。

但是这样就太浪费时间了。操作系统不做浪费时间,浪费空间的事情。

所以我们需要进行:流量控制

16位的窗口大小,正是TCP实现其核心功能 「流量控制」 的关键所在。

窗口大小 是一个16位的字段,由接收方在发送给发送方的TCP报文段中设置。

它的含义非常明确:“我的接收缓冲区目前还有这么多字节的空闲空间,你可以发送这么多字节的数据过来。”

换句话说,它直接定义了发送方在当前时刻允许发送的、未被确认的数据量的上限

当接收方来不及接收时,发送方选择发慢点,发少点,甚至不发。

这就跟我们向刻度杯里倒水一样的道理,我们会根据杯子水平线的刻度控制我们倒水的速度。

通信双方的报文都是发给对方的,所以填的信息是就是自己接收缓冲区剩余空间的大小。双方都要进行流量控制。


超时重传机制

我们之前都默认我们的TCP传输时没有出错,但事实上怎么可能没有出错的情况呢。

超时重传是TCP实现可靠数据传输最核心的机制之一。它的基本思想直观易懂:“发送一个数据后,如果在一个合理的时间内没有收到确认,就再发一次。” 但深入其内部,你会发现TCP的设计极其精巧,远非“傻等”那么简单。

网络本质是不可靠的。数据包在传输过程中可能会因为多种原因丢失:

  1. 网络拥塞:路由器队列已满,新到的数据包被丢弃。
  2. 信号干扰/比特错误:数据在物理链路上发生错误,接收方通过校验和发现后丢弃。
  3. 路径变化:路由振荡导致数据包进入“黑洞”。

确认应答机制只能处理“成功接收”的情况。对于“数据包丢失”和“确认应答丢失”这两种情况,发送方唯一的感知就是迟迟收不到ACK

在这里插入图片描述
在这里插入图片描述
  • 主机 A 发送数据给 B 之后, 可能因为网络拥堵等原因, 数据无法到达主机 B;
  • 如果主机 A 在一个特定时间间隔内没有收到 B 发来的确认应答, 就会进行重发;

但是, 主机 A 未收到 B 发来的确认应答, 也可能是因为 ACK 丢失了。

在这里插入图片描述
在这里插入图片描述

因此主机 B 会收到很多重复数据. 那么 TCP 协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉。

这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.

那么, 如果超时的时间如何确定?

为了解决这个问题,TCP为每一个已发送但未确认的报文段都启动一个重传计时器。如果计时器超时,就执行重传。

最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”。但是这个时间的长短, 随着网络环境的不同, 是有差异的.

  • 如果超时时间设的太长, 会影响整体的重传效率。
  • 如果超时时间设的太短, 有可能会频繁发送重复的包。

TCP 为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.

Linux 中(BSD Unix 和 Windows 也是如此), 超时以 500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍。

如果重发一次之后, 仍然得不到应答, 等待 2 x 500ms 后再进行重传。

如果仍然得不到应答, 等待 4 x 500ms 进行重传. 依次类推, 以指数形式递增。为什么这样做呢?因为它也不知道你的网络状态如何。

累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接。


连接管理机制

在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接。

ACK,SYN与FIN

大家可以看见我们TCP格式中的六个标记位。为什么会有这么多标记位呢?

在这里插入图片描述
在这里插入图片描述

我们发送的各种TCP报文,其实都是有种类的,而我们标记位中的ACK标记位,当它置为1的时候,就是表明自己是一个确认报文,你需要关心我的确认序号。 ACK标记几乎无处不在。一旦连接建立,几乎所有的报文都会把ACK置为1。在三次握手的后两次,以及四次挥手的每一步,你都能看到ACK=1的身影。

因为除了纯粹的连接请求(第一个SYN),TCP通信本质上是一个“带确认的对话”。

而SYN是:同步标志位,表示这是一个建立连接的请求。三次握手的前两次。客户端和服务器各发一次SYN=1的报文,来交换彼此的初始序列号。

FIN:连接断开标记位,通信结束的时候使用,执行握手协商。四次挥手中,主动关闭方和被动关闭方各发送一次FIN=1的报文。

在了解了这三个标记位号,我们就可以继续来讲一下三次握手了。


三次握手

三次握手是TCP连接建立的过程。其核心目标是:

  1. 同步序列号:交换双方的初始序列号,为后续可靠数据传输奠定基础。
  2. 协商参数:交换一些重要的TCP参数,如最大报文段长度、窗口缩放因子、选择性确认等。
  3. 验证连通性:确认双方都具有发送和接收的能力,确保连接是“活”的。
在这里插入图片描述
在这里插入图片描述

三次握手的本质,就是 SYNACK 这两个标志位有序地“亮起”与“回应”的过程。

以我们的客户端与服务端的通信为例子:

初始状态

  • 服务器:调用 listen() 后,进入 LISTEN 状态,等待客户的连接请求。
  • 客户端:处于 CLOSED 状态。

第一次握手 (客户端发送 SYN)

  • 动作:客户端调用 connect(),发送一个 SYN=1 的报文。
  • 状态变化:客户端从 CLOSED 进入 SYN-SENT 状态。
  • SYN_SENT 的含义:“同步已发送”。我发出了连接请求(SYN),现在正在等待服务器给我一个回应(SYN-ACK)

第二次握手 (服务器发送 SYN-ACK)

  • 动作:服务器收到 SYN 后,回复一个 SYN=1, ACK=1 的报文。
  • 状态变化:服务器从 LISTEN 进入 SYN-RCVD 状态。
  • SYN_RCVD 的含义:“同步已收到”。我收到了客户的请求并给出了回应,现在正在等待客户最终确认我的回应(ACK)

第三次握手 (客户端发送 ACK)

  • 动作:客户端收到 SYN-ACK 后,发送一个 ACK=1 的报文。
  • 状态变化
  1. 客户端收到 SYN_ACK,证明服务器的发送和自己的接收能力都没问题。于是客户端从 SYN_SENT 进入 ESTABLISHED(连接已建立)状态,可以开始发数据了。
  2. 服务器收到这个 ACK,证明自己的发送和客户的接收能力也没问题。于是服务器从 SYN_RCVD 也进入 ESTABLISHED 状态,可以开始收数据了。

至此,连接建立完成,双方进入数据传输阶段。

根据以上过程,我们可以知道,Accept并没有参加三次握手,并且握手的过程是由操作系统自己完成的。

对于客户端来说,把ACK发出去就已经认为自己握手好了。

四次挥手

假设客户端主动发起关闭。

初始状态:双方都处于 ESTABLISHED 状态。

第一次挥手 (客户端发送 FIN)

  • 动作:客户端调用 close(),发送一个 FIN=1 (通常也带 ACK=1) 的报文。
  • 状态变化:客户端从 ESTABLISHED 进入 FIN_WAIT_1 状态。
    • FIN_WAIT_1 的含义:“结束等待1”。我发出了关闭请求(FIN),现在正在等待服务器对这个请求的确认(ACK)

第二次挥手 (服务器发送 ACK)

  • 动作:服务器收到 FIN 后,回复一个 ACK=1 的报文。
  • 状态变化
    1. 客户端收到这个 ACK,知道服务器已经收到它的关闭请求了。于是从 FIN_WAIT_1 进入 FIN_WAIT_2 状态。
      • FIN_WAIT_2 的含义:“结束等待2”。服务器已经确认了我的关闭,现在我正在等待服务器也发出它自己的关闭请求(FIN)
    2. 服务器从 ESTABLISHED 进入 CLOSE_WAIT 状态。
      • CLOSE_WAIT 的含义:“关闭等待”。我知道客户端已经没数据发了,但我可能还有数据要传给它。我正处于半关闭状态。(这里的半关闭状态我们后面理解)

(此时,从客户端到服务器的数据通道已关闭。但服务器到客户端的通道还开着,服务器可以继续发送剩余数据)

第三次挥手 (服务器发送 FIN)

  • 动作:当服务器也调用 close() 时,发送一个 FIN=1 (通常也带 ACK=1) 的报文。
  • 状态变化:服务器从 CLOSE-WAIT 进入 LAST-ACK 状态。
    • LAST-ACK 的含义:“最后确认”。我也发出了关闭请求(FIN),现在正在等待客户端给我最后一个确认(ACK)

第四次挥手 (客户端发送 ACK)

  • 动作:客户端收到 FIN 后,回复一个 ACK=1 的报文。
  • 状态变化
    1. 服务器收到这个 ACK,证明连接可以安全关闭了。于是服务器从 LAST-ACK 进入 CLOSED 状态。
    2. 客户端发送完 ACK 后,进入 TIME-WAIT 状态。
      • TIME-WAIT 的含义:“时间等待”。我会在这里等待 2MSL 的时间。这么做有两个目的:
        • 确保我的最后一个ACK能到达服务器。如果没到,服务器会超时重传FIN,我还能再回一个ACK。
        • 让本次连接的所有报文都在网络中消散,防止干扰新的连接。
    3. 等待 2MSL 时间过后,客户端最终进入 CLOSED 状态。

至此,连接完全关闭。


以上只是个简单减少过程,相信大家还是有很多疑问,没关系我们慢慢来解惑。

深刻理解三次握手

问:为什么一定是三次握手呢?不能是四次五次六次?

这是因为三次是建立一条“双向”可靠通信通道的【最小】且【必要】的次数。

我们来拆解一下:

1. 第一次握手 (Client -> Server)

  • 能证明什么:证明了 Client 的发送能力Server 的接收能力 是正常的。
  • 不能证明什么:Server 的发送能力和 Client 的接收能力是否正常,还不知道。

2. 第二次握手 (Server -> Client)

  • 能证明什么
    • 证明了 Server 的发送能力Client 的接收能力 是正常的(因为Client收到了这个包)。
    • 同时,它也确认了第一次握手,表明 Server 收到了Client的SYN。
  • 此时,在 Client 看来,已经大功告成了! 因为它已经确认了:
    • 自己的发送能力 (OK)
    • 自己的接收能力 (OK)
    • 对方的发送能力 (OK)
    • 对方的接收能力 (OK)
    • 所以 Client 在收到第二次握手后,就完全可以进入连接状态,开始发送数据了。

3. 关键的第三次握手 (Client -> Server)

  • 它的唯一目的,是给 Server 一个“最终的确认”。
  • 在 Server 看来,直到收到第三次握手之前,它都是不确定的: “我虽然发出了SYN-ACK,但Client你到底收到没有?如果你的接收能力不行,我这个包就白发了。如果你没收到,我傻傻地开着连接等着,岂不是浪费资源?”
  • 它本质上就是在赌,赌最后一次ACK对方可以接收到。

问:三次握手就一定要成功吗?不可以失败吗?

三次握手当然可能失败。TCP的可靠,指的是它能发现错误并处理,而不是保证100%成功。

握手过程中的任何一个环节都可能出问题:

1. 第一次握手 (Client SYN) 就失败

  • 原因:SYN包在网络中丢失了。
  • 结果:Client 在 SYN-SENT 状态等不到 SYN-ACK,会超时重传SYN。重传几次后仍失败,则 connect() 调用返回失败。

2. 第二次握手 (Server SYN-ACK) 失败

  • 原因:SYN-ACK包在网络中丢失了。
  • 结果
    • Client 侧:在 SYN-SENT 状态等不到回复,超时重传SYN。
    • Server 侧:在 SYN-RCVD 状态等不到ACK,也会超时重传SYN-ACK。
    • 双方多次重传失败后,各自关闭连接。

3. 第三次握手 (Client ACK) 失败

  • 原因:ACK包在网络中丢失了。
  • 结果
    • Client 侧:因为已经收到SYN-ACK,它认为连接已建立,进入 ESTABLISHED 状态,并可能开始发送数据。
    • Server 侧:在 SYN-RCVD 状态一直等不到ACK,会超时重传SYN-ACK。
      • 如果Server的重传SYN-ACK被Client收到,Client会再次发送ACK,连接最终能建立。
      • 如果Server始终收不到ACK,多次重传失败后,Server会关闭这个“半连接”,释放资源。
      • 此时会出现一种不一致的状态:Client以为连接好了,Server却已经关了。当Client发送数据时,Server会回复一个 RST 复位报文,强制Client断开连接。

问:为什么要三次握手?

  1. 建立双方主机通信的意愿共识
  2. 双方验证全双工信道的通畅性(也就是为了验证网络是通畅的!!)

问:我们如何理解建立连接呢?连接是什么?

从应用程序的角度看,连接是一个抽象的通信通道,像一个虚拟的电话线。

但从操作系统内核的角度看,连接就是一个拥有特定状态和资源的上下文环境

当你说“建立了一个TCP连接”,内核里实际发生了:

  • 为这次通信分配了内存(用于发送缓冲区和接收缓冲区)。
  • 创建并维护了关键信息:对方的IP和端口、自己的IP和端口、双方的序列号、窗口大小、连接状态(如 ESTABLISHED)。
  • 启动了相关的定时器(如保活定时器)。

所以,连接就是内核为了维护一次特定通信所需的所有数据与状态的集合。

一条连接,一定会和一个文件对应,因为一个连接一个fd,并且连接在OS内部,会存在很多个!!!

如果OS要对连接进行管理,那就只有一个办法,就是:先描述,在组织!

对于TCP连接,Linux内核用一个非常复杂的结构体(struct sock)来“描述”它。这个结构体里包含了维护这个连接所需的全部信息:

代码语言:javascript
复制
/ 伪代码,示意核心成员
struct sock {
    // 1. 连接的唯一标识(四元组)
    __be32          sk_daddr;  // 目的IP(对方IP)
    __be32          sk_rcv_saddr; // 源IP(本机IP)
    __be16          sk_dport;  // 目的端口(对方端口)
    __be16          sk_num;    // 源端口(本机端口)

    // 2. 连接状态
    volatile u8     sk_state;  // 比如 TCP_ESTABLISHED, TCP_CLOSE_WAIT...

    // 3. 数据缓冲区
    struct sk_buff_head sk_receive_queue; // 接收队列(对方发来的数据)
    struct sk_buff_head sk_write_queue;   // 发送队列(要发给对方的数据)

    // 4. TCP核心协议信息
    u32             snd_nxt;   // 下一个要发送的序列号
    u32             rcv_nxt;   // 期望收到的下一个序列号
    u32             snd_wnd;   // 发送窗口(根据对方通告)
    u32             rcv_wnd;   // 接收窗口(本机空闲缓冲区)

    // ... 还有几十个其他成员,如定时器、拥塞控制信息等
};

每一个 struct sock 的实例,就代表了内核眼中的一个“连接对象”。

内核创建了成千上万个 struct sock 对象后,不能胡乱堆在内存里。它必须用高效的数据结构把它们组织起来,以便快速地进行查找、插入和删除。

最常见的组织方式是通过哈希表

  • Key(键):连接的四元组(源IP、源端口、目的IP、目的端口)。这唯一确定了一条连接。
  • Value(值):就是对应的 struct sock 对象的地址。

当一个新的网络包到达网卡时,内核会:

  1. 解析出IP和TCP头,得到四元组。
  2. 用这个四元组作为Key,去哈希表里查找。
  3. 瞬间找到对应的 struct sock 对象。
  4. 然后根据TCP状态,将数据包放入该连接的 sk_receive_queue,或回复ACK,或进行重传等。

深刻理解四次挥手

在刚刚的四次挥手的过程介绍中我们知道,每次执行一个close,就会触发一对FIN-ACK。

问:但是我们都已经把客户端的文件描述符关闭了,此刻你服务器的ACK怎么能发送给客户端呢??

这个时候我们可以调用这个系统调用:

在这里插入图片描述
在这里插入图片描述

我们只关闭客户端的写而不关闭读。


问:那么为什么要在等待 2MSL 时间过后,客户端才最终进入 CLOSED 状态?

在四次挥手的最后一步,主动关闭方(客户端)发送了最后一个 ACK,然后立即进入 TIME-WAIT 状态。

其原因有二:

一、

这个最终的 ACK 可能会丢失。

如果这个 ACK 在网络中丢失了,会发生什么?

  • 被动关闭方(服务器)正处于 LAST_ACK 状态,它在等待这个最终的 ACK
  • 它永远等不到,于是触发超时重传机制,会重新发送它的 FIN 报文。

现在,第一个 MSL 的作用就体现了:

  1. 客户端在 TIME_WAIT 状态下等待了第一个MSL的时间
  2. 如果在此期间,它收到了服务器重传的 FIN 报文(这证明它自己的最后一个 ACK 确实丢了),那么客户端会重新发送最后一个 ACK,并重置 2MSL 的计时器
  3. 这个重传的 ACK 会让服务器正常关闭,从而实现了连接的可靠终止

如果没有这个等待期,客户端发完 ACK 就直接关闭,那么当 ACK 丢失时,服务器将不断重传 FIN 直至超时,最终只能以错误状态重置连接,这不符合TCP“优雅关闭”的设计原则

第二个 MSL 的作用是:

  • 确保在主动关闭方(客户端)自己这个方向上,所有由于网络原因迟到的、属于本次连接的报文,都有足够的时间在网络中被丢弃。

为什么需要这个? 假设客户端在发出最后一个 ACK 后,立刻用相同的源端口号与服务器建立了新连接。

  • 此时,一个属于旧连接的、迟到的数据包到达了。
  • 服务器或客户端可能会错误地将这个历史报文当作新连接的数据接收,造成数据混乱

通过等待 2MSL,TCP确保了在旧连接中产生的任何一个报文,其存活时间都不可能超过 2MSL(因为一个报文的MSL已经定了它的最大寿命)。因此,当 TIME-WAIT 结束时,整个网络路径中关于这个旧连接的“遗迹”都已被彻底清理干净,为新连接提供了一个“纯洁”的通信环境。

同时,这个TIME_WAIT状态,也正是我们之前进行http以及TCP套接字使用后,主动断开连接重新加载时,可能出现的绑定失败的原因。就是因为还在TIME_WAIT状态,端口未释放。


六、TCP的其他特性理解

流量控制与滑动窗口

我们刚刚在介绍16位窗口大小的时候介绍了一下流量控制的特性。

现在我们继续深入理解一下流量控制与滑动窗口。

流量控制

接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.

因此 TCP 支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)

接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过 ACK 端通知发送端。

  • 窗口大小字段越大, 说明网络的吞吐量越高;
  • 接收端一旦发现自己的缓冲区快满了, 就会将窗口大小设置成一个更小的值通知给发送端;
  • 发送端接受到这个窗口之后, 就会减慢自己的发送速度;
  • 如果接收端缓冲区满了, 就会将窗口置为 0; 这时发送方不再发送数据, 但是需要定期发送一个窗口探测数据段, 使接收端把窗口大小告诉发送端.
在这里插入图片描述
在这里插入图片描述

接收端如何把窗口大小告诉发送端呢? 回忆我们的 TCP 首部中, 有一个 16 位窗口字段,就是存放了窗口大小信息。

那么问题来了, 16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么?

实际上, TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口字段的值左移 M 位。

要注意几点:

  1. 流量控制 != 发送变慢
  2. 首次发送时,我们进行过三次握手,已经做过了报文交换与窗口协商。

滑动窗口

假如这个是我们的TCP的发送缓冲区,那么它可以被划分成三部分:

在这里插入图片描述
在这里插入图片描述

其中,我们把可以直接发送的部分叫做滑动窗口。


如何理解滑动窗口呢?

在这里插入图片描述
在这里插入图片描述

请看上面一幅图。

我们可以把所谓的滑动窗口理解为start与end限制的一个下标范围,因为我们之前说过这些缓冲区就像是一个char类型的数组。

以这个图为例,我们的可以直接发送的数据范围就是1001-5000。

理论上,我们可以一次性将这些数据全部发完,但实际操作中更多的是选择分批发送。也就是先发送1000-1999,再发送2000-2999。

因为应用层的数据参数速度肯定不会很快,数据在缓冲区的积累是一个多次的过程,所以TCP选择“有数据就发,直到窗口耗尽”,而不是“等数据填满窗口再一起发”

并且,在数据链路层中,也不允许发送大的报文。


说明滑动窗口最重要的问题

**滑动窗口如何滑动??

滑动窗口的“滑动”,本质上是窗口左边界 LBR 的向右移动。这个移动是由收到新的确认序号触发的。

当发送方收到一个来自接收方的ACK,其中确认序号为 N 时,它执行以下操作:

  1. 这意味着接收方已经成功接收了所有序号小于 N 的字节。
  2. 因此,发送窗口的左边界会向右滑动到 N
  3. 窗口的右边界随之更新为 N + 最新通告的窗口大小

举例说明:

  • 初始状态:发送窗口 = [1000, 5000),通告窗口=4000。
  • 发送方发送了数据 [1000-2000)。
  • 收到ACK,确认号=2000,通告窗口=3500。
  • 窗口滑动:左边界从1000移动到2000。右边界更新为 2000 + 3500 = 5500。
  • 新窗口变为:[2000, 5500)。

所以流量控制就是通过滑动窗口实现的,滑动窗口的滑动是“前向”的,是基于确认的驱动。


**滑动窗口只能向右移动??可以向左移动吗?

只能向右,不能向左。

这是由TCP的序列号空间可靠性语义决定的。

  • 序列号是单调递增的:序列号在连接的整个生命周期内是不断变大的(虽然会回绕)。一个被确认的序列号(比如2000)意味着它之前的所有数据都已安全送达。从逻辑上讲,窗口的左边界不可能退回到一个已经被确认的序列号之前,那将是协议的严重错误。
  • 确认的不可逆性:ACK是累积性的。确认号=2000是一个永久性的状态,它不能被“撤销”。因此,依赖于确认号的窗口左边界也只能前进,不能后退。

如果窗口向左移动,意味着发送方可以重新发送序列号小于当前 LBR 的数据,这会导致接收方收到重复数据,破坏可靠性。


**可以变大吗?变小吗?不变吗?为0吗?

所有这些情况都会发生,并且都是由接收方控制的

窗口大小的变化,取决于接收方TCP通告窗口字段的值。

  • 变大非常常见。当接收方应用程序从接收缓冲区读取了大量数据后,空闲缓冲区变大。接收方会在下一个ACK中通告一个更大的窗口,发送方的窗口随之变大。
  • 变小非常常见。当接收方应用程序处理速度跟不上接收速度,导致缓冲区逐渐被填满时,接收方会通告一个更小的窗口,发送方必须立即减速。
  • 不变:也可能发生。当发送和接收速率达到平衡时,通告窗口可能在一段时间内保持稳定。
  • 变为0是流量控制的关键机制。当接收方缓冲区完全满时,它会通告窗口=0。这会完全阻止发送方传输新数据(直到窗口重新打开)。

窗口大小的动态变化,是TCP流量控制得以实现的根本。


**会超过发送缓冲区,导致越界吗???

不会,我们可以把这个想象成一个环形结构。

TCP的序列号是一个32位的无符号整数,范围是 [0, 2^32 - 1]。它会回绕。对于TCP协议逻辑来说,这是一个巨大的、虚拟的、环形的序号空间。 滑动窗口在这个虚拟空间上移动,永不越界,因为当序列号达到最大值后,会回绕到0。


理解滑动窗口的丢包问题

我们假设一个场景:

  • 发送窗口[1000, 4000)(共3000字节)
  • MSS:1000字节(为了简化,假设每个报文段携带1000字节)
  • 发送方依次发送了三个报文段:
    • P1: Seq=1000, 数据1000-1999
    • P2: Seq=2000, 数据2000-2999
    • P3: Seq=3000, 数据3000-3999

情况一:最左边的包丢失(P1丢失)

发生的过程:

  1. P1丢失,P2和P3成功到达接收方。
  2. 接收方发现期望的序列号是1000,但收到了2000和3000。这是乱序
  3. 接收方不能确认P2和P3,因为TCP使用累积确认。它只能反复发送对期望序列号的确认,即重复发送 ACK=1000
  4. 发送方会连续收到多个 ACK=1000(重复ACK)。

TCP的应对机制:快速重传

  • 当发送方连续收到3个重复的ACK时,它推断P1很可能丢失了(而不是简单的乱序)。
  • 于是,发送方立即重传P1,而不必等待P1的超时计时器到期。
  • 这是效率最高的重传方式。

客户端在收到应答之前怕发出的数据丢包,所以不会把发送的删除,这个数据会放在滑动窗口里

当某一段报文段丢失之后, 发送端会一直收到 1001 这样的 ACK, 就像是在提醒 "我想要的是 1001" 一样。如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后, 再次返回的 ACK 就是 7001 了(因为 2001 -7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为 "高速重发控制"(也叫 "快重传").

这里插入一个题外话,既然我们有了这个快重传,为什么还会有超时重传呢?因为超时重传是用来兜底的。

总结:最左边丢包 → 触发快速重传。


情况二:中间的包丢失(P2丢失)

发生的过程:

  1. P1成功到达,接收方回复 ACK=2000。窗口滑动到 [2000, 5000)
  2. P2丢失。
  3. P3成功到达。接收方期望Seq=2000,但收到了3000。同样是乱序。
  4. 接收方将P3存入缓冲区,然后重复发送 ACK=2000

TCP的应对机制:同样是快速重传

  • 这个过程和情况一完全一样。
  • 发送方连续收到对于2000的重复ACK,达到阈值(通常是3个)后,触发快速重传,立即重传P2。
  • 当P2的重传包到达后,接收方就拥有了连续的2000-3999的数据,于是发送一个累积的 ACK=4000

相当于转化为了最左边丢失

总结:中间丢包 → 同样触发快速重传。


情况三:最右边的包丢失(P3丢失)

发生的过程:

  1. P1和P2成功到达。
  2. 接收方确认P1和P2,发送 ACK=3000。此时窗口已经滑动,P3落在窗口内,它的丢失不影响对P1和P2的确认。
  3. 但是,P3本身丢失了,或者它的ACK丢失了。
  4. 此时,没有后续的包可以触发重复ACK,因为P3就是当前已发送的最后一个包。

TCP的应对机制:超时重传

  • 发送方为P3启动的重传计时器会超时
  • 超时后,发送方判定P3丢失,并重传P3
  • 超时重传是TCP所有重传机制中性能惩罚最大的一种,因为它等待的时间最长(RTO通常比一个RTT大得多)。

总结:最右边丢包 → 触发超时重传。


PSH,RST,与URG
PSH - 推送标志

TCP为了提高网络效率,通常会缓冲数据(在发送和接收缓冲区中),凑够一个比较“大”的包再发送,或者等缓冲区满了再交付给应用层。但这对于交互式应用(如Telnet、SSH)来说体验很差。

想象你在命令行里输入一个命令,你希望按下回车后能立刻执行并得到结果,而不是等操作系统“凑够数”再处理。

在对方的接收缓冲区堆满的情况,我们会进行窗口探测,如果窗口探测长时间的结果都是无空间,那么就会发送一个PSH置为1的报文。

它是在告诉TCP协议栈:“别等了,立刻把这个报文段发出去!

当接收方收到一个 PSH=1 的报文段时,它会立即将数据从接收缓冲区交付给上层应用程序,而不是等待缓冲区填满。


RST - 复位标志

这是TCP的 “紧急开关”“重置按钮”。它用于立即、非正常地终止一个连接

触发场景:

  • 访问未开放的端口:客户端尝试连接服务器的一个未被进程监听的端口,服务器会直接回复一个 RST=1 的报文。
  • 异常崩溃恢复:一方系统崩溃后重启,另一方却不知情,继续发送数据。重启方收到不属于任何当前连接的数据包,会回复 RST
  • 处理半打开连接:一方已经关闭或异常终止了连接,但另一方还认为连接有效。
  • 应用程序强制关闭:当应用程序想立即回收资源,而不是经过优雅的四次挥手时,可能会发送RST。
  • 收到非法序列号的报文:收到一个完全不属于当前连接上下文的数据包。

工作流程与特点:

  • 立即生效:一旦收到 RST,接收方会无条件地、立即地释放该连接的所有资源(缓冲区、端口等)。
  • 无需确认:RST报文段本身不需要被确认。它是一个单向的、强制的通知。
  • 粗暴有效:与 FIN 的“友好告别”不同,RST 是直接“掐断电话线”。

在三次握手时失败会发送RST,从而重新进行三次握手。


URG - 紧急标志

TCP是按需到达的,必须按照顺序,如果我们上传一个大文件,对方接受缓冲区就会被我拉满了。

后来我后悔了,点击终止上传发送一个终止报头,可能会导致终止时间很长才运行。

所以可以设置URG为1表示紧急情况,收到 URG=1 的报文后,TCP协议栈会优先将紧急数据提取出来,并通知上层应用程序(例如,通过产生一个异常或信号,如 SIGURG)。普通数据仍按正常流程处理。

这个通常会跟我们TCP格式中的16位紧急指针搭配使用。

通常我们会设置 URG=1,并填充 16位紧急指针 字段。这个指针是一个正的偏移量,它与本报文段的序列号相加,指向了紧急数据的最后一个字节的下一个字节

但是我们在读取的时候只知道偏移量,不知道应该读取多少个?

为了解决“读多少”的问题,TCP规范(如RFC 6093)和操作系统形成了一套约定俗成的实现方式。

紧急数据的长度通常被理解为1个字节

这个在我们使用RECV函数时,最后一个参数flag就是在这个场景下使用的:

在这里插入图片描述
在这里插入图片描述

拥塞控制

虽然 TCP 有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.

因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的。

我们不能只考虑两端的主机,也应该考虑一下网络的好坏。

TCP 引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。

在这里插入图片描述
在这里插入图片描述

此处引入一个概念称为拥塞窗口

  • 发送开始的时候, 定义拥塞窗口大小为 1
  • 每次收到一个 ACK 应答, 拥塞窗口加 1
  • 每次发送数据包的时候, 将拥塞窗口和接收端主机反馈的窗口大小做比较, 取较小的值作为实际发送的窗口

像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快。

主机会有自己的一个拥塞窗口里,是在发送端内部维护的而不是在报文里。

所以发送方可以同时发送的数据量,并不只是接收方的接收缓冲区决定的,而是由接收缓冲区与阻塞窗口共同决定的,取其二者中的较小值。


为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.

此处引入一个叫做慢启动的阈值:当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。

在这里插入图片描述
在这里插入图片描述

当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;

  • 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回 1;
  • 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;

当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降。拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。

拥塞窗口说实话就是对我们网络健康情况的一个估测值。拥塞窗口一直变化,象征着我们的网络健康状况也是一直变化的。


延迟应答

接收方在收到数据后,不要立即回复ACK,而是故意等待一个很短的时间(比如40毫秒)。如果在这个等待期内,恰好发生了以下两件事,就能带来巨大的性能提升:

  1. 本方的应用程序读走了接收缓冲区中的数据。
  2. 本方有要发送的数据,需要回复给对方(即触发了捎带应答)。
  • 假设接收端缓冲区为 1M. 一次收到了 500K 的数据; 如果立刻应答, 返回的窗口就是 500K
  • 但实际上可能处理端处理的速度很快, 10ms 之内就把 500K 数据从缓冲区消费掉了
  • 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来
  • 如果接收端稍微等一会再应答, 比如等待 200ms 再应答, 那么这个时候返回的窗口大小就是 1M

一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率

延迟应答听起来很美好,但也不能无限制地延迟,否则会破坏TCP的可靠性。 1. 延迟时间有上限

  • 通常延迟时间被设定为几十毫秒(例如Linux中常见的40ms)。
  • 这个时间必须远小于发送方的重传超时时间(RTO)。如果延迟ACK的时间超过了RTO,发送方会误以为数据丢失而触发不必要的重传,反而降低了性能。

2. 有“强制ACK”的规则

为了保证发送方不被卡住,即使在延迟应答模式下,接收方也不能对每个包都延迟。常见的规则包括:

  • “收到两个包,ACK一个”:在很多实现中(如Windows),每收到两个完整的数据包,就必须立即ACK一个。这是为了给发送方足够的反馈,尤其是在慢启动阶段。
  • 不能延迟ACK的ACK:如果收到的是对方对我方数据的确认(即ACK包),则不能延迟。
  • 有乱序包到达时立即ACK:一旦收到乱序的报文段(表明可能有包丢失),必须立即发送重复ACK,以触发快速重传,此时不能延迟。

捎带应答

在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收"的.。意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine,thank you”;

那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端。

这个例子大家想到了什么?

没错,就是我们的三次握手,其实之所以三次握手比四次挥手少了一次,就是因为捎带应答将ACK信息与要发送过去的报文信息合并了。

为什么四次挥手不能合并?因为在收到第一个FIN并回复ACK之后,被动关闭方(服务器)不一定能立即发送自己的FIN。 这导致它的ACK和FIN无法像握手那样“捎带”在同一个报文里


随机序号

在TCP三次握手开始时,通信双方(客户端和服务器)都会为自己选择的第一个字节数据分配一个序列号,这个序列号被称为 初始序列号

  • 客户端在第一个SYN报文中发送它的ISN。
  • 服务器在第二个SYN-ACK报文中发送它的ISN。

这个ISN不是从0开始的,而是一个随机生成的32位数字。这是为了防止被预测到序列号,以及减少游离报文的影响。


面向字节流

您可以想象TCP连接是一条源源不断的“水管”或“河流”,而数据就是流淌在其中的水。

核心特征:TCP不关心应用层数据的边界。它只负责将应用程序写入的数据视为一连串无结构的字节,可靠且有序地传输到对端。至于这些字节如何被划分成有意义的“消息”,TCP一概不管。

发送方:应用程序可能多次调用 send() 发送数据。

代码语言:javascript
复制
send("hello");
send(" ");
send("world");

TCP协议栈可能会将这三个调用产生的数据 "hello", " ", "world" 合并成一个TCP报文段发送出去,变成 "hello world"

接收方:应用程序调用 recv() 从接收缓冲区中读取数据。它可能一次读到 "hello wo",第二次读到 "rld"。TCP不保证一次 recv() 调用能取回多少数据,它只保证字节的顺序是正确的


创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;

  • 调用 write 时, 数据会先写入发送缓冲区中
  • 如果发送的字节数太长, 会被拆分成多个 TCP 的数据包发出
  • 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去
  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区
  • 然后应用程序可以调用 read 从接收缓冲区拿数据;

另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 **全双工

由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:

  • 写 100 个字节数据时, 可以调用一次 write 写 100 个字节, 也可以调用 100 次write, 每次写一个字节
  • 读 100 个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100 个字节, 也可以一次 read 一个字节, 重复 100 次.

二、 粘包问题

“粘包”这个说法其实是个俗称,它并非指TCP协议本身出了问题,而是由于TCP面向字节流的特性,导致接收方应用程序在读取数据时,无法正确区分原本不同消息的边界

1. 什么是粘包? 粘包有两种表现形式:

  • 多个消息被合并到一起:发送方发送了两次数据 "ABC""DEF",接收方一次 recv() 却收到了 "ABCDEF"。两个包“粘”在了一起。
  • 一个消息被拆分开:发送方发送了一个较长的消息 "Hello, World!",接收方第一次 recv() 收到了 "Hello, ",第二次 recv() 才收到 "World!"。一个包被“拆”开了。

2. 为什么会产生粘包?

根本原因就是上面提到的面向字节流。具体触发场景包括:

  • 发送方:TCP协议栈的 Nagle 算法会将多个小的应用层数据合并成一个报文段发送。
  • 接收方:TCP协议栈将接收到的数据先放入接收缓冲区。应用程序读取时,可能缓冲区中已经积累了多个报文段的数据,导致一次读出;也可能一个报文段的数据被分多次读出。

解决方案

既然TCP不维护消息边界,那么维护消息边界的责任就落在了应用程序身上。解决方案的核心思想都是:在应用层协议中,自定义消息的边界

定长法
  • 方法:每个应用层消息都被规定为固定的长度。例如,每个消息都是128字节。如果实际数据不足,则用空格或0填充。
  • 接收方处理:每次从缓冲区读取固定长度的数据(如128字节),这就自然分离出了每个消息。
  • 优缺点:实现简单,但不够灵活,可能浪费带宽。
分隔符法
  • 方法:在每个应用层消息的末尾加上一个特殊的分隔符,例如换行符 \n。许多协议如HTTP、FTP、Redis的通信协议都采用此法。
  • 接收方处理:从缓冲区读取数据,直到遇到分隔符,则认为一个完整的消息已经收到。
  • 优缺点:非常灵活。但需要确保分隔符不会在消息体内部出现,否则需要转义处理。
长度前缀法(最常用、最可靠的方法)
  • 方法:在每个应用层消息的头部,加上一个固定长度的字段,用来表示后面消息体的长度。
  • 接收方处理
    1. 先读取固定长度的消息头(比如4字节),解析出消息体的长度 N
    2. 然后继续从缓冲区读取恰好 N 字节的数据,这就构成了一个完整的消息。
    3. 重复此过程。
  • 优缺点:高效、准确,是工业级应用中最主流的方式。编程上稍复杂。
TCP 异常情况

在理想的网络世界中,TCP连接总是通过优雅的“四次挥手”来告别。但现实网络环境复杂多变,连接随时可能以各种意想不到的方式中断。理解这些异常情况及其应对机制,是构建稳定网络应用的基石。

一、 有序的告别:进程正常终止或机器重启

当连接的一方决定主动结束通信时,只要遵循了操作系统的标准流程,连接就能体面地关闭。

1. 进程终止

  • 核心原理:在Unix/Linux体系中,“万物皆文件”。Socket也是一个文件描述符。当进程终止时(无论是正常退出、被 kill 命令结束,或因异常崩溃),操作系统内核会作为“清理者”,负责回收该进程占用的所有资源,其中就包括关闭所有打开的文件描述符
  • 内核的关键角色:当内核关闭一个代表TCP连接的Socket时,它会触发标准的TCP连接终止协议。只要连接还存在,内核就会代为发送 FIN 报文,启动四次挥手流程。
  • 结论:因此,进程的突然终止并不等同于连接的粗暴断开。只要FIN报文能被成功发出,对端就能收到这个明确的结束信号,双方依然能完成完整的关闭序列。这与在应用程序中主动调用 close() 函数几乎没有区别。

2. 系统关机或重启

  • 机制:系统关机或重启是一个更具组织性的过程。操作系统会首先通知所有用户进程,要求它们自行退出。在这个过程中,大部分网络服务进程会有机会关闭其连接。对于未及时退出的进程,内核最终会强制介入,执行与“进程终止”相同的清理工作——关闭文件描述符并发送FIN。
  • 结论:只要在系统完全断电或重启之前,FIN报文有足够的时间被发送并抵达对端,连接的关闭就是正常的。这可以看作是一次大规模的、有序的进程终止事件
二、 无声的消失:机器掉电、系统崩溃或网络中断

这是最棘手的一类情况。通信的一方(我们称之为A方)瞬间“消失”,没有留下任何告别语(FIN报文)。这使得另一方(B方)的TCP连接会一直停留在 ESTABLISHED 状态,形成了一个 “半打开连接”——B方以为连接依然健康,而A方早已不复存在。

B方如何探测到这场“无声的消失”?主要有三种机制:

机制一:通过数据通信被动发现

  • 场景:当B方应用程序尝试向这个“僵尸连接”写入数据时,灾难恢复的序幕才被拉开。
  • 过程
    1. B方内核不断重传数据包,但永远收不到A方的ACK确认。
    2. 在经过多次重传(次数由系统配置决定)均告失败后,B方TCP协议栈会最终放弃。
    3. 此时,B方内核会向应用程序报告一个错误(如连接超时或主机不可达),并可能向网络中对A的地址发送一个 RST 复位报文,以清除本地的连接状态。
  • 局限:如果B方应用程序一直不发送数据,那么这个“僵尸连接”将永远消耗着B方的系统资源。

机制二:TCP保活定时器

  • 设计初衷:为了解决上述“僵尸连接”长期占用资源的问题,TCP提供了一个可选的 保活 机制。
  • 工作流程(以典型实现为例)
    1. 静默期:连接连续空闲(无数据交换)达到 tcp_keepalive_time(默认7200秒,即2小时)。
    2. 探测期:此后,探测方开始发送保活探测包。这些包没有实际数据,序列号是对端期望的ACK号减一,旨在引发对端的ACK回复。
    3. 重试与判决:每隔 tcp_keepalive_intvl(默认75秒)发送一次,最多发送 tcp_keepalive_probes(默认9次)次。
    4. 如果所有探测均未收到回复,探测方则断定连接已死亡,随即关闭连接并释放资源。
  • 评价:保活机制是传输层的安全网,但它默认关闭且探测周期非常长(以小时计),不适合需要快速感知故障的应用。

机制三:应用层心跳机制(最常用、最灵活的方案)

  • 为什么需要:对于即时通讯(QQ、微信)、在线游戏、微服务等应用,2小时的故障发现时间是无法接受的。它们需要在秒级甚至毫秒级内感知到连接中断。
  • 如何工作
    • 通信双方在应用层协议中约定,定期(如每隔30秒)向对方发送一个特殊的心跳包(或PING/PONG报文)。
    • 这个包通常很小,只包含必要的头部信息,有时也可携带一些业务状态。
    • 应用程序自己维护一个计时器,如果在规定时间内没有收到对端的心跳回复,就可以主动判定连接失效,并执行断开重连的逻辑。
  • 巨大优势
    • 极速感知:心跳间隔可由应用自由控制,实现快速故障发现。
    • 灵活可控:可根据网络状况和应用需求动态调整心跳策略。
    • 功能丰富:心跳包可以“搭车”传递一些简单的应用状态信息,实现双向的健康检查。

总结

为什么 TCP 这么复杂? 因为它既要保证可靠性, 同时又要尽可能的提高性能.

可靠性:

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重发
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

其他:

  • 定时器(超时重传定时器, 保活定时器, TIME_WAIT 定时器等)

TCP的核心理念,是在“绝对可靠”与“极致性能”这一看似矛盾的目标间,寻找那个精妙的平衡点。

  • 它的可靠性,并非一种魔法,而是一套环环相扣的“安全网”:通过 校验和 侦测错误,凭借序列号与确认应答 实现有序交付,依靠超时与重传 弥补丢失,并以严谨的连接管理 明确通信的生命周期。
  • 它的高性能,则是对简单“停止-等待”模型的彻底革命:滑动窗口 机制将串行改为并行,填满了网络管道;快速重传 以敏锐的洞察力取代了迟钝的超时等待;而延迟应答捎带应答 这对黄金组合,则于细微处减少开销,提升了整体效率。

然而,TCP最令人惊叹之处,在于它超越了单纯的端到端通信,展现出一种“天下为公”的全局智慧。流量控制 是君子协定,防止快的发送方淹没慢的接收方;而拥塞控制 则是社会公德,引导每个连接在争夺带宽时自我克制,共同维护整个网络的健康与稳定,避免了“拥塞崩溃”的悲剧。

最终,我们发现TCP的成功,源于它作为一个复杂系统的优雅分层:

  • 在微观上,它用序列号和ACK管理每一个字节的生死。
  • 在中观上,它用窗口和定时器调度数据的洪流。
  • 在宏观上,它用算法感知并适应整个网络的脉动。

尽管QUIC等新协议在特定场景下发出了挑战,但TCP所蕴含的设计思想——通过确认与重传实现可靠性、通过窗口机制实现性能优化、通过反馈循环实现系统稳定——早已成为分布式系统设计的基石。

理解TCP,不仅仅是学习一套网络规则,更是聆听一场关于如何在一个混乱无序的环境中,通过协作、反馈与自律,建立起高效、稳定秩序的经典演讲。它告诉我们,真正的强大,源于对复杂性的深刻理解与精巧驾驭。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-10-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 一、 引言:为什么需要TCP?
  • 二、TCP协议段格式
  • 三、TCP的报头问题
  • 四、TCP内核结构体
  • 五、TCP的可靠性理解
    • 确认应答(ACK)
    • 序号与确认序号
    • 16位窗口大小
    • 超时重传机制
    • 连接管理机制
      • ACK,SYN与FIN
      • 三次握手
      • 四次挥手
    • 深刻理解三次握手
    • 深刻理解四次挥手
  • 六、TCP的其他特性理解
    • 流量控制与滑动窗口
      • 流量控制
      • 滑动窗口
      • 说明滑动窗口最重要的问题
      • 理解滑动窗口的丢包问题
    • PSH,RST,与URG
      • PSH - 推送标志
      • RST - 复位标志
      • URG - 紧急标志
    • 拥塞控制
    • 延迟应答
    • 捎带应答
    • 随机序号
    • 面向字节流
    • 二、 粘包问题
      • 解决方案
    • TCP 异常情况
      • 一、 有序的告别:进程正常终止或机器重启
      • 二、 无声的消失:机器掉电、系统崩溃或网络中断
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档