传输层协议UDP/TCP

在学习HTTP 协议时,一直是在应用层,接下来我们继续向下,学习传输层的协议。传输层最具代表的协议:UDP和TCP。

1. UDP 协议

1. 端口号 port

端口号 port 标识主机上进行通信的不同的应用程序。

在TCP/IP协议中,用"源IP","源端口号","目的IP","目的端口号","协议号"这样一个五元组来标识一个通信(可以通过netstat-n查看)。

端口号范围划分:

0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,它们的端口号都是固定的。

1024-65535:操作系统动态分配的端口号。客户端程序的端口号,就是由操作系统从这个范围分配的

有些服务器是非常常用的,为了使用方便,人们约定一些常用的服务器,都是用以下这些固定的端口号:

ssh 服务器 ------ 使用22端口

ftp 服务器 ------ 使用21端口

telnet 服务器 ------ 使用23端口

http 服务器 ------ 使用80端口

https 服务器 ------ 使用443端口

有关端口号的问题

一个端口号是否可以被多个进程绑定?不能,因为端口号是一个key值,它需要标识进程的唯一性。

一个进程是否可以绑定多个端口号?可以。端口号对进程是1对1,进程对端口号可以是1对多。


2. UDP 协议的格式

UDP 协议整个报头部分分为5个区域。数据部分就是从应用层获取到的数据。UDP协议规定端口号是16位的(0-65535)。

协议的两个核心问题

问题1:报头和有效载荷如何分离?

协议 分离机制
HTTP 根据空行(\r\n\r\n)分离请求行/头部和正文
UDP 固定长度报头 (8字节),无需额外分隔符

UDP的具体做法:

  • 报文前8个字节固定为UDP报头
  • 报头中的len字段(16位UDP长度)表示整个报文的长度
  • 通过len - 8即可准确计算数据部分长度

问题2:有效载荷如何分用(交付给上层)?

协议 分用机制
HTTP 反序列化后交给应用层处理
UDP 根据报头中的16位目的端口号(dest),确定交付给哪个进程

延申出的问题:

问题1:UDP是否存在粘包问题?不存在。

UDP协议设计时就是用户数据报协议 。每个UDP报文都是独立的、边界明确的 :每次 recvfrom 返回一个完整的UDP数据报,不会出现TCP那种"读一半"或"多读"的情况。

问题2:如何理解UDP报头?

UDP报头本质就是一个C语言结构体,如下所示:

cpp 复制代码
struct udphdr {
    __u16   source;    // 源端口
    __u16   dest;      // 目的端口
    __u16   len;       // 总长度
    __u16   check;     // 校验和
};

是否需要序列化/反序列化?不需要。 UDP传输层属于操作系统内核 ,通信双方(内核之间)可以直接传递结构体变量。序列化/反序列化是应用层为了跨平台/跨语言通信才需要的。

问题3:如何理解报文封装过程?

封装的本质:指针移动 + 结构体拷贝。

用户缓冲区(应用层数据)

↓ 拷贝到内核缓冲区(中间位置)

预留报头空间 \| 应用层数据

↑ data指针 ↑ tail指针

向下交付到UDP层:

data指针向左移动8字节(报头长度)

填充struct udphdr到预留空间

封装就是对结构体变量进行拷贝,填充到预留的报头空间。

问题4:如何理解报文的内核管理?

OS内部存在海量报文,管理原则:先描述,再组织。真正的报文 = struct sk_buff(管理结构)+ 报文数据。

struct sk_buff关键字段:

cpp 复制代码
struct sk_buff {
    unsigned char   *head,   // 缓冲区起始
                    *data,   // 有效数据起始(可移动)
                    *tail,   // 有效数据结束
                    *end;    // 缓冲区结束
    // ... 其他管理字段
};

四个指针的作用:

  • head/end:标记整个缓冲区的边界(固定)
  • data/tail:标记当前有效数据的边界(移动)

封装/解包的核心就是移动data指针:

  • 封装(向下层): data指针向上(低地址)移动,留出报头空间
  • 解包(向上层): data指针向下(高地址)移动,跳过报头

报文贯穿协议栈的过程,就是data指针不断移动的过程。

问题5:如何用文件原理读取数据到应用层?

结构体关联链:

struct socket(VFS层)

├── struct file *file ←→ 关联文件描述符fd

├── wait_queue_head_t wait // 阻塞进程等待队列

└── struct sock *sk // 指向具体协议结构(多态基类指针)

struct inet_sock(INET层,继承自sock)

├── struct sock sk // 基类嵌入

├── __u32 daddr/rcv_saddr // 目的/源IP

├── __u16 dport/num // 目的/源端口

└── struct sock *sk → 指向...

struct udp_sock(UDP专用,继承自inet_sock)

├── struct inet_sock inet // 基类嵌入

└── __u16 len // 待发送数据总长度

示意图:

多态实现: struct sock *sk是基类指针,指向udp_sock/tcp_sock等派生类,实现不同协议的分发处理。

接收队列: sk_receive_queue(struct sk_buff_head)管理所有待读取的报文。

文件原理读取:

  1. 用户read/recvfrom → 查fd表 → 找到struct file
  2. struct file → private_data → struct socket
  3. socket → sk → 协议专用结构
  4. 从sk_receive_queue取sk_buff,拷贝数据到用户空间

3. UDP

UDP的特点总结

特性 说明
无连接 知道对端IP和端口就直接传输,无需建立连接(像寄信)
不可靠 无确认机制、无重传机制;丢包后协议层不通知应用层
面向数据报 应用层给多少,UDP发多少;不拆分、不合并

用 UDP传输100个字节的数据:如果发送端调用一次 sendto,发送100个字节,那么接收端也必须调用对应的一次 recvfrom,接收 100 个字节;而不能循环调用10次 recvfrom,每次接收10个字节。

既然 udp 协议都不可靠了,为什么还要有它呢?

不可靠并不是说,数据传不过去,只是在传输过程中,对于丢包了不关心。在传输过程中,丢包发生的概率很低。udp不可靠,并不代表它不能用

角度 说明
丢包概率 现代网络丢包率很低,UDP简单高效
实时性 视频通话、游戏等场景,重传延迟比丢包更可怕
轻量级 无需维护连接状态,资源消耗极低
灵活性 应用层可自行实现可靠性(如QUIC基于UDP)

UDP的缓冲区机制

缓冲区 存在性 特点
发送缓冲区 无真正意义上的发送缓冲区 调用sendto直接交给内核,立即返回
接收缓冲区 不保证接收顺序与发送顺序一致;满了则丢弃新数据

全双工: UDP 的 socket 既能读也能写,同时支持收发


UDP使用注意事项

16位长度字段的限制:

  • UDP首部中len字段是16位无符号整数
  • 最大长度 = 64K(65535字节),包含UDP首部
  • 有效数据最大 = 64K - 8 = 65527字节

现代互联网的挑战:

  • 64K在当今是非常小的数字(高清图片几MB,视频流更大)
  • 超过64K的解决方案: 应用层手动分包 → 多次发送 → 接收端手动拼装

注意: 实际中还需考虑MTU限制(以太网通常1500字节),过大的UDP包会在IP层分片,增加丢包风险。


2. TCP 协议

TCP协议全称"传输控制协议"。

应用层将报文序列化形成字节流,通过write/send拷贝到内核发送缓冲区后,数据何时发送、发送多少、出错如何处理,完全由TCP协议自主控制------这正是"传输控制"的核心含义。


1. TCP 报文的格式

TCP报头本质上是C语言结构体 struct tcphdr,网络字节序统一采用大端序,主机通过ntohs/ntohl进行转换。struct tcphdr 源码如下所示:

cpp 复制代码
struct tcphdr {
    __u16   source;
    __u16   dest;
    __u32   seq;
    __u32   ack_seq;

#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u16   res1:4,
            doff:4,
            fin:1,
            syn:1,
            rst:1,
            psh:1,
            ack:1,
            urg:1,
            ece:1,
            cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
    __u16   doff:4,
            res1:4,
            cwr:1,
            ece:1,
            urg:1,
            ack:1,
            psh:1,
            rst:1,
            syn:1,
            fin:1;
#else
#error   "Adjust your <asm/byteorder.h> defines"
#endif

