我们昨天讲解了传输层的概念,端口,以及我们传输层的一个协议:UDP。
今天我们就来继续讲一下另外一个大名鼎鼎的传输层协议:TCP!
在我们日常的互联网体验中,无论是流畅的视频通话、精准的网页加载,还是稳定的文件传输,其背后都离不开一个默默无闻却又至关重要的功臣——传输控制协议(Transmission Control Protocol, TCP)。如果说IP协议定义了数据包如何跨越网络“寻路”,那么TCP则确保了这些数据包能够可靠、有序、不丢不重地抵达目的地。本文将深入TCP的内部世界,从核心概念到复杂机制,全面解析这一支撑现代互联网的基石协议。
互联网的基础是IP协议,但IP协议本身是一种“尽力而为”的无连接协议。这意味着:
对于电子邮件、文件传输、网页浏览等应用来说,这种不确定性是无法接受的。我们无法想象下载一个软件安装包,结果内容错乱或缺失一半。因此,我们需要一个更上层的协议来弥补IP的不足,为应用程序提供一个可靠的、面向连接的、端到端的字节流服务。这就是TCP诞生的初衷。
TCP的核心目标:在不可靠的IP网络之上,建立一个可靠的“数据管道”,使得发送方发送的字节流能够原封不动、顺序正确地被接收方接收。
要想学会,理解TCP,我们就需要先知道TCP协议的格式:

我先简单对这些字段进行解释,会在后面的小节对这些字段进行更加深层次的解释:
关键字段解释:
ACK = N 表示序列号 N-1 及之前的所有数据都已确认。

请看这个图,在我们的网络的信息传输中,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协议本质上也是双方操作系统约定好的一个结构化数据对象,就是结构体。
Linux内核中自然存在对应的一个核心结构体 —— struct tcphdr。
这个结构体精确地映射了TCP报文段的格式,使得内核能够通过操作结构体的成员来构建发送的包,或解析接收到的包
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是可靠的,UDP是不可靠的。
我们该如何理解TCP的可靠性呢?我认为,要理解他的可靠性,主要应该先理解他的确认应答的机制。
这是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时,这个过程就是用户与系统的一个生产者消费者模型问题。
所以在读取数据时,如果底层接收缓冲区没数据,我们上层就会被阻塞,本质上就是在做生产者消费者模型的同步问题。
我们知道,TCP在发送方与接收双方都各自有一个发送缓冲区与接收缓冲区。
如果发送方发送了多条数据,但是接收方的接收缓冲区已经快满了,并且此时已经无法接收更多信息了。多余的数据只会被丢弃。虽然我们后面说没收到应答,会有重新发送的机制。
但是这样就太浪费时间了。操作系统不做浪费时间,浪费空间的事情。
所以我们需要进行:流量控制!
16位的窗口大小,正是TCP实现其核心功能 「流量控制」 的关键所在。
窗口大小 是一个16位的字段,由接收方在发送给发送方的TCP报文段中设置。
它的含义非常明确:“我的接收缓冲区目前还有这么多字节的空闲空间,你可以发送这么多字节的数据过来。”
换句话说,它直接定义了发送方在当前时刻允许发送的、未被确认的数据量的上限。
当接收方来不及接收时,发送方选择发慢点,发少点,甚至不发。
这就跟我们向刻度杯里倒水一样的道理,我们会根据杯子水平线的刻度控制我们倒水的速度。
通信双方的报文都是发给对方的,所以填的信息是就是自己接收缓冲区剩余空间的大小。双方都要进行流量控制。
我们之前都默认我们的TCP传输时没有出错,但事实上怎么可能没有出错的情况呢。
超时重传是TCP实现可靠数据传输最核心的机制之一。它的基本思想直观易懂:“发送一个数据后,如果在一个合理的时间内没有收到确认,就再发一次。” 但深入其内部,你会发现TCP的设计极其精巧,远非“傻等”那么简单。
网络本质是不可靠的。数据包在传输过程中可能会因为多种原因丢失:
确认应答机制只能处理“成功接收”的情况。对于“数据包丢失”和“确认应答丢失”这两种情况,发送方唯一的感知就是迟迟收不到ACK。

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