    __u16   window;
    __u16   check;
    __u16   urg_ptr;
};

从源码可以看出,明显的区分了大小端问题。TCP报头就是上述的结构体。

  • __u16 source 是源端口号 ,__u16 dest 是目的端口号,都是16位;
  • __u32 seq 是序号 ,__u32 ack_seq 是确认序号,都是32位。
  • __u16 window ------ 16位窗口大小;__u16 check ------ 16位检验和;__u16 urg_ptr ------ 16位紧急指针。

关于TCP协议的相关问题

TCP如何解决报头和有效载荷分离问题?

TCP 报文段由 固定+可变长度的头部 和 应用层数据(有效载荷) 组成。由于 TCP 支持选项字段(如 MSS、SACK、时间戳等),其头部长度是可变的(20~60 字节)。为实现精确解析,TCP 在报头中定义了 4 位的"首部长度"(Data Offset)字段。

  • 该字段以 4 字节为单位 表示整个 TCP 头部的长度。
  • 最小值为 5(对应 5 × 4 = 20 字节,即无选项的标准头部);
  • 最大值为 15(对应 15 × 4 = 60 字节)。
  • 接收方读取该字段后,乘以 4 即可得到实际头部字节数。
  • 有效载荷起始位置 = TCP 段起始地址 + Data Offset × 4。

示例:若 Data Offset = 10,则头部长度为 40 字节,第 41 字节开始即为有效载荷。

因此,无需"有效载荷长度"字段------只要知道头部结束位置,剩余部分自然就是数据。

为何 TCP 报头中没有"有效载荷长度"字段?

这与 TCP 的面向字节流 设计密切相关:

UDP 是面向报文的:每个 UDP 数据报是独立、完整的消息,因此需要"总长度"字段来界定边界。

TCP 是面向字节流的:它不关心应用层如何划分消息(如 HTTP 请求、JSON 对象等),只负责将发送方写入的数据按序、无损地传递到接收方的缓冲区。应用层自行处理消息边界。

此外,一旦通过 Data Offset 硗定头部长度,剩余部分必然是有效载荷,再引入"payload length"会造成冗余且违背设计原则。

TCP 如何实现分用?

TCP 通过以下机制实现:

  • 报头中包含 16 位目的端口号(Destination Port)。
  • 内核维护一个 套接字(Socket)表,记录活跃连接或监听端口的信息。
  • 对于监听状态的 socket(如 listen(80)),内核根据目的端口号匹配;
  • 对于已建立连接的 socket,使用 四元组(源IP、源端口、目的IP、目的端口) 唯一标识连接,并据此路由数据。

示例:收到一个目的端口为 80 的 TCP 段 → 内核查找监听 80 端口的 Web 服务器进程 → 将数据放入其接收缓冲区 → 应用调用 recv() 获取。


2. 报头中的字段

1. 32位序列号(seq)

TCP被称为可靠协议,但我们必须正确认识"可靠性"的内涵。

主机A给主机B发送了一个报文,主机A无法100%确定主机B一定收到了。为此,TCP引入了确认应答(ACK)机制:主机B在收到报文后,必须向主机A发送一个应答报文。收到此应答,主机A便能100%确信其历史报文已被对方成功接收。

然而,这个过程似乎会陷入无穷递归:主机B如何确定自己的应答报文被主机A收到了呢?答案是:在互联网中,不存在100%可靠的协议 。最新的、尚未被确认的报文,其可靠性永远是未知的。但TCP的可靠性主要体现在对所有已确认的历史报文的100%送达保证上

超时重传机制是ACK机制的必要补充。主机A在发出报文后会启动一个计时器。如果在规定时间内(RTO, Retransmission Timeout)未收到ACK,主机A便会判定报文(或ACK)丢失,并执行重传。这个"规定时间"是动态计算的,基于网络的实际往返时延(RTT)。

重传会带来一个问题:主机B可能会收到重复的报文。此时,32位序列号(seq) 就派上了用场。接收方通过检查序列号,可以轻松识别并丢弃重复的数据,确保向上层交付的数据流是唯一的。

看下图:

**我们将这种两个方向上的互发数据,对报文进行应答,保证报文本身的可靠性叫做确认应答机制。**确认应答机制保证可靠性,体现在"你收到了,我得知道"。"如果你没有收到,我也得知道"。

主机A没有收到主机B的应答需要分情况考虑:

  1. 报文真的丢了;
  2. 主机B收到报文了,但是发送给主机A的应答报文丢了。

主机A没有收到应答,它会判定报文丢失了。但是A怎么知道自己会不会收到应答呢?收到应答是一个100%确定的事,没有收到应答怎么确定?现在没有收到应答,不代表未来不会收到应答呀?主机A会给自己设置一个deadline,过了这个deadline,就判定100%没有收到应答,判定报文丢失了。丢包是"规定"出来的。

为什么丢包只能规定出来?没有别的办法确定报文确实丢了呢? 对于发送方,它是无法判定数据是真的丢失了,还是应答丢失了,对它而言,它只知道它没有收到应答。

双方基于TCP通信,TCP是不能保证数据能够送到对方,这是网络决定的,它能确定对方是否收到数据。确认应答机制的核心,不是保证数据100%发送给对方,这是由网络决定的。对于发送方,对方是否收到报文,发送方有一个确定的结果。 如果没有收到应答,就重传报文。

尽管,主机A没有接收到应答的原因是应答报文丢失,主机A依旧需要重传。如此一来,主机B就会接收到两个一样的报文,主机B就需要对报文做去重。在TCP协议报头格式中,32位序号 就派上用场了,它的核心功能就是判断是否需要去重。如果序号相同,将之前收到的报文丢弃,保留新收到的报文。重传就是在没有收到应答之后的补发措施。

TCP常见的工作方式:

很多人以为TCP是这样工作的:主机A发一个报文给主机B,必须等到B的应答回来,才能发下一个;B也一样,收到A的报文后,才发应答。这看起来很"可靠",对吧?但这其实是停等协议 ,效率极低。所有发送和接收都是串行的,网络空闲时间太多。真实的TCP根本不用这种方式

那TCP真实怎么干?它让主机A一口气发一批报文 出去,比如4个、10个,甚至更多。然后A一边等应答,一边还能继续发------把等待时间重叠起来,并行化,效率就上去了

但问题来了:主机A发了4个报文,主机B收到的顺序,和A发送的顺序一样吗?不一定 。网络太复杂了,不同报文可能走不同路径,先发的反而后到。这就叫乱序

乱序是不是不可靠?是的,底层网络就是不可靠的。但TCP号称"可靠协议",那就必须解决乱序问题。

怎么解决?给每个字节打上序号 。TCP用32位序列号(seq) 标记每个字节在整个连接中的位置。接收方一看序号,就知道这个报文该放哪儿------重复的丢掉,乱序的先缓存,等前面的补齐了,再按序交给上层。这样,应用层看到的永远是整齐的字节流


2. 32位确认序号

主机B要对A的报文做应答。如果B一个个回 ACK,比如对 seq=1000 回 ack=1001,对 2000 回2001......万一中间某个 ACK 丢了,A怎么知道到底是数据丢了,还是 ACK 丢了?是不是得重传?

这里就体现出TCP设计的巧妙了。TCP的确认序号(ack)不是"我收到了这个报文",而是"这个序号之前的所有数据我都收到了"

比如A发了四段:seq=1000、2000、3000、4000(每段1000字节),B全收到了,就回一个ack=5000。就算中间ack=3001那个应答丢了,只要A最终收到了ack=5000,它就能100%确定:1000~4999的所有字节B都收到了 ,根本不用重传!一个确认号,就把前面所有数据都"打包"确认了。这大大减少了不必要的重传,也容忍了部分ACK丢失。

再深入一个问题:

主机A发报文给B,发的是什么?B应答A,应答的又是什么?答案是:双方互发的都是完整的TCP报文,也就是"报头 + 有效载荷"这个结构体,前面简述 ACK,只是为了方便语言描述,并不是直接发送 ACK 标志位就完事了。


那有人会问:A发数据时,只需要32位序号(seq)就行了吧?B回ACK时,只需要32位确认序号(ack)就够了呀?为什么报头里要同时存在seq和ack两个字段?一个不就够了吗?

关键在于:TCP是全双工的 !A可以给B发消息的同时,B也在给A发消息。这两个方向的数据流完全独立,各有各的字节序号。

举个例子:A发给B的数据,seq从1000开始;B发给A的数据,seq从10000开始。它们互不影响。

那既然B也要给A发数据,干嘛不把应答"捎带"在自己的数据里一起发?这正是TCP的做法!这种策略叫捎带应答

于是,B发给A的报文里,既包含自己的数据(所以要有seq=10000),又包含对A的确认(所以要有ack=5000)。一个报文,两件事一起办

所以我们看到的"应答",很可能既有确认,也有有效载荷。数据和确认可以同时传输,效率更高。


最后总结一下:

TCP报文里的32位序号(seq)和32位确认序号(ack),共同支撑了四大核心能力:

  • 去重:靠seq识别重复报文;
  • 按序到达:靠seq重组乱序数据;
  • 高效确认:靠ack的累积语义减少ACK数量;
  • 捎带应答:靠seq+ack共存,实现全双工高效通信。

所以,TCP的"可靠",不是靠死等、不是靠100%不丢包,而是靠聪明的编号、灵活的确认、并行的传输和双向的协同,在不可靠的网络上,硬生生造出一条可靠的字节流通道。


3. 16位窗口大小

主机A向主机B疯狂发报文,发得太快了,结果主机B的接收缓冲区满了,新来的报文直接被丢弃。

这时候你可能会说:"没关系啊!TCP有确认序号和超时重传机制,丢了就重传呗?"

听起来好像没问题,但仔细一想------这太浪费了 !假设一个报文千辛万苦穿越半个地球,终于到了主机B,结果B一看:"我缓存满了,不要了",啪一下丢掉。这个报文本身没出错,网络也没问题,纯粹是因为接收方来不及处理。可TCP还得把它重传一遍,白白消耗带宽、时间、计算资源。

那有没有办法提前告诉发送方:"我快装不下了,你慢点发" ?有!这就是流量控制(Flow Control) ------发送的速度,必须以接收方的接收能力为上限

流量控制不只是"别发太快",它其实是个双向调节机制

比如有时候,发送方发得太慢,接收方处理得飞快,缓冲区一直很空------这时候窗口很大,等于在说:"你快点发啊,我吃得下!"。发送方看到大窗口,自然就加速。

所以,流量控制既防"撑死"(接收方溢出),也防"饿着"(发送太慢),在可靠性和效率之间找到最佳平衡。


那问题来了:

问题1:接收方怎么知道自己还能收多少数据?

很简单------看自己的接收缓冲区里还剩多少空闲空间

比如缓冲区总共64KB,已经存了50KB的数据,那还能收14KB。这个"14KB"就是当前的接收能力。

问题2:发送方怎么知道接收方现在能收多少?

靠TCP报头里的一个字段:16位窗口大小(Window Size)

每次接收方发ACK应答时,都会把自己的当前剩余缓冲区大小填进这个字段,告诉发送方:"我现在最多还能收这么多字节"。

发送方一看这个值,就知道接下来该发多少、能不能发。如果窗口是0,那就先别发了,等对方通知有空间再说。

所以,TCP原则上要对每个报文(或批量)做应答,不只是为了确认收到,更是为了持续同步窗口大小------这样才能动态调整发送节奏,既不压垮接收方,也不浪费网络。

问题3:16位最大只能表示65535,那TCP窗口最大就64KB吗?

早期确实是这样,但在高速或高延迟网络中(比如跨洋传输),64KB窗口根本跑不满带宽。所以后来在TCP首部的选项字段(最多40字节)里加了一个"窗口扩大因子(Window Scale Factor)M"。

实际窗口大小 = 窗口字段的值 × 2^M(或者说"左移M位")。比如窗口字段是65535,M=3,那实际窗口就是 65535 × 8 ≈ 524KB。这样,TCP就能支持几MB甚至更大的窗口,适应现代网络需求。

注意:窗口扩大因子只在三次握手阶段 通过SYN报文协商,一旦连接建立就不能改了。

问题4:三次握手完成后,第一次发数据时,该发多大?

虽然是第一次发应用数据 ,但不是第一次交换TCP报文

因为在三次握手中:

  • A发SYN时,会带上自己的初始窗口大小;
  • B回SYN+ACK时,也带上了自己的窗口大小;
  • A再回ACK,连接就建立了。

所以,双方在发第一个数据包之前,就已经知道对方的初始接收能力了 。A第一次发数据,绝不会瞎发,而是严格遵守B在SYN+ACK里通告的窗口大小


4. 标志位

多个客户端同时向同一个服务端发报文,服务端收到的报文五花八门:有的是建连请求(SYN),有的是应答(ACK),有的是断连通知(FIN),有的是纯数据(DATA),还有的是"我出错了,重来!"(RST)......

那问题来了:报文自己怎么知道自己是哪一类? 答案就在 TCP 报头里------6 个标志位(Flags)URG, ACK, PSH, RST, SYN, FIN

它们本质就是比特开关:0 表示"没这事",1 表示"有这个功能"。靠这些位的组合,TCP 就能区分报文类型,决定下一步动作。


1. ACK 标志位

ACK 标志位:确认序号是否有效

只要报文里有 ack 字段(即确认序号),就必须把 ACK 位置 1;反过来,ACK=1 时,ack 字段才被解释为"期望接收的下一个字节序号"。

你可能会说:"那纯数据报文不带 ACK 吗?"几乎不存在 。因为 TCP 连接建立后, 几乎所有报文都带 ACK (哪怕只是重复确认)。

为什么?因为捎带应答太香了 :B 要给 A 发数据,顺手就把对 A 的 ACK 带上,省一个纯 ACK 报文。

所以,ACK 标志位 ≠ "这是个纯应答报文",而是"这个报文包含确认信息"。


2. SYN 标志位

SYN 标志位:请求建立连接

SYN=1 表示"我想和你建连",是三次握手的起点。流程:主机 A 发 SYN → 主机 B 回 SYN+ACK → 主机 A 再回 ACK,连接建立。

那为什么一般是 3 次握手?因为要最小成本验证全双工通畅 + 双方意愿一致

  • A 发 SYN(A 能发)→ B 收到(B 能收);
  • B 回 SYN+ACK(B 能发)→ A 收到(A 能收);
  • A 回 ACK(A 能发)→ B 收到(B 能收)。

三次握手就像结婚:双方同意 + 双方家长(网络)点头,缺一不可。4 次?没必要;2 次?无法验证 B 是否真能发

三次握手中,最后那个 ACK 是客户端主动发出的 。客户端只要把 ACK 发出去,就认为连接建好了(即使服务端没收到)。如果这个 ACK 丢了,客户端开始发数据,服务端一看:"谁啊?没建连就发数据?"------立刻回一个 RST,告诉客户端:"你连都没建好,别闹!"


3. FIN 标志位

FIN 标志位:我要关写端了

FIN=1 表示"我不会再发送新数据了",不是"我要彻底断开连接"!关键理解:TCP 是全双工的,读和写可以独立关闭

举个例子:

客户端发 FIN → 服务端回 ACK → 此时客户端进入 FIN_WAIT_2,它确实不再向服务端写数据了 ,但仍可读服务端的数据(比如服务端还在回传响应)。直到服务端也发 FIN,客户端回 ACK,才进入 TIME_WAIT。