因此主机 B 会收到很多重复数据. 那么 TCP 协议需要能够识别出那些包是重复的包, 并且把重复的丢弃掉。
这时候我们可以利用前面提到的序列号, 就可以很容易做到去重的效果.
那么, 如果超时的时间如何确定?
为了解决这个问题,TCP为每一个已发送但未确认的报文段都启动一个重传计时器。如果计时器超时,就执行重传。
最理想的情况下, 找到一个最小的时间, 保证 “确认应答一定能在这个时间内返回”。但是这个时间的长短, 随着网络环境的不同, 是有差异的.
TCP 为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间.
Linux 中(BSD Unix 和 Windows 也是如此), 超时以 500ms 为一个单位进行控制, 每次判定超时重发的超时时间都是 500ms 的整数倍。
如果重发一次之后, 仍然得不到应答, 等待 2 x 500ms 后再进行重传。
如果仍然得不到应答, 等待 4 x 500ms 进行重传. 依次类推, 以指数形式递增。为什么这样做呢?因为它也不知道你的网络状态如何。
累计到一定的重传次数, TCP 认为网络或者对端主机出现异常, 强制关闭连接。
在正常情况下, TCP 要经过三次握手建立连接, 四次挥手断开连接。
大家可以看见我们TCP格式中的六个标记位。为什么会有这么多标记位呢?

我们发送的各种TCP报文,其实都是有种类的,而我们标记位中的ACK标记位,当它置为1的时候,就是表明自己是一个确认报文,你需要关心我的确认序号。 ACK标记几乎无处不在。一旦连接建立,几乎所有的报文都会把ACK置为1。在三次握手的后两次,以及四次挥手的每一步,你都能看到ACK=1的身影。
因为除了纯粹的连接请求(第一个SYN),TCP通信本质上是一个“带确认的对话”。
而SYN是:同步标志位,表示这是一个建立连接的请求。三次握手的前两次。客户端和服务器各发一次SYN=1的报文,来交换彼此的初始序列号。
FIN:连接断开标记位,通信结束的时候使用,执行握手协商。四次挥手中,主动关闭方和被动关闭方各发送一次FIN=1的报文。
在了解了这三个标记位号,我们就可以继续来讲一下三次握手了。
三次握手是TCP连接建立的过程。其核心目标是:

三次握手的本质,就是 SYN 和 ACK 这两个标志位有序地“亮起”与“回应”的过程。
以我们的客户端与服务端的通信为例子:
初始状态:
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 的报文。
SYN_ACK,证明服务器的发送和自己的接收能力都没问题。于是客户端从 SYN_SENT 进入 ESTABLISHED(连接已建立)状态,可以开始发数据了。
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 的报文。
ACK,知道服务器已经收到它的关闭请求了。于是从 FIN_WAIT_1 进入 FIN_WAIT_2 状态。
FIN_WAIT_2 的含义:“结束等待2”。服务器已经确认了我的关闭,现在我正在等待服务器也发出它自己的关闭请求(FIN)。ESTABLISHED 进入 CLOSE_WAIT 状态。
CLOSE_WAIT 的含义:“关闭等待”。我知道客户端已经没数据发了,但我可能还有数据要传给它。我正处于半关闭状态。(这里的半关闭状态我们后面理解)(此时,从客户端到服务器的数据通道已关闭。但服务器到客户端的通道还开着,服务器可以继续发送剩余数据)
第三次挥手 (服务器发送 FIN)
close() 时,发送一个 FIN=1 (通常也带 ACK=1) 的报文。
CLOSE-WAIT 进入 LAST-ACK 状态。
LAST-ACK 的含义:“最后确认”。我也发出了关闭请求(FIN),现在正在等待客户端给我最后一个确认(ACK)。第四次挥手 (客户端发送 ACK)
FIN 后,回复一个 ACK=1 的报文。
ACK,证明连接可以安全关闭了。于是服务器从 LAST-ACK 进入 CLOSED 状态。
ACK 后,进入 TIME-WAIT 状态。
TIME-WAIT 的含义:“时间等待”。我会在这里等待 2MSL 的时间。这么做有两个目的:
CLOSED 状态。
至此,连接完全关闭。
以上只是个简单减少过程,相信大家还是有很多疑问,没关系我们慢慢来解惑。
问:为什么一定是三次握手呢?不能是四次五次六次?
这是因为三次是建立一条“双向”可靠通信通道的【最小】且【必要】的次数。
我们来拆解一下:
1. 第一次握手 (Client -> Server)
2. 第二次握手 (Server -> Client)
3. 关键的第三次握手 (Client -> Server)
问:三次握手就一定要成功吗?不可以失败吗?
三次握手当然可能失败。TCP的可靠,指的是它能发现错误并处理,而不是保证100%成功。
握手过程中的任何一个环节都可能出问题:
1. 第一次握手 (Client SYN) 就失败
SYN-SENT 状态等不到 SYN-ACK,会超时重传SYN。重传几次后仍失败,则 connect() 调用返回失败。
2. 第二次握手 (Server SYN-ACK) 失败
SYN-SENT 状态等不到回复,超时重传SYN。
SYN-RCVD 状态等不到ACK,也会超时重传SYN-ACK。
3. 第三次握手 (Client ACK) 失败
ESTABLISHED 状态,并可能开始发送数据。
SYN-RCVD 状态一直等不到ACK,会超时重传SYN-ACK。
RST 复位报文,强制Client断开连接。
问:为什么要三次握手?
问:我们如何理解建立连接呢?连接是什么?
从应用程序的角度看,连接是一个抽象的通信通道,像一个虚拟的电话线。
但从操作系统内核的角度看,连接就是一个拥有特定状态和资源的上下文环境。
当你说“建立了一个TCP连接”,内核里实际发生了:
ESTABLISHED)。
所以,连接就是内核为了维护一次特定通信所需的所有数据与状态的集合。
一条连接,一定会和一个文件对应,因为一个连接一个fd,并且连接在OS内部,会存在很多个!!!
如果OS要对连接进行管理,那就只有一个办法,就是:先描述,在组织!
对于TCP连接,Linux内核用一个非常复杂的结构体(struct sock)来“描述”它。这个结构体里包含了维护这个连接所需的全部信息:
/ 伪代码,示意核心成员
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 对象后,不能胡乱堆在内存里。它必须用高效的数据结构把它们组织起来,以便快速地进行查找、插入和删除。
最常见的组织方式是通过哈希表:
struct sock 对象的地址。当一个新的网络包到达网卡时,内核会:
struct sock 对象。
sk_receive_queue,或回复ACK,或进行重传等。
在刚刚的四次挥手的过程介绍中我们知道,每次执行一个close,就会触发一对FIN-ACK。
问:但是我们都已经把客户端的文件描述符关闭了,此刻你服务器的ACK怎么能发送给客户端呢??
这个时候我们可以调用这个系统调用:

我们只关闭客户端的写而不关闭读。
问:那么为什么要在等待 2MSL 时间过后,客户端才最终进入 CLOSED 状态?
在四次挥手的最后一步,主动关闭方(客户端)发送了最后一个 ACK,然后立即进入 TIME-WAIT 状态。
其原因有二:
一、
这个最终的 ACK 可能会丢失。
如果这个 ACK 在网络中丢失了,会发生什么?
LAST_ACK 状态,它在等待这个最终的 ACK。
FIN 报文。
现在,第一个 MSL 的作用就体现了:
TIME_WAIT 状态下等待了第一个MSL的时间。
FIN 报文(这证明它自己的最后一个 ACK 确实丢了),那么客户端会重新发送最后一个 ACK,并重置 2MSL 的计时器。
ACK 会让服务器正常关闭,从而实现了连接的可靠终止。
如果没有这个等待期,客户端发完 ACK 就直接关闭,那么当 ACK 丢失时,服务器将不断重传 FIN 直至超时,最终只能以错误状态重置连接,这不符合TCP“优雅关闭”的设计原则
第二个 MSL 的作用是:
为什么需要这个?
假设客户端在发出最后一个 ACK 后,立刻用相同的源端口号与服务器建立了新连接。
通过等待 2MSL,TCP确保了在旧连接中产生的任何一个报文,其存活时间都不可能超过 2MSL(因为一个报文的MSL已经定了它的最大寿命)。因此,当 TIME-WAIT 结束时,整个网络路径中关于这个旧连接的“遗迹”都已被彻底清理干净,为新连接提供了一个“纯洁”的通信环境。
同时,这个TIME_WAIT状态,也正是我们之前进行http以及TCP套接字使用后,主动断开连接重新加载时,可能出现的绑定失败的原因。就是因为还在TIME_WAIT状态,端口未释放。
我们刚刚在介绍16位窗口大小的时候介绍了一下流量控制的特性。
现在我们继续深入理解一下流量控制与滑动窗口。
接收端处理数据的速度是有限的. 如果发送端发的太快, 导致接收端的缓冲区被打满, 这个时候如果发送端继续发送, 就会造成丢包, 继而引起丢包重传等等一系列连锁反应.
因此 TCP 支持根据接收端的处理能力, 来决定发送端的发送速度. 这个机制就叫做流量控制(Flow Control)
接收端将自己可以接收的缓冲区大小放入 TCP 首部中的 “窗口大小” 字段, 通过 ACK 端通知发送端。

接收端如何把窗口大小告诉发送端呢? 回忆我们的 TCP 首部中, 有一个 16 位窗口字段,就是存放了窗口大小信息。
那么问题来了, 16 位数字最大表示 65535, 那么 TCP 窗口最大就是 65535 字节么?
实际上, TCP 首部 40 字节选项中还包含了一个窗口扩大因子 M, 实际窗口大小是 窗口字段的值左移 M 位。
要注意几点:
假如这个是我们的TCP的发送缓冲区,那么它可以被划分成三部分:

其中,我们把可以直接发送的部分叫做滑动窗口。
如何理解滑动窗口呢?