所以:

  • FIN 不代表"连接没了",只代表"我这端不写了";
  • ACK 依然可以发------因为 ACK 是报头的一部分,不占 payload,发 ACK 不违反"不写数据"的承诺。

你可能会问:"那客户端 fd 都 close() 了,怎么还能发 ACK?"

答:close(sockfd) 在应用层调用后, OS 会:

  1. 立即停止应用层写入(fd 的写端关闭);
  2. 但 TCP 层仍需完成挥手流程(发 FIN、等 ACK、发 LAST_ACK);

挥手是内核干的,和应用层是否 close 无关。所以,close() 后,连接还没真正释放,要等 TIME_WAIT 结束。


4. RST 标志位

RST 标志位:连接复位,强制中断

RST=1 表示:"这连接有问题,立刻终止,别玩了。"

常见场景:

  1. 连接未建立成功时:如客户端发完第三次 ACK 后直接发数据, 服务端发现"这连接根本没建好",回 RST;
  2. 收到非法报文:比如 seq 严重错乱、窗口为负、或对方已关闭连接却还发数据;
  3. 应用层主动 reset:比如用 shutdown(sockfd, SHUT_RDWR) 或 setsockopt(..., SO_LINGER) 强制关闭。

RST 的特点是:不等待、不重传、立即终止。它是 TCP 的"紧急刹车",比 FIN 更暴力,但更可靠------避免无效连接占用资源。


5. PSH 标志位

PSH 标志位:催促对方"快点交数据!"

PSH=1 的意思是:"请接收方 TCP 层立刻把缓冲区里的数据交给应用层,别攒着!"

很多人以为 PSH 是让对方"马上发数据",其实不是------PSH 是催接收方的应用层赶紧读数据

极端例子:

服务端接收缓冲区满了(window=0),客户端停发数据,但服务端应用层迟迟不 read(),缓冲区一直满着......

这时客户端可以发一个 纯 ACK + PSH=1 的报文(无 payload),服务端 TCP 层一看 PSH=1,就立刻通知应用层:"有数据!快 read!"

虽然不能强迫应用层读,但至少给了它一个" urgent 提醒"。

现实中,PSH 很少被显式设置。需要注意:PSH 不保证"立即交付",它只是个提示。最终读不读,还是看应用层调度。


6. URG 标志位

URG=1 表示"这个报文里有紧急数据",配合 16 位紧急指针(Urgent Pointer) 使用。

紧急指针不是指向"紧急数据本身",而是指向紧急数据之后的那个字节的序号

比如发送字节流:[1000]A [1001]B [1002]C [1003]D [1004]E,你想标 D 为紧急数据,那么紧急指针 = 1004(即 D 的下一个字节序号)。

接收方 TCP 层看到 URG=1,会:

  1. 通知应用层:"有紧急数据!";
  2. 允许通过 recv(sockfd, buf, len, MSG_OOB) 读取带外数据(OOB);
  3. 正常数据仍按序放入缓冲区,紧急数据插队优先交付。

但真相是:URG 几乎没人用

为什么?因为设计混乱:

  • 不同系统对"紧急数据范围"解释不同(有的指 1 字节,有的指到指针前所有);
  • 应用层很难可靠处理 OOB 数据;
  • 现代协议(用应用层帧控制代替了紧急机制。
    所以 URG 是 TCP 的"历史遗迹"------保留是为了兼容老系统,新开发基本忽略它。

3. 连接

什么是连接?多个客户端可以向同一个服务端建立连接,因此存在多个连接,有的连接是正在建立中,有的连接是正在通信中,有的连接是将要断开......。

既然如此,OS就需要将它们管理起来,怎么管理?"先描述,再组织"。

主机双方建立连接成功后,都会在OS内构建连接结构体 ------ struct tcp_sock。struct tcp_sock 结构体中存在一个结构体变量struct inet_connection_sock inet_conn,struct inet_connection_sock 结构体的源码为:

cpp 复制代码
struct inet_connection_sock {
    /* inet_sock has to be the first member! */
    struct inet_sock          icsk_inet;
    struct request_sock_queue icsk_accept_queue;
    struct inet_bind_bucket  *icsk_bind_hash;
    unsigned long             icsk_timeout;
    struct timer_list         icsk_retransmit_timer;
    struct timer_list         icsk_delack_timer;
    __u32                     icsk_rto;
    __u32                     icsk_pmtu_cookie;
    const struct tcp_congestion_ops *icsk_ca_ops;
    const struct inet_connection_sock_af_ops *icsk_af_ops;
    unsigned int              (*icsk_sync_mss)(struct sock *sk, u32 pmtu);
    __u8                      icsk_ca_state;
    __u8                      icsk_retransmits;
    __u8                      icsk_pending;
    __u8                      icsk_backoff;
    __u8                      icsk_syn_retries;
    __u8                      icsk_probes_out;
    __u16                     icsk_ext_hdr_len;
    struct {
        __u8                  pending;    /* ACK is pending               */
        __u8                  quick;      /* Scheduled number of quick acks       */
        __u8                  pingpong;   /* The session is interactive           */
        __u8                  blocked;    /* Delayed ACK was blocked by socket lock */
        __u32                 ato;        /* Predicted tick of soft clock         */
        unsigned long         timeout;    /* Currently scheduled timeout           */
        __u32                 lrcvtime;   /* timestamp of last received data packet */
        __u16                 last_seg_size; /* Size of last incoming segment       */
        __u16                 rcv_mss;    /* MSS used for delayed ACK decisions    */
    } icsk_ack;
    struct {
        int                   enabled;

        /* Range of MTUs to search */
        int                   search_high;
        int                   search_low;

        /* Information on the current probe. */
        int                   probe_size;
    } icsk_mtup;
    u32                       icsk_ca_priv[16];
#define ICSK_CA_PRIV_SIZE      (16 * sizeof(u32))
};

inet_connection_sock 结构体中存在一个结构体变量:struct inet_sock icsk_inet。inet_sock 结构体中存在一个结构体变量:struct sock sk。

这里的层级就和UDP处的一样了,如下图所示:

总结:task_struct → files_struct → fd_array[] → struct file → private_data → struct socket → struct sock *sk。而 sk 实际指向的是 struct inet_connection_sock → struct tcp_sock 的继承链。

也就是说:

  • 每个 TCP 连接,在内核里就是一个 struct tcp_sock 对象;
  • 它维护着:seq/ack、窗口、重传定时器、状态(如 ESTABLISHED)、接收/发送缓冲区......
  • 建立连接 = 创建这个结构体;断开连接 = 释放它。

所以,listen() 和 accept() 干了什么?

  • listen():创建一个监听套接字,初始化 tcp_sock,并准备两个队列:
    • 半连接队列(SYN_RCVD 状态的连接,未完成三次握手);
    • 全连接队列(ESTABLISHED 状态,等待 accept() 取走);
  • accept():从全连接队列取出一个已建好的连接,复制一份 tcp_sock 给新 socket,返回新的 fd。

注意:accept() 不参与三次握手!握手是内核自动完成的。


4. 三次握手和四次挥手

三次握手,四次挥手流程图如下所示:

服务端典型路径:

CLOSED

→ listen() → LISTEN

→ 收到 SYN → SYN_RCVD(放入半连接队列)

→ 收到 ACK → ESTABLISHED(移入全连接队列)

→ accept() 取走 → 应用层读写

→ 客户端发 FIN → CLOSE_WAIT(服务端仍可写!)

→ 服务端调用 close() → 发 FIN → LAST_ACK

→ 收到 ACK → CLOSED

客户端典型路径:

CLOSED

→ connect() → SYN_SENT

→ 收到 SYN+ACK → ESTABLISHED

→ 主动 close() → FIN_WAIT_1

→ 收到 ACK → FIN_WAIT_2(可读,不可写)

→ 收到服务端 FIN → TIME_WAIT

→ 等 2MSL → CLOSED

重点纠正一个误区:
FIN_WAIT_2 不代表"连接已断",它只是"我发完 FIN,等对方 FIN" 。如果服务端卡在 CLOSE_WAIT(即收到 FIN 后,迟迟不 close()),就会导致量 CLOSE_WAIT 积压------这通常是服务端代码 Bug:忘了 close() 对应的 fd!

为什么要有 TIME_WAIT?

等 2MSL(Maximum Segment Lifetime),确保:

  1. 网络中残留的旧报文(比如延迟的 ACK)能自然消亡;
  2. 如果最后一个 ACK 丢了,服务端重传 FIN,客户端还能响应;
  3. 防止新连接误收旧连接的残余报文(IP+port 相同,seq 可能撞车)。

所以:TIME_WAIT 是主动关闭方的"善后期",不是"没关",而是"正在安全收尾"。


三次握手:不是"建连接",而是"确认双方能收能发"。

主机A想和主机B通信,为什么不能发一个SYN就完事?为什么非要3次? 你可能会说:"因为要可靠嘛。"但可靠不是靠次数多,是靠验证能力

TCP 要建立的是全双工信道------A 能发、B 能收;同时 B 能发、A 能收。这四个动作,必须都被确认过。

我们模拟一下:

第一次:A → B:SYN(seq=x)

  • A 知道自己能发(报文已发出)

  • B 收到后,知道 A 能发、自己能收

  • 此时只验证了单向通路(A→B)

第二次:B → A:SYN+ACK(seq=y, ack=x+1)

  • B 知道自己能发(仅假设,尚未确认)、知道自己能收、知道 A 能发

  • A 收到后,知道:自己能收、自己能发、B 能发、B 能收

  • 此时双向通路已验证(A 已确认全双工),但 B 尚未确认自己能发(需等第三次握手)

第三次:A → B:ACK(ack=y+1)

  • A 知道自己能发(发出 ACK)

  • B 收到后,确认:A 能收、自己能发(闭环完成)

  • 至此,双方都确认:A 能发/能收,B 能发/能收 ------ 全双工确认完成

所以,三次握手的本质是:用最少的报文,让双方都亲自"发出 + 接收"各一次,从而共同确认:我们之间,双向都通!

常见误解纠正:

  • "服务端无条件答应" → 实际上,B 在收到 SYN 后会检查资源(比如全连接队列是否满),满了就丢弃 SYN,A 就卡在 SYN_SENT;
  • "第三次 ACK 可有可无" → 如果丢了,A 以为连好了就开始发数据, B 却认为"连接没建好",直接回 RST,连接失败;所以:第三次 ACK 是必要的"最终确认",不是形式主义。

类比理解:两个人约见面。

A 发消息:"我在东门等你"(SYN)

B 回:"我出发了,5分钟后到东门"(SYN+ACK)

A 再回:"收到,我也快到了"(ACK)

只有这时, 双方才敢放下手机、走向东门.

少一句,可能一人到了,另一人还在犹豫:"他到底来不来?"


四次挥手:不是"断连接",而是"分阶段关读写"

既然建连要3次,为什么断连要4次?不能3次搞定吗? 答案是:可以3次,但前提是"双方同时想断" ------这叫"同时关闭",现实中极少发生。绝大多数情况是:一方先说"我不写了",另一方过会儿再说"我也不写了"

我们看标准流程(客户端主动关闭):

第一次:A → B:FIN(seq=u)

  • A 说:"我数据发完了,不再向你写数据了。"(注意:不是"断连",是关写端)
  • A 进入 FIN_WAIT_1
  • B 收到后,知道"A 不写了",但 B 可能还有数据要发给 A(比如响应未完成),所以 B 不能立刻关连。

第二次:B → A:ACK(ack=u+1)

  • B 说:"收到,你不用写了。"
  • B 进入 CLOSE_WAIT(此时 B 仍可向 A 发数据!)
  • A 收到后,进入 FIN_WAIT_2 ------ 它确实不写了,但还能读 B 的数据
  • 关键点:ACK 不是断连信号,只是对 FIN 的确认

第三次:B → A:FIN(seq=v)

  • B 说:"我数据也发完了,我也不写了。"
  • B 进入 LAST_ACK
  • A 收到后,知道"B 也不写了",于是准备最后一步。

第四次:A → B:ACK(ack=v+1)

  • A 说:"收到,连接可以彻底关了。"
  • A 进入 TIME_WAIT,等 2MSL;
  • B 收到后,立即进入 CLOSED。

所以,4 次挥手 = 2 次"关写端" + 2 次"确认" ,本质是全双工的对称关闭

3次挥手可能吗? 可能!当双方几乎同时发 FIN 时:

  • A 发 FIN → B 正好也要发 FIN
  • B 把自己的 FIN 和对 A 的 ACK 捎带在一起:FIN+ACK
  • A 收到后,回一个 ACK
  • 完成:FIN → FIN+ACK → ACK,共3次。

这叫 "同时关闭"(Simultaneous Close),在 TCP 状态图里对应:

A: ESTABLISHED → CLOSED (经 FIN_WAIT_1 → CLOSING → TIME_WAIT)

B: ESTABLISHED → CLOSED (经 CLOSE_WAIT → LAST_ACK → CLOSED)

但注意:这不是"简化版挥手",而是特殊场景下的自然合并,就像两个人同时说"拜拜",一人顺手把对方的"再见"也回了。

对比握手与挥手:

  • 握手必须3次:因为建连是单向发起、双向确认,无法合并;
  • 挥手通常4次:因为关连是双向独立决策,多数情况不同步;
  • 但两者底层逻辑一致:每一次交互,都要求"发出 + 接收"闭环。

为什么挥手后,主动方要等 TIME_WAIT?

很多人觉得:"我都发完 ACK 了,怎么还不 CLOSE?"其实,TIME_WAIT 不是"没关",而是"正在安全收尾"

它解决两个关键问题:

  1. 防止旧报文干扰新连接
    假设 A 和 B 的连接刚关,A 立刻用相同 IP:port 建新连接。
    如果网络中还飘着上次连接的延迟 ACK(比如 seq=1000 的 ACK),新连接误收它,就会乱序甚至崩溃。
    等 2MSL(一般 60s),确保所有旧报文自然死亡。
  2. 保证最后的 ACK 到达
    如果 A 发的最后一个 ACK 丢了, B 会重传 FIN;
    A 处于 TIME_WAIT 就能响应这个重传 FIN,避免 B 卡在 LAST_ACK 永久等待.

所以:TIME_WAIT 是主动关闭方的"责任期"------它关得最晚,但关得最稳。


从握手到挥手,TCP 的"生命周期"

你可以把一个 TCP 连接想象成一个人的一生:

阶段 对应动作 内核干了什么
出生 socket() 分配 struct sock,初始化为 CLOSED
报名 bind() 绑定 IP:port,准备监听
等待面试 listen() 创建半/全连接队列,进入 LISTEN
初试 A 发 SYN A 进入 SYN_SENT;B 收到,放入半队列,进入 SYN_RCVD
复试 B 回 SYN+ACK B 进入 SYN_RCVD → ESTABLISHED(待 accept)
录用 A 回 ACK A 进入 ESTABLISHED;连接真正可用
工作期 read/write 循环 双方持续交换 DATA + ACK + 窗口更新
辞职申请 A 发 FIN A 进入 FIN_WAIT_1;B 收到,进入 CLOSE_WAIT
批准离职 B 回 ACK A 进入 FIN_WAIT_2;B 仍可发数据
交接完毕 B 发 FIN B 进入 LAST_ACK;A 收到,进入 TIME_WAIT
档案封存 A 回 ACK + 等 2MSL A 释放资源;连接彻底结束

5. TCP的可靠性和效率

问题:网络本来就不可靠------报文会丢、会乱序、会重复。TCP 却号称"可靠传输",它靠什么做到的?

依靠三套协同机制:

  1. 确认应答(ACK) ------ 你知道我收到了吗?
  2. 超时重传 + 快重传 ------ 你没回?我等一会儿再发;你连回三次"我要1001",那我立刻重发!
  3. 滑动窗口 ------ 不是等一个ACK再发下一个, 而是一口气发一串,边发边等,把等待时间"压扁"。

这三者不是独立的,而是拧成一股绳

  • ACK 告诉你"收到哪了";
  • 滑动窗口根据 ACK 动态决定"还能发多少";
  • 超时/快重传在 ACK 长期不来时兜底。

下面我们就按这个逻辑,一条一条捋清楚。


1. 确认应答(ACK)

尤其需要注意:ACK 字段的值,不是"我收到了这个报文",而是"我收到了这个序号之前的所有字节"

为什么这么设计?因为 TCP 是面向字节流 的,不是面向报文的。

想象发送缓冲区是一个大数组 char outbuf[∞],每个字节有唯一序号(从随机初始值开始)。

  • A 发 1~1000 → seq=1
  • B 收到 → 回 ack=1001
  • A 发 1001~2000 → seq=1001
  • B 收到 → 回 ack=2001

ack=2001 的含义是:1~2000 全收到了,下个要 2001

这样,哪怕中间某个 ACK 丢了(比如 ack=1001 没收到),只要最终收到ack=2001,A 就知道:1~2000 全 OK,不用重传!

所以 ACK 的本质是累积确认------它压缩了确认信息,极大减少 ACK 数量,也天然支持乱序重组。


2. 超时重传机制

等待特定的时间,特定的时间间隔不能太短,会导致多次重传;也不能太长,会拖慢效率。它应该随网络状态浮动。

那这个"超时时间"到底谁定?怎么定?在 Linux 及 Windows 中, TCP 用 RTO (Retransmission Timeout) 控制重传时机,其基础单位是 500ms。但 RTO 不是固定 500ms,而是动态计算的:

  • 初始 RTO ≈ 1s(保守起见);
  • 每次成功收到 ACK,就用新测得的 RTT(往返时间)更新平滑 RTT(SRTT);
  • RTO = SRTT + 4×RTTVAR(RTT 方差),公式保证它既灵敏又稳定。

重传策略

如果第一次超时没收到 ACK:

  • 第1次重传:等 RTO
  • 第2次重传:等 2×RTO
  • 第3次重传:等 4×RTO
  • ...... 依次翻倍

为什么指数增长?避免在网络拥塞时雪崩式重传(大家都疯狂重发,更堵了)。

一般默认最多重传 15 (Linux 可调),累计超时总时间约 9 分钟。超过后,TCP 认为:

  • 对端挂了?
  • 网络断了?
  • 路由器黑洞了?

直接发 RST,强制关闭连接。关键结论:超时重传是"兜底机制"------平时靠 ACK 和滑动窗口高效运转;一旦 ACK 长期不来,它才出手救场。


3. 滑动窗口

问题:发出去的报文,在收到 ACK 前,OS 会丢掉吗? 绝对不会!

因为:

  • 如果丢了,后续发现数据没到,却找不到原始报文重传;
  • 更糟的是,如果 ACK 到了,但原始报文已被删,你连"确认了哪段"都对不上。

所以:所有已发送、未确认的报文,必须保留在发送缓冲区里 ------而这块区域,就是滑动窗口的覆盖范围

滑动窗口示意图:

滑动窗口到底在哪?怎么表示?

把发送缓冲区想象成一个无限长的数组。

滑动窗口 = 一个左闭右开区间 [send_base, send_base + window_size)

  • send_base:最小未确认序号(即下一个该 ACK 的值)
  • window_size:当前允许发送的字节数(来自对端通告的窗口大小)

而窗口右边界:send_next = send_base + window_size ------ 这就是你还能发到哪里。

所以发送缓冲区被分为三段:

区域 范围 状态
已确认 < send_base 已安全交付,可删除
正在发送(窗口内) [send_base, send_next) 已发未确认,必须缓存
待发送 >= send_next 还没轮到,等窗口滑动

窗口会变大、变小、不变?会左移吗?

会变大:当接收方上层读得快,缓冲区空了

  • B 的接收窗口从 3000 → 6000
  • 下次 ACK 中 window=6000
  • A 更新:send_next = ack + 6000 → 窗口右移,可发更多

会变小:当接收方上层卡住, 缓冲区满了

  • B 的接收窗口从 4000 → 1000
  • 下次 ACK 中 window=1000
  • A 更新:send_next = ack + 1000 → 窗口右边界左移(注意:是右边界动,不是整个窗口左移!)

绝对不会整体左移!

  • 左边界 send_base = ack,而 ack 是单调不减的(序号只增不减),所以左边界只能不动或右移;
  • 右边界 send_next = send_base + window,若 window 缩小,右边界可能左移------但这只是"窗口收缩",不是"窗口倒着滑";
  • 整个窗口永远不会向左滑动,否则就违反了"已确认数据不可逆"的可靠性原则。

举个例子:

初始:send_base=1001, window=4000 → 可发 1001~5000

收到 ack=2001, window=3000 → send_base=2001, send_next=5001

窗口从 [1001,5001) → [2001,5001),左移1000,右不动

再收到 ack=3001, window=2000 → [3001,5001),左再移1000
左边界前进,右边界可能因 window 缩小而滞后,但绝不会退回到 2000 之前!

来看下图:

主机A发 1~1000, 1001~2000, 2001~3000, 3001~4000......结果 1001~2000 丢了,B 一直回 "下一个该收1001"。

我们可以敏锐地指出:收到3个相同的ACK(都是1001),就触发快重传(Fast Retransmit)。

为什么是"3个相同ACK"?

  • 第1个 ACK=1001:可能是延迟,正常;
  • 第2个 ACK=1001:有点怪,但还忍;
  • 第3个 ACK=1001:几乎可以确定 1001~2000 丢了(因为 B 已收到后续数据,却还在要 1001);

A 立刻重传 1001~2000,不等超时!快重传的本质:用重复 ACK 当"丢包信号",比等超时快得多(通常节省 1~2 个 RTT)。

如果报文丢的是中间段呢?比如 3001~4000 丢了?

流程

  • 收到 1~1000 → ack=1001
  • 收到 1001~2000 → ack=2001
  • 收到 3001~4000(但 2001~3000 丢了)→ 仍回 ack=2001!
    因为 2001 是第一个缺失的序号,TCP 要求"确认序号 = 最小未收到序号"。

所以,无论丢哪一段,只要它是最左边未收到的,后续所有 ACK 都会卡在同一个值 ------这就保证了:任何丢包,最终都会表现为"多个重复 ACK",从而触发快重传。只有极端情况(如最后一批数据丢了,且连接马上关闭)才依赖超时重传。

滑动窗口会越界吗 ?不会,将滑动窗口理解成环形数组


4. 拥塞控制

既然客户端得到了服务端的接收能力,假设为4000字节,那么客户端发送的时候,为什么不直接发送4000字节,反而发送了多个报文?

因为:接收能力 ≠ 网络容量

  • 接收窗口(rwnd)告诉你是"对方能存多少";
  • 但网络中间可能有路由器、交换机、链路带宽瓶颈------它们才是真正的"堵点"。
  • 如果你不管三七二十一,按接收窗口满打满发,很可能把网络塞爆,导致大量丢包,反而更慢。

所以 TCP 必须引入一个新变量:拥塞窗口(cwnd) ,它代表的是------当前网络能承受多少数据

发送方实际能发的数据量,取两者最小值:发送窗口= min ( rwnd*,* cwnd*)***。这才是最终限制你发多少的"闸门"。

拥塞窗口怎么来的?

1. 慢启动:从 1 开始,指数试探

TCP 连接刚建立时,对网络一无所知。

它不会直接冲上去发 4000 字节,而是:

  • 设 cwnd = 1(单位是 MSS,即最大段大小,比如 1460 字节);
  • 每收到 1 个 ACK,cwnd += 1;
  • 也就是说:第 1 轮发 1 段 → 收到 1 个 ACK → cwnd=2 → 第 2 轮发 2 段;
  • 收到 2 个 ACK → cwnd=4 → 第 3 轮发 4 段;
  • ...... 指数增长:1 → 2 → 4 → 8 → 16 → ...

为什么是指数?因为早期网络空闲时,资源充足,快速探测上限是合理的;而如果网络真拥塞了,一开始只发 1 段,就算丢了,损失也最小------这是"安全第一"的试探策略。

2. 慢启动阈值:从"狂奔"到"稳跑"的切换点

指数增长不能永远持续------万一撞上拥塞,会雪崩式丢包。

所以 TCP 引入一个临界值:慢启动阈值 ssthresh

  • 初始时,ssthresh 通常设为一个较大值(如 65535 字节),或等于接收窗口;
  • 当 cwnd < ssthresh:走慢启动,每轮 ACK 增 1(指数增长);
  • 当 cwnd ≥ ssthresh:进入拥塞避免阶段,改为每轮增加 1/cwnd(近似线性增长);
    举例:cwnd=16,收到 16 个 ACK 后,cwnd += 1 → 变成 17;再收 17 个 ACK,cwnd += 1 → 18......
    所以增长变慢了,但仍在稳步上升。

这个设计很精妙:

  • 前期快探底(慢启动),快速利用空闲带宽;
  • 后期稳推进(拥塞避免),避免突然压垮网络。

拥塞发生了怎么办?------两种响应机制

网络拥塞的典型信号是:大量丢包(注意:不是个别丢包!)。

  • 丢 1~2 个包?正常,靠超时重传或快重传解决;
  • 丢 3% 以上?大概率拥塞;5%?严重拥塞。

一旦检测到拥塞,TCP 必须立刻刹车,否则全网一起瘫痪。

情况一:超时重传触发(严重拥塞)

  • 当 RTO 超时,说明不仅丢包,而且 ACK 长期没回来 → 网络可能已断或极度拥堵;
  • 此时 TCP 做两件事:
    1. ssthresh = cwnd / 2(阈值砍半,记住这次教训);
    2. cwnd = 1(回到慢启动起点,重新试探);
  • 下一轮又从 1 开始指数增长------就像吵架后冷战,重新追人一样。

情况二:快重传触发(轻度拥塞)

  • 收到 3 个重复 ACK(如连续三次 ACK=1001),说明有单段丢失,但网络尚通;
  • 此时 TCP 认为:"可能只是局部丢包,还没全局拥塞",于是:
    1. ssthresh = cwnd / 2(阈值减半);
    2. cwnd = ssthresh + 3(不是归 1!留一点余量,继续发);
    3. 进入快速恢复阶段:每收到 1 个重复 ACK,cwnd += 1;直到收到新 ACK,才切回拥塞避免。

为什么快重传后不归零?因为网络还没崩溃,只是有点堵,没必要从头爬坡。这就像两个人吵架,一方认错+递杯水,关系就不用彻底重启。

具体过程如下图所示:

拥塞窗口一定是变化的,因为网络状态时刻在变。TCP 根本不可能提前知道"现在能发多少",它只能不断试探。

所以整个拥塞控制过程,本质上是一场动态探索实验

  • 慢启动:大胆试探上限;
  • 拥塞避免:谨慎逼近瓶颈;
  • 丢包发生:立即收缩,重新学习;
  • 网络恢复:缓慢回升,再次试探。

图中那条曲线------先指数上升,到 ssthresh 后线性增长,遇到拥塞骤降,再重启慢启动------

这不是人为规定,而是 TCP 在安全、可靠、效率三者间反复权衡的结果:

  • 太保守?吞吐太低;
  • 太激进?全网拥塞;
  • 刚刚好?就是现在这套算法。

拥塞控制与前面机制的关系

机制 负责对象 关键变量 作用
接收窗口(rwnd) 接收端缓冲区 对端通告的 window 字段 流量控制:别把对方撑爆
拥塞窗口(cwnd) 网络链路容量 发送端维护的 cwnd 拥塞控制:别把网络堵死
实际发送窗口 min(rwnd, cwnd) --- 最终发多少,由两者共同决定
  • 没有 rwnd → 会压垮接收方;
  • 没有 cwnd → 会压垮网络;
  • 两者都有 → TCP 才能在复杂网络中既快又稳地传数据

拥塞控制不是"不让发",而是"聪明地发":前期像新手司机,慢慢踩油门;中期像老司机,匀速跟车;一见前车急刹(丢包),立刻松油+点刹;等路况好转,再缓缓提速。


5. 延迟应答

你发一个报文给主机 B,B 收到后不立刻回 ACK,而是等一会儿------比如 200ms 内,如果又有新数据到来,或者上层应用把缓冲区里的数据读走了,那它就可以在同一个 ACK 里,告诉对方:"我现在能收更多了!"

举个例子:

  • 主机 B 的接收缓冲区是 1M;
  • 主机 A 一次发了 500K 数据过来;
  • 如果 B 立刻回 ACK,通告窗口就是 1M - 500K = 500K;
  • 但如果 B 等 10ms,而在这 10ms 内,应用层把这 500K 全读走了,缓冲区又空了;
  • 那它回的 ACK 就可以带 window=1M ------ 窗口翻倍!

而我们知道:窗口越大,吞吐越高

所以延迟应答的本质,就是用一点点等待时间,换一个更大的接收窗口,从而提升整体效率。

但这不是无限制等。TCP 规定:

  • 延迟时间一般不超过 200ms(Linux 默认是 40ms 或 200ms,取决于配置);
  • 或者,最多延迟两个报文------收到第一个不回,收到第二个必须回;
  • 另外,如果接收方有数据要发给对方,也可以直接把 ACK 捎带上(这就是捎带应答,后面讲)。

所以,并不是所有 ACK 都能延迟:

  • 如果对端正在等这个 ACK 才能继续发(比如窗口卡住了),就不能拖;
  • 如果超时重传的 RTO 很短(比如 10ms),你等 200ms 再回,人家早就重传了,反而浪费带宽;
  • 因此,延迟应答是有条件的:只在安全、不影响可靠性的情况下启用。

6. 捎带应答

TCP 是全双工的,双方都能同时发数据。当 A 给 B 发数据时,B 要回 ACK;但如果 B 正好也有数据要发给 A ,那它就可以把 ACK 字段塞进自己的数据包里,一起发过去。这就叫捎带应答

好处很明显:省下一个纯 ACK 包,减少网络开销,提高链路利用率。

典型场景就是交互式通信:

  • 你 telnet 登录服务器,敲一个命令;
  • 服务器回结果的同时,顺便确认你刚发的命令字节;
  • 不需要单独发一个"我收到了"的空包。

再比如三次握手:

  • 第一次 SYN:不能带数据(连接未建);
  • 第二次 SYN+ACK:也不能带数据(虽然有些实现允许,但标准不推荐);
  • 第三次 ACK:可以带数据!
    因为客户端发完这个 ACK,连接就算建立了。既然连接已通,干嘛不让它顺手把第一个请求(比如 HTTP GET)一起发出去?这就是典型的捎带应答,也是为什么很多协议能在 1-RTT 内完成首请求。

3. TCP总结

TCP 为什么这么复杂? 因为它的目标太"贪心"了:既要绝对可靠,又要尽可能高效

为了可靠,它堆了一整套机制:

  • 校验和:防比特错误;
  • 序列号:保证按序到达 + 自动去重;
  • 确认应答(ACK):核心反馈机制;
  • 超时重传 + 快重传:丢包兜底;
  • 连接管理(三次握手/四次挥手):确保两端状态同步;
  • 流量控制(接收窗口):别把对方缓冲区撑爆;
  • 拥塞控制(拥塞窗口):别把网络链路堵死。

为了高效,它又加了一堆优化:

  • 滑动窗口:并发发送,压扁等待时间;
  • 快重传:3 个重复 ACK 就重发,不等超时;
  • 延迟应答:等一等,可能通告更大窗口;
  • 捎带应答:有数据要发?顺路把 ACK 带上!

这些机制环环相扣:

  • 没有 ACK,滑动窗口不知道往哪滑;
  • 没有拥塞控制,滑动窗口会把网络干崩;
  • 没有延迟应答,窗口可能长期偏小,吞吐上不去;
  • 没有捎带应答,交互式通信会多出一堆空包。

4. 其它

1. 面向字节流

创建一个 TCP socket,内核会自动给它配两个缓冲区:

  • 发送缓冲区:你调 write() 写的数据,先扔进去,由 TCP 协议栈慢慢发;
  • 接收缓冲区:对端发来的数据,先存这里,等你 read() 来取。

正因为有这两个缓冲区,读写完全解耦

  • 你可以一次 write(100),也可以 100 次 write(1) ------ 对 TCP 来说,都是一串连续的字节;
  • 对方可以一次 read(100),也可以 100 次 read(1) ------ 它看到的只是缓冲区里的一堆字节,根本不知道你是怎么写的。

这就是"面向字节流 "的本质:没有消息边界

对比 UDP 的"面向数据报":你发一个包,对方收一个包,包和包之间天然隔离。而 TCP 不是这样。它把所有数据熔成一锅粥,按序号排好,倒进接收缓冲区。应用层从里面舀汤喝,但没人告诉你"这一勺是一个请求,下一勺是另一个响应"。

面向字节流:缓冲区是核心。


2. 粘包问题

粘包"这个词容易误导人------好像 TCP 把包"粘"错了。其实 TCP 根本没"包"的概念!所谓的"包",是应用层自定义的消息单元TCP 只负责按序交付字节,至于你怎么切分这些字节,它不管

为什么会"粘"?

  • 应用层发了两条消息:"Hello" 和 "World";
  • TCP 可能合并成一个报文段发出去(Nagle 算法);
  • 或者分成多个报文段,但接收方一次 read() 全读回来了;
  • 结果应用层拿到 "HelloWorld",傻眼了:这是 1 个消息?还是 2 个?

更糟的是"拆包":一个长消息被切成两段,第一次 read() 只拿到一半,程序误以为是完整消息,解析出错。

所以粘包/拆包的本质是:应用层无法从字节流中还原出原始消息边界

怎么解决?三种主流方案:

  1. 定长消息
    每条消息固定 N 字节。读的时候每次读 N 字节就行。
    简单粗暴,但浪费空间(短消息也要补全),一般只用于内部高性能通信。
  2. 带长度头的变长消息
    在每条消息前加 4 字节(或 2 字节)表示"后面跟多少字节"。
    例如:[len=5][Hello][len=5][World]
    接收方先读 4 字节得到 len,再读 len 字节,就精准切出一条消息。
    这是最常用、最可靠的方式(HTTP/2、Protobuf、Redis 协议都这么干)。
  3. 特殊分隔符
    用 \n、\r\n 或其他不会出现在正文中的字符做分隔。
    例如 HTTP/1.1 的 header 就用 \r\n\r\n 分隔头部和 body。
    缺点:正文不能包含分隔符,否则要转义,增加复杂度。

记住:粘包问题永远在应用层解决,TCP 层无能为力


3. TCP 异常情况

TCP 连接是两个进程之间的状态。一旦一方出问题,连接就可能"假死"或"半开",连接不是永生的

场景 1:客户端进程退出(正常 or 异常)

  • 进程退出 → OS 关闭其所有文件描述符 → socket 被 close;
  • 内核自动发送 FIN → 进入四次挥手流程;
  • 服务端收到 FIN 后,也会走完挥手,连接正常释放。

这是最干净的情况,TCP 自己能处理。

场景 2:客户端主机重启

  • 重启前,OS 会先终止所有进程 → 同场景 1,正常挥手;
  • 如果是强制断电(来不及挥手),那就变成"突然断网",见下一条。

场景 3:物理断网(拔网线)

  • 客户端视角:网卡 down 了,本地立刻知道连接失效,直接释放资源;
  • 服务端视角:完全不知情!它还以为连接好着,继续往缓冲区塞数据;
    • 如果服务端一直不发数据,连接会永久挂在那里(半开连接);
    • 如果服务端尝试发数据,因为收不到 ACK,会不断重传;
    • 重传超时(通常几分钟后),才判定对端不可达,释放连接。

为了解决"服务端不知道客户端挂了"的问题,TCP 提供了 TCP Keep-Alive(保活机制)

  • 开启后,如果连接空闲超过一定时间(默认 2 小时),会发送探测包;
  • 连续几次没回应,就认为对端死了,关闭连接。

但现实中很少用 TCP Keep-Alive,因为:

  • 默认超时太长(小时级),业务等不了;
  • 它只能检测"连接是否通",不能判断"应用是否活着"(比如进程卡死但 TCP 栈还在回 ACK)。

场景 4:服务端断网又恢复

  • 客户端以为连接还在,继续发数据;
  • 服务端重启后,内核已清空所有连接状态;
  • 当它收到客户端的数据包(带旧 seq 号),发现"这连接我根本不认识";
  • 于是回复一个 RST(复位)包,强制关闭这个"幽灵连接";
  • 客户端收到 RST,就知道连接已失效,需要重新三次握手。

真正靠谱的保活:心跳机制(应用层)

既然 TCP 层的 Keep-Alive 不够用,那怎么办?

答案:自己在应用层实现心跳

  • 客户端每隔 30 秒发一个 {"type": "ping"};
  • 服务端回一个 {"type": "pong"};
  • 如果连续几次没收到 pong,就认为对端宕机,主动关闭连接。

这就是**"心跳机制"**。

它的好处:

  • 时间可控(秒级);
  • 能验证整个链路 + 应用逻辑是否正常(不只是网络通);
  • 可携带业务信息(比如上报状态)。

几乎所有长连接服务都靠心跳维持连接有效性。


4. 用UDP实现可靠传输

在它之上叠加适当的控制逻辑,就能构建出满足业务需求的可靠通道

要做的,就是回答三个问题:

  1. 怎么知道对方收到了? → ACK;
  2. 怎么知道数据顺序对不对? → 序列号;
  3. 没收到怎么办? → 超时重传(+ 快速重传)。

剩下的,比如要不要窗口、要不要拥塞控制、ACK 频率多高......全看你的场景。可以做一个极简版(只保不丢),也可以做一个 TCP Pro Max(带拥塞控制、选择性确认、快速恢复)。


相关推荐
聊点儿技术2 小时前
游戏分服总跨大区:如何用IP精准定位服务避免跨运营商分配?
tcp/ip·游戏·游戏安全·ip定位·ip离线库·ip精准定位·ip地址定位api
2401_873479402 小时前
网络安全中,如何通过IP地址分析攻击来源?
网络·tcp/ip·web安全
Andya_net2 小时前
网络安全 | 深度解析 F5 BIG-IP Advanced WAF:Bot 防护全攻略
网络·tcp/ip·web安全
lularible2 小时前
PTP协议精讲(3.1):走进开源PTP世界——LinuxPTP项目全景
网络·网络协议·开源·嵌入式·ptp
下地种菜小叶2 小时前
RPC 超时、重试、幂等怎么一起设计?一次讲清调用失败、重试风暴与下游保护思路
网络·网络协议·rpc
上海云盾-小余3 小时前
服务器 UDP/TCP 反射 DDoS 原理详解:攻击识别、清洗策略与企业防御部署指南
服务器·tcp/ip·udp
code_li16 小时前
HTTPS免费证书配置指南
网络协议·http·https
以太浮标17 小时前
华为eNSP模拟器综合实验之- 主机没有配置缺省网关时,通过路由式Proxy ARP实现通信(arp-proxy enable)
运维·网络·网络协议·华为·智能路由器·信息与通信
时空自由民.18 小时前
蓝牙协议栈知识和网络协议栈知识对比
网络·arm开发·网络协议