请看上面一幅图。
我们可以把所谓的滑动窗口理解为start与end限制的一个下标范围,因为我们之前说过这些缓冲区就像是一个char类型的数组。
以这个图为例,我们的可以直接发送的数据范围就是1001-5000。
理论上,我们可以一次性将这些数据全部发完,但实际操作中更多的是选择分批发送。也就是先发送1000-1999,再发送2000-2999。
因为应用层的数据参数速度肯定不会很快,数据在缓冲区的积累是一个多次的过程,所以TCP选择“有数据就发,直到窗口耗尽”,而不是“等数据填满窗口再一起发”。
并且,在数据链路层中,也不允许发送大的报文。
**滑动窗口如何滑动??
滑动窗口的“滑动”,本质上是窗口左边界 LBR 的向右移动。这个移动是由收到新的确认序号触发的。
当发送方收到一个来自接收方的ACK,其中确认序号为 N 时,它执行以下操作:
N 的字节。
N。
N + 最新通告的窗口大小。
举例说明:
所以流量控制就是通过滑动窗口实现的,滑动窗口的滑动是“前向”的,是基于确认的驱动。
**滑动窗口只能向右移动??可以向左移动吗?
只能向右,不能向左。
这是由TCP的序列号空间和可靠性语义决定的。
如果窗口向左移动,意味着发送方可以重新发送序列号小于当前 LBR 的数据,这会导致接收方收到重复数据,破坏可靠性。
**可以变大吗?变小吗?不变吗?为0吗?
所有这些情况都会发生,并且都是由接收方控制的
窗口大小的变化,取决于接收方TCP通告窗口字段的值。
窗口大小的动态变化,是TCP流量控制得以实现的根本。
**会超过发送缓冲区,导致越界吗???
不会,我们可以把这个想象成一个环形结构。
TCP的序列号是一个32位的无符号整数,范围是 [0, 2^32 - 1]。它会回绕。对于TCP协议逻辑来说,这是一个巨大的、虚拟的、环形的序号空间。 滑动窗口在这个虚拟空间上移动,永不越界,因为当序列号达到最大值后,会回绕到0。
我们假设一个场景:
[1000, 4000)(共3000字节)
发生的过程:
ACK=1000。
ACK=1000(重复ACK)。
TCP的应对机制:快速重传
客户端在收到应答之前怕发出的数据丢包,所以不会把发送的删除,这个数据会放在滑动窗口里
当某一段报文段丢失之后, 发送端会一直收到 1001 这样的 ACK, 就像是在提醒 "我想要的是 1001" 一样。如果发送端主机连续三次收到了同样一个 "1001" 这样的应答, 就会将对应的数据 1001 - 2000 重新发送;这个时候接收端收到了 1001 之后, 再次返回的 ACK 就是 7001 了(因为 2001 -7000)接收端其实之前就已经收到了, 被放到了接收端操作系统内核的接收缓冲区中。这种机制被称为 "高速重发控制"(也叫 "快重传").
这里插入一个题外话,既然我们有了这个快重传,为什么还会有超时重传呢?因为超时重传是用来兜底的。
总结:最左边丢包 → 触发快速重传。
发生的过程:
ACK=2000。窗口滑动到 [2000, 5000)。
ACK=2000。
TCP的应对机制:同样是快速重传
ACK=4000。
相当于转化为了最左边丢失
总结:中间丢包 → 同样触发快速重传。
发生的过程:
ACK=3000。此时窗口已经滑动,P3落在窗口内,它的丢失不影响对P1和P2的确认。
TCP的应对机制:超时重传
总结:最右边丢包 → 触发超时重传。
TCP为了提高网络效率,通常会缓冲数据(在发送和接收缓冲区中),凑够一个比较“大”的包再发送,或者等缓冲区满了再交付给应用层。但这对于交互式应用(如Telnet、SSH)来说体验很差。
想象你在命令行里输入一个命令,你希望按下回车后能立刻执行并得到结果,而不是等操作系统“凑够数”再处理。
在对方的接收缓冲区堆满的情况,我们会进行窗口探测,如果窗口探测长时间的结果都是无空间,那么就会发送一个PSH置为1的报文。
它是在告诉TCP协议栈:“别等了,立刻把这个报文段发出去!”
当接收方收到一个 PSH=1 的报文段时,它会立即将数据从接收缓冲区交付给上层应用程序,而不是等待缓冲区填满。
这是TCP的 “紧急开关” 或 “重置按钮”。它用于立即、非正常地终止一个连接。
触发场景:
RST=1 的报文。
RST。
工作流程与特点:
RST,接收方会无条件地、立即地释放该连接的所有资源(缓冲区、端口等)。
FIN 的“友好告别”不同,RST 是直接“掐断电话线”。
在三次握手时失败会发送RST,从而重新进行三次握手。
TCP是按需到达的,必须按照顺序,如果我们上传一个大文件,对方接受缓冲区就会被我拉满了。
后来我后悔了,点击终止上传发送一个终止报头,可能会导致终止时间很长才运行。
所以可以设置URG为1表示紧急情况,收到 URG=1 的报文后,TCP协议栈会优先将紧急数据提取出来,并通知上层应用程序(例如,通过产生一个异常或信号,如 SIGURG)。普通数据仍按正常流程处理。
这个通常会跟我们TCP格式中的16位紧急指针搭配使用。
通常我们会设置 URG=1,并填充 16位紧急指针 字段。这个指针是一个正的偏移量,它与本报文段的序列号相加,指向了紧急数据的最后一个字节的下一个字节
但是我们在读取的时候只知道偏移量,不知道应该读取多少个?
为了解决“读多少”的问题,TCP规范(如RFC 6093)和操作系统形成了一套约定俗成的实现方式。
紧急数据的长度通常被理解为1个字节。
这个在我们使用RECV函数时,最后一个参数flag就是在这个场景下使用的:

虽然 TCP 有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据. 但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵. 在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的。
我们不能只考虑两端的主机,也应该考虑一下网络的好坏。
TCP 引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据。

此处引入一个概念称为拥塞窗口
像上面这样的拥塞窗口增长速度, 是指数级别的. “慢启动” 只是指初使时慢, 但是增长速度非常快。
主机会有自己的一个拥塞窗口里,是在发送端内部维护的而不是在报文里。
所以发送方可以同时发送的数据量,并不只是接收方的接收缓冲区决定的,而是由接收缓冲区与阻塞窗口共同决定的,取其二者中的较小值。
为了不增长的那么快, 因此不能使拥塞窗口单纯的加倍.
此处引入一个叫做慢启动的阈值:当拥塞窗口超过这个阈值的时候, 不再按照指数方式增长, 而是按照线性方式增长。

当 TCP 开始启动的时候, 慢启动阈值等于窗口最大值;
当 TCP 通信开始后, 网络吞吐量会逐渐上升; 随着网络发生拥堵, 吞吐量会立刻下降。拥塞控制, 归根结底是 TCP 协议想尽可能快的把数据传输给对方, 但是又要避免给网络造成太大压力的折中方案。
拥塞窗口说实话就是对我们网络健康情况的一个估测值。拥塞窗口一直变化,象征着我们的网络健康状况也是一直变化的。
接收方在收到数据后,不要立即回复ACK,而是故意等待一个很短的时间(比如40毫秒)。如果在这个等待期内,恰好发生了以下两件事,就能带来巨大的性能提升:
一定要记得, 窗口越大, 网络吞吐量就越大, 传输效率就越高. 我们的目标是在保证网络不拥塞的情况下尽量提高传输效率
延迟应答听起来很美好,但也不能无限制地延迟,否则会破坏TCP的可靠性。 1. 延迟时间有上限
2. 有“强制ACK”的规则
为了保证发送方不被卡住,即使在延迟应答模式下,接收方也不能对每个包都延迟。常见的规则包括:
在延迟应答的基础上, 我们发现, 很多情况下, 客户端服务器在应用层也是 "一发一收"的.。意味着客户端给服务器说了 “How are you”, 服务器也会给客户端回一个 “Fine,thank you”;
那么这个时候 ACK 就可以搭顺风车, 和服务器回应的 “Fine, thank you” 一起回给客户端。
这个例子大家想到了什么?
没错,就是我们的三次握手,其实之所以三次握手比四次挥手少了一次,就是因为捎带应答将ACK信息与要发送过去的报文信息合并了。
为什么四次挥手不能合并?因为在收到第一个FIN并回复ACK之后,被动关闭方(服务器)不一定能立即发送自己的FIN。 这导致它的ACK和FIN无法像握手那样“捎带”在同一个报文里
在TCP三次握手开始时,通信双方(客户端和服务器)都会为自己选择的第一个字节数据分配一个序列号,这个序列号被称为 初始序列号。
这个ISN不是从0开始的,而是一个随机生成的32位数字。这是为了防止被预测到序列号,以及减少游离报文的影响。
您可以想象TCP连接是一条源源不断的“水管”或“河流”,而数据就是流淌在其中的水。
核心特征:TCP不关心应用层数据的边界。它只负责将应用程序写入的数据视为一连串无结构的字节,可靠且有序地传输到对端。至于这些字节如何被划分成有意义的“消息”,TCP一概不管。
发送方:应用程序可能多次调用 send() 发送数据。
send("hello");
send(" ");
send("world");TCP协议栈可能会将这三个调用产生的数据 "hello", " ", "world" 合并成一个TCP报文段发送出去,变成 "hello world"。
接收方:应用程序调用 recv() 从接收缓冲区中读取数据。它可能一次读到 "hello wo",第二次读到 "rld"。TCP不保证一次 recv() 调用能取回多少数据,它只保证字节的顺序是正确的。
创建一个 TCP 的 socket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
另一方面, TCP 的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 **全双工
由于缓冲区的存在, TCP 程序的读和写不需要一一匹配, 例如:
“粘包”这个说法其实是个俗称,它并非指TCP协议本身出了问题,而是由于TCP面向字节流的特性,导致接收方应用程序在读取数据时,无法正确区分原本不同消息的边界。
1. 什么是粘包? 粘包有两种表现形式:
"ABC" 和 "DEF",接收方一次 recv() 却收到了 "ABCDEF"。两个包“粘”在了一起。
"Hello, World!",接收方第一次 recv() 收到了 "Hello, ",第二次 recv() 才收到 "World!"。一个包被“拆”开了。
2. 为什么会产生粘包?
根本原因就是上面提到的面向字节流。具体触发场景包括:
既然TCP不维护消息边界,那么维护消息边界的责任就落在了应用程序身上。解决方案的核心思想都是:在应用层协议中,自定义消息的边界。
\n。许多协议如HTTP、FTP、Redis的通信协议都采用此法。
N。
N 字节的数据,这就构成了一个完整的消息。
在理想的网络世界中,TCP连接总是通过优雅的“四次挥手”来告别。但现实网络环境复杂多变,连接随时可能以各种意想不到的方式中断。理解这些异常情况及其应对机制,是构建稳定网络应用的基石。
当连接的一方决定主动结束通信时,只要遵循了操作系统的标准流程,连接就能体面地关闭。
1. 进程终止
kill 命令结束,或因异常崩溃),操作系统内核会作为“清理者”,负责回收该进程占用的所有资源,其中就包括关闭所有打开的文件描述符。
close() 函数几乎没有区别。
2. 系统关机或重启
这是最棘手的一类情况。通信的一方(我们称之为A方)瞬间“消失”,没有留下任何告别语(FIN报文)。这使得另一方(B方)的TCP连接会一直停留在 ESTABLISHED 状态,形成了一个 “半打开连接”——B方以为连接依然健康,而A方早已不复存在。
B方如何探测到这场“无声的消失”?主要有三种机制:
机制一:通过数据通信被动发现
RST 复位报文,以清除本地的连接状态。
机制二:TCP保活定时器
tcp_keepalive_time(默认7200秒,即2小时)。
tcp_keepalive_intvl(默认75秒)发送一次,最多发送 tcp_keepalive_probes(默认9次)次。
机制三:应用层心跳机制(最常用、最灵活的方案)
为什么 TCP 这么复杂? 因为它既要保证可靠性, 同时又要尽可能的提高性能.
可靠性:
提高性能:
其他:
TCP的核心理念,是在“绝对可靠”与“极致性能”这一看似矛盾的目标间,寻找那个精妙的平衡点。
然而,TCP最令人惊叹之处,在于它超越了单纯的端到端通信,展现出一种“天下为公”的全局智慧。流量控制 是君子协定,防止快的发送方淹没慢的接收方;而拥塞控制 则是社会公德,引导每个连接在争夺带宽时自我克制,共同维护整个网络的健康与稳定,避免了“拥塞崩溃”的悲剧。
最终,我们发现TCP的成功,源于它作为一个复杂系统的优雅分层:
尽管QUIC等新协议在特定场景下发出了挑战,但TCP所蕴含的设计思想——通过确认与重传实现可靠性、通过窗口机制实现性能优化、通过反馈循环实现系统稳定——早已成为分布式系统设计的基石。
理解TCP,不仅仅是学习一套网络规则,更是聆听一场关于如何在一个混乱无序的环境中,通过协作、反馈与自律,建立起高效、稳定秩序的经典演讲。它告诉我们,真正的强大,源于对复杂性的深刻理解与精巧驾驭。