一文吃透传输层 TCP 与 UDP:从报文结构、底层原理到业务选型全解

前言:在 TCP/IP 五层网络模型中,传输层是衔接应用层与网络层的关键枢纽,向下封装 IP 报文、向上为各类应用程序提供数据传输能力。我们日常上网刷网页、打游戏、视频通话、域名解析,底层全部依赖 TCP 与 UDP 两大传输层协议。二者设计理念截然不同:一个追求极致可靠,一个主打低延迟高效。本文结合学习笔记,从端口基础入手,逐层拆解 UDP、TCP 报文格式、核心特性、经典机制、踩坑问题与落地选型,搞定面试与开发中 90% 的传输层考点。

一、再谈端口号

IP 地址用来定位全网中的一台主机,端口号用来定位一台主机上具体的应用进程,二者配合才能精准完成端到端的数据投递。

1.1 端口号范围划分

  1. 0~1023:知名端口(Well-Know Port)系统预约定制固定端口,绑定主流应用协议,不能随意占用:
    • 22:SSH 远程登录、21:FTP 文件传输、23:Telnet 远程终端、80:HTTP 网页、443:HTTPS 加密网页
    • Linux 可通过cat /etc/services查看系统全部知名端口映射表,自定义程序开发需要避开该区间端口。
  2. 1024~65535:动态临时端口客户端发起连接时,由操作系统随机自动分配,用完自动回收。

1.2 五元组唯一标识一条通信链路

在操作系统中,源 IP、源端口号、目的 IP、目的端口号、协议号五元组可以唯一标记一条网络通信,Linux 使用netstat -n命令查看五元组信息。

补充两个经典思考题:

⼀个进程是否可以bind多个端口号?

① 单个进程可以绑定多个端口(一个服务开多端口监听);

⼀个端口号是否可以被多个进程bind?

② 单个端口默认只能被一个进程绑定,如需多进程复用端口,需要设置setsockopt开启SO_REUSEADDR选项。

二、UDP协议

UDP 全称用户数据报协议,设计思想:尽可能少的协议开销,优先保证传输速度,可靠性交给上层应用自行处理,类比生活中邮寄平信:填好收件地址直接投递,不确认对方是否签收、丢件不通知。

1. 为什么说 “UDP 没有真正意义上的发送缓冲区”?

UDP没有真正意义上的发送缓冲区,调用sendto会直接交给内核,由内核将数据传给网络层协议进行后续的传输动作可以理解:

  • 当应用层调用 sendto(或 write)发送数据时,本质是一次用户态→内核态的数据拷贝:序列化后的应用数据(比如 “你好啊 + 时间 + 昵称”)会被直接拷贝到内核,交给 UDP 协议处理;
  • UDP 是无连接、不可靠的协议,不需要缓存发送过的数据来等待重传(TCP 会缓存发送缓冲区,直到收到 ACK 才释放),因此内核不会在内核态为 UDP 维护 “发送队列 / 发送缓冲区”,数据拷贝到内核后,会立刻被 UDP 封装报文,交给 IP 层处理;
  • 注意:sendto 调用成功,仅代表 “数据已成功拷贝到内核、交给 UDP 处理”,不代表数据已经发送到网络,更不代表对方已经收到,这和 TCP 的 “发送缓冲区缓存 + ACK 确认” 完全不同。

2. UDP 接收缓冲区:有序?丢包?全解析

UDP具有接收缓冲区,但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃,拆解核心特性:

  • 作用:内核收到 UDP 报文后,会先把报文放到 socket 对应的接收缓冲区中,等待应用层调用 recvfrom(或 read)来读取,避免应用层处理不及时导致报文丢失;
  • 顺序不保证:UDP 报文在网络传输中可能乱序到达(比如不同报文走了不同的路由),内核不会像 TCP 那样对报文排序,因此接收缓冲区里的报文顺序,和发送顺序可能不一致;
  • 缓冲区满则丢包:接收缓冲区的大小是有限的(可通过 setsockopt 调整 SO_RCVBUF),如果应用层读取速度慢于报文到达速度,缓冲区被占满后,新到达的 UDP 报文会被内核直接丢弃,且不会通知发送方;
  • 补充:应用层调用 recvfrom 读取数据时,也是一次内核态→用户态的拷贝,内核把接收缓冲区里的报文数据拷贝到用户态缓冲区,之后内核中的报文数据会被释放。

3. 全双工:UDP Socket 既能读也能写

UDP的socket既能读,也能写,这个概念叫做全双工,本质原因是:

  • UDP 是无连接协议,一个 UDP Socket 仅绑定 “本地 IP + 端口”,不关联任何对端 IP / 端口;
  • 因此同一个 Socket,既可以调用 sendto 向任意对端发送数据,也可以调用 recvfrom 接收任意对端发来的数据,天然支持全双工通信,不需要像 TCP 那样区分 “读 / 写方向”。

2.1 UDP 报文头部结构(固定 8 字节,无可变字段)

  • 16 位 UDP 总长度:代表整个 UDP 报文(首部 + 数据)总字节长度,16 位数值最大值 65535,因此单个 UDP 报文最大承载 64KB 数据。超过 64KB 数据需要在应用层手动拆分、发送、接收端手动拼装。
  • 16 位校验和:校验报文完整性,校验出错直接丢弃整包,不会向上报错。

Linux 内核中,所有网络报文都由 struct sk_buff(套接字缓冲区)管理,画的 sk_buff 结构,是理解内核处理的关键:

  • 每个 sk_buff 对应一个网络报文,内核通过 headdatatailend 四个指针,管理报文的内存空间;
  • 内存空间分为两部分:
    • 头空间:用来存放链路层头、IP 头、UDP 头(或 TCP 头);
    • 线性数据区:存放应用层数据,以及各层头部的实际内容;
  • 所谓的封装和解包,本质就是移动data指针在缓冲区中的指向!,这是内核处理报文的核心逻辑:不需要拷贝数据,只需要调整 data 指针的位置,就能完成各层的封装 / 解包,效率极高。

结合 sk_buff 指针移动,还原完整的封装 / 解包过程:

发送方(封装流程):
  1. 应用层数据序列化后,通过 sendto 拷贝到内核,内核创建 sk_buff,把应用层数据放到线性数据区的尾部;
  2. UDP 层封装:向前移动 data 指针,在头空间构建 UDP 头部(源端口、目的端口、长度、校验和),此时 data 指针指向 UDP 头部;
  3. IP 层封装:继续向前移动 data 指针,构建 IP 头部;
  4. 链路层封装:再向前移动 data 指针,构建以太网头部;
  5. 最终 sk_buff 交给网卡驱动发送,此时 data 指针指向以太网头部,网卡直接发送 datatail 的数据即可。
接收方(解包流程):
  1. 网卡收到报文,内核创建 sk_buff,把报文数据放到线性数据区,此时 data 指针指向以太网头部;
  2. 链路层处理:向后移动 data 指针,跳过以太网头部;
  3. IP 层处理:继续向后移动 data 指针,跳过 IP 头部;
  4. UDP 层处理:再向后移动 data 指针,跳过 UDP 头部,此时 data 指针指向应用层数据;
  5. 内核把这个 sk_buff 放到 UDP Socket 的接收缓冲区中,等待应用层 recvfrom 读取;
  6. 应用层调用 recvfrom,内核把 datatail 的应用数据拷贝到用户态缓冲区,之后 sk_buff 被释放。

如果应用层正在进行报文的解析、处理,会不会影响OS从网络中读取报文?为什么?

不会影响。内核的 sk_buff 和接收缓冲区是在内核态维护的,应用层调用 recvfrom 只是把内核接收缓冲区里的数据拷贝到用户态,之后应用层处理的是用户态的副本;内核会继续独立处理网卡收到的新报文,只要接收缓冲区没满,就会把新报文放到缓冲区中,和应用层的处理过程完全解耦;只有当应用层读取速度过慢,导致接收缓冲区被占满时,内核才会丢弃新的 UDP 报文,这是缓冲区大小限制导致的,不是应用层处理直接影响了内核的读取。

2.2 UDP 四大核心特性

  1. 无连接:通信前不需要握手建立连接,拿到目标 IP + 端口直接发送数据,建连开销几乎为 0;
  2. 不可靠传输:无 ACK 确认、无超时重传,报文丢包、乱序时 UDP 协议层不会给应用返回任何错误提示;
  3. 面向数据报:应用层交付多少字节数据,UDP 就打包成一个完整报文,既不分拆、也不合拼

    举例:sendto 一次性发送 100 字节,接收端必须调用一次 recvfrom 完整收取 100 字节,不能循环 10 次每次收 10 字节;天然自带报文边界,UDP 不存在 TCP 的粘包问题

  4. 全双工通信:UDP Socket 可读可写;无发送缓冲区(sendto 数据直接丢给内核发网络层)、有接收缓冲区(接收报文无序,缓冲区满则新来数据直接丢弃)。

2.3 基于 UDP 的主流应用协议 & 落地场景

协议用途
DNS域名→IP 地址解析
DHCP局域网设备自动分配 IP
TFTP/NFS轻量级文件传输、网络磁盘
BOOTP无盘设备开机引导

业务选型:优先 UDP:直播、网络游戏、语音通话、视频会议,这类场景允许少量丢包,实时性优先级 > 数据完整性,丢少量数据只会轻微卡顿,延迟过高体验崩盘。

三、TCP协议

3.1TCP协议端格式

我们这里先大致了解:

TCP 报文段分为两大部分:

  1. TCP 首部(报头):包含所有控制信息(端口号、序号、标志位等),长度可变,范围是20~60 字节
  2. 数据部分(有效载荷):应用层交给 TCP 传输的数据,长度可空(比如纯控制报文段),最大长度受限于 MSS(通常是 1460 字节)。
字段长度核心作用对应说明
16 位源端口号16 位标识发送方的进程端口(传输层地址)用来区分同一主机上的不同进程
16 位目的端口号16 位标识接收方的进程端口比如 HTTP 默认 80、HTTPS 默认 443
32 位序号(SN)32 位本报文段数据的第一个字节的编号TCP 是面向字节流的,每个字节都有编号,用于保证数据有序性和去重
32 位确认序号(ACK)32 位期望收到对方下一个报文段的第一个字节的序号只有ACK=1时有效,格式为「已收到的最后一个字节序号 + 1」
4 位首部长度(数据偏移)4 位表示 TCP 首部的长度,单位是4 字节下面会说,核心考点,用来分离报头和数据
6 位保留6 位保留给未来扩展使用,当前全为 0无实际作用
6 位标志位6 位控制 TCP 连接的状态(建立、释放、流量控制等)包含 URG/ACK/PSH/RST/SYN/FIN
16 位窗口大小16 位接收方的接收缓冲区剩余空间(单位:字节)流量控制核心字段,防止发送方发送过快
16 位检验和16 位校验整个 TCP 报文段(首部 + 数据)是否出错出错则直接丢弃报文段
16 位紧急指针16 位标识紧急数据的最后一个字节位置只有URG=1时有效,用于处理紧急数据

1.理解 TCP 报头和数据分离的关键,理解4位首部程度

  • 字段长度:4 位 → 取值范围0000~1111(十进制 0~15)
  • 单位约定:TCP 协议约定「首部长度的单位是 4 字节」,因为报头长度必须是 4 字节的整数倍(方便对齐)
  • 实际范围:
    • 最小值:TCP 首部固定部分是 20 字节(无选项时),所以20 ÷ 4 = 5 → 首部长度字段最小为 5
    • 最大值:4 位最大取值 15,15 × 4 = 60字节 → TCP 首部最大长度为 60 字节(选项字段最多 40 字节)
    • 因此首部长度字段的有效取值范围是[5,15],对应报头长度[20,60]字节

作用:接收方收到报文段后,先读取这个字段,乘以 4 得到报头总长度,就能直接定位到数据部分的起始位置,完美解决「报头和有效载荷分离」的问题。

2.6 位标志位(控制位)详解

  • URG(紧急)URG=1时,紧急指针有效,报文段包含紧急数据,需优先处理
  • ACK(确认)ACK=1时,确认序号有效,TCP 规定连接建立后所有报文段都必须置为 1
  • PSH(推送)PSH=1时,接收方需立即将数据交给应用层,不等待缓冲区满
  • RST(复位)RST=1时,强制释放异常连接,比如收到非法报文段时
  • SYN(同步)SYN=1时,请求建立连接,三次握手的核心标志,会占用一个序号
  • FIN(结束)FIN=1时,通知对方本方数据发送完毕,请求释放连接,会占用一个序号

3.为什么 TCP 报文段没有「总长度」字段,只有报头长度?

这是 TCP 和 IP 层的配合设计:

  • IP 数据报的首部已经包含「总长度」字段,记录了整个 IP 数据报的长度(IP 首部 + IP 数据部分,也就是 TCP 报文段)
  • 因此 TCP 报文段的总长度 = IP 数据报总长度 - IP 首部长度
  • TCP 数据部分长度 = TCP 报文段总长度 - TCP 首部长度(通过首部长度字段得到)
  • 结论:TCP 不需要重复定义总长度字段,通过 IP 层的信息 + 自身的首部长度字段,就能算出数据部分的长度。

4.报头和有效载荷如何分离?

完全依赖4 位首部长度字段

  • 发送方:根据报头实际长度设置该字段(比如无选项时设为 5)
  • 接收方:读取该字段后乘以 4,得到报头总长度,数据部分就是「报文段起始位置 + 报头长度」的位置,直接分离。
  1. 发送方:应用层数据交给 TCP 后,TCP 加上首部(端口号、序号、标志位、校验和等),封装成报文段交给 IP 层。
  2. 传输中:IP 层负责路由转发,链路层负责数据校验。
  3. 接收方
    • IP 层收到数据后,交给 TCP;
    • TCP 根据源 / 目的端口号,把数据交给对应的进程;
    • 根据首部长度字段分离报头和数据;
    • 校验和验证数据完整性,出错则丢弃;
    • 根据序号和确认序号处理数据有序性、可靠性;
    • 根据标志位处理连接建立 / 释放、紧急数据等控制逻辑;
    • 根据窗口大小进行流量控制,防止接收缓冲区溢出。

3.2确认应答(ACK)机制

图里的三次握手,就是 TCP 建立连接的过程,分为三步:

  1. 第一次握手(SYN):客户端给服务器发一个「连接请求包」,只带一个SYN(同步标志位),意思是:“服务器你好,我想和你建立连接,我的初始序号是 x”。
  2. 第二次握手(SYN+ACK):服务器给客户端回一个「同意连接包」,带SYN+ACK,意思是:“客户端你好,我收到你的请求了,同意建立连接,我的初始序号是 y”。
  3. 第三次握手(ACK):客户端再给服务器回一个「确认包」,带ACK,意思是:“服务器,我收到你的同意了,连接正式建立!”。

三次握手完成后,双方才进入ESTABLISHED(已建立)状态,之后才能正常收发数据。

1.为什么「前两次握手不能带数据」?

  • 连接还没 “真正建立”,状态不允许

前两次握手结束后,双方都还没完全确认对方收到了自己的消息:

  • 客户端发了 SYN,服务器回了 SYN+ACK,但服务器不知道客户端有没有收到自己的回复
  • 客户端收到了 SYN+ACK,但还没给服务器发最终确认,服务器还在 “等确认” 的状态。

这时候双方的连接状态是「半开」的,TCP 还没完成初始序列号的同步,也没确认对方的接收能力,根本没法可靠传输数据 —— 就算带了数据,对方也不知道要不要接收、怎么确认。

  • 防止攻击,保护服务器资源

如果允许 SYN 包(第一次握手)带数据,会有很大的安全隐患: 攻击者可以疯狂发送带大量数据的 SYN 包,服务器收到后,需要为这些「半开连接」分配内存、资源来处理数据,但这些连接永远不会完成第三次握手。最终服务器的资源会被占满,无法处理正常请求(这就是 SYN Flood 攻击的变种)。

所以 TCP 规范直接规定:SYN 报文(前两次握手的包)不允许携带数据,只能有 TCP 头部。

序列号:TCP给每个字节的数据都进行了排序

这样我们每次进行ACK的时候带上对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据,下一次你从哪里开始发

3.3超时重传机制

场景 1:发送的数据报文丢失

  1. 主机 A 发送1~1000字节数据,数据包在网络中丢失;
  2. A 等待预设超时时间后,始终未收到主机 B 的 ACK 确认;
  3. 触发超时重传,重新发送1~1000完整数据;
  4. B 收到重传报文,返回确认应答ack=1001,告知 A 全部数据已接收。

场景 2:数据到达,但 ACK 确认报文丢失

  1. 主机 A 发送1~1000数据,主机 B 正常收到;
  2. B 回复ack=1001确认报文,但 ACK 在传输途中丢失;
  3. A 等待超时无应答,判定数据丢失,再次重传1~1000
  4. B 收到重复报文,通过序列号识别重复数据包,直接丢弃重复数据,再次回复 ACK;
  5. A 收到 ACK 后停止重传,本轮传输完成。

💡核心关键点:序列号是 TCP 天然的去重工具,接收端无需额外逻辑,仅比对 seq 序号即可过滤重复重传包。

超时时间(RTO)的取值存在两难矛盾:

  1. 超时时间过长:丢包后等待很久才重传,大幅降低传输效率;
  2. 超时时间过短:网络轻微延迟就误判丢包,频繁重发重复数据包,浪费带宽,加剧网络拥塞。

因此 TCP 不会使用固定超时值,而是动态计算 RTO,适配不同网络时延。

重传间隔呈指数倍增:

  • 第 1 次重传等待:1 * 500ms
  • 第 2 次重传等待:2 * 500ms
  • 第 3 次重传等待:4 * 500ms
  • 第 4 次重传等待:8 * 500ms 以此类推,等待时间指数上涨。

当重传次数达到系统预设上限后,TCP 判定对端主机 / 网络故障,直接强制关闭 TCP 连接,释放内核资源。

3.4连接管理

我们知道正常情况下TCP要进行三次握手建立连接,四次挥手断开连接

3.4.1 服务端完整执行链路

  1. socket():创建监听文件描述符,TCP 初始状态 CLOSED
  2. bind():绑定固定 IP 与端口
  3. listen():切换为 LISTEN,内核创建半连接 / 全连接队列等待客户端
  4. accept():阻塞等待客户端连接,三次握手完成后返回通信 fd,进入 ESTABLISHED
  5. read/write:循环收发业务数据
  6. close(connfd):主动发送 FIN,走完四次挥手流程

3.4.2 客户端完整执行链路

  1. socket():创建客户端 fd,初始 CLOSED
  2. connect():发起 SYN 握手报文,状态切换 SYN_SENT,阻塞直至握手完成进入 ESTABLISHED
  3. read/write:业务数据交互
  4. close(fd):主动关闭连接,走完 FIN、ACK、TIME_WAIT 全流程

3.4.3三次握手(建立连接)状态变化

服务端状态流转

CLOSEDLISTENSYN_RCVDESTABLISHED

  1. CLOSED → LISTEN:调用listen()开启监听,等待外部 SYN 请求;
  2. LISTEN → SYN_RCVD:收到客户端 SYN 报文,回复SYN+ACK,连接存入内核半连接队列;
  3. SYN_RCVD → ESTABLISHED:收到客户端最终 ACK,握手完成,移入全连接队列,accept()返回。
客户端状态流转

CLOSEDSYN_SENTESTABLISHED

  1. CLOSED → SYN_SENTconnect()调用触发,向外发送 SYN 同步报文;
  2. SYN_SENT → ESTABLISHED:收到服务端SYN+ACK,回复 ACK,握手完成,connect函数解除阻塞返回。

3.4.4四次挥手(断开连接)状态变化

客户端主动关闭、服务端被动关闭的标准场景讲解:

客户端(主动关闭方)状态流转

ESTABLISHEDFIN_WAIT_1FIN_WAIT_2TIME_WAITCLOSED

  1. ESTABLISHED → FIN_WAIT_1:调用close()发送 FIN 报文,停止发送数据;
  2. FIN_WAIT_1 → FIN_WAIT_2:收到服务端对 FIN 的 ACK,此时客户端只能收数据,不能发;
  3. FIN_WAIT_2 → TIME_WAIT:收到服务端 FIN 关闭报文,回复最后一个 ACK;
  4. TIME_WAIT → CLOSED:强制等待2MSL(报文最大生存时间),超时后彻底释放连接。
服务端(被动关闭方)状态流转

ESTABLISHEDCLOSE_WAITLAST_ACKCLOSED

  1. ESTABLISHED → CLOSE_WAIT:收到客户端 FIN,回复 ACK,此时服务端仍可发送剩余业务数据;
  2. CLOSE_WAIT → LAST_ACK:业务数据发送完毕,调用close()发送 FIN 报文;
  3. LAST_ACK → CLOSED:收到客户端最后的 ACK,直接关闭连接,无等待阶段。

3.5理解TIME_WAIT机制

如果我们启动服务端监听 8000 端口,客户端建立连接;使用Ctrl+C强制终止服务端程序,服务端作为主动关闭方发送 FIN;立刻重新执行服务端程序,bind()系统调用报错。

  • CLOSE_WAIT客户端收到服务端 FIN,尚未关闭自身 fd;
  • FIN_WAIT2服务端等待客户端 FIN 报文; 此时内核 TCP 连接未完全释放,端口被占用,无法重新绑定。

3.5.1 协议硬性规则

主动关闭 TCP 连接的一端,必须进入TIME_WAIT状态,持续2MSL后才会切换至CLOSED释放端口。

  • MSL(Maximum Segment Lifetime):TCP 报文在网络中的最大生存时间;
  • RFC1122 标准规定 MSL=2 分钟,Linux(CentOS7/Ubuntu)系统默认简化为 60s;
  • 查看系统 MSL 配置:
cat /proc/sys/net/ipv4/tcp_fin_timeout

输出值即为单 MSL 时长,完整 TIME_WAIT 等待时间 = 2× 该数值。

3.5.2 为什么必须等待 2MSL(两大核心作用)

作用 1:保证最后一个 ACK 可靠送达对端

四次挥手最后一步:主动关闭方发送 ACK 给对端(被动关闭方)。

  • 若这条 ACK 在网络中丢失,被动关闭方会超时重发 FIN;
  • 主动关闭方仍处于 TIME_WAIT,内核连接资源未销毁,可以重新补发 ACK;
  • 若没有 2MSL 等待,连接直接销毁,收到重传 FIN 时只能回复 RST,造成对端连接异常断开。
作用 2:隔离旧连接延迟报文,避免脏数据干扰新连接

网络中存在延迟滞留的旧数据包,最长存活时间为 1MSL:

  • 等待完整 2MSL,能确保双向传输路径里所有上一轮连接的延迟报文全部过期消失;
  • 若立刻重启服务复用端口,新建立的连接可能收到上一次连接残留的乱序报文,引发数据错乱。

Q1:被动关闭连接的一方为什么不会进入 TIME_WAIT?

被动关闭方走完LAST_ACK后,收到最后一个 ACK 即可直接释放连接,不存在补发报文、隔离旧数据包的需求,协议无需设计等待周期。

Q2:大量 TIME_WAIT 堆积会造成什么问题?

  1. 客户端:耗尽本机临时端口,无法新建 TCP 连接;
  2. 服务端:端口被持续占用,服务重启失败;
  3. 内核维护大量连接状态,消耗内存与 CPU 资源。

Q3:2MSL 为什么不是 1MSL?

报文往返需要双向链路,1MSL 仅能保证单向报文过期,2MSL 才能覆盖往返全链路所有延迟数据包。

3.6解决TIME_WAIT状态引起的bind失败的方法

3.6.1大量 TIME_WAIT 产生的业务场景痛点

1. 高并发短连接服务场景

服务端承载海量短连接,客户端请求生命周期极短、每秒新建大量连接;若由服务端主动关闭连接(如清理空闲超时客户端),每一次关闭都会生成一条TIME_WAIT连接。

2. 五元组资源耗尽问题

TCP 连接由五元组唯一标识:源IP、源端口、目的IP、目的端口、协议

  • 服务端目的IP、目的端口、协议固定不变;
  • 大量TIME_WAIT连接会持续占用五元组资源;
  • 当新客户端的源 IP + 源端口与旧 TIME_WAIT 连接重复时,新建连接会失败,同时服务重启执行bind()会直接抛出Address already in use
3. 原生 TCP 限制

默认情况下,端口在存在TIME_WAIT连接时,不允许程序重新bind监听该端口,对开发调试、高并发线上服务不友好。

3.6.2SO_REUSEADDR 原理与代码实现

1. 核心作用

通过setsockoptSO_REUSEADDR置 1,允许同一端口绑定多个不同 IP的 socket,同时允许在端口存在 TIME_WAIT 连接时,重新执行bind()监听该端口,直接解决端口占用报错。

2. 标准 C 语言代码
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

⚠️ 调用时机:必须在bind()之前设置,若已完成绑定再设置该选项不会生效。

选项核心能力适用场景
SO_REUSEADDR允许端口存在 TIME_WAIT 时 bind;同端口绑定多 IP单进程服务、开发调试、普通 Web 服务
SO_REUSEPORT允许多个进程 / 线程同时 bind 同一个 IP + 端口多进程负载均衡服务(Nginx 多 worker、网关集群)

3.7理解CLOSE_WAIT状态

// 读取客户端数据失败(客户端关闭连接)时,注释掉关闭fd代码
if (!ret) {
    printf("[client %s:%d] disconnect!\n", ip.c_str(), port);
    // new_sock.Close();  // 关键代码被注释,未释放通信套接字
    break;
}

启动服务端,客户端建立连接,两端均为ESTABLISHED正常传输状态;客户端主动关闭程序,发送 FIN 报文执行四次挥手;查看 TCP 连接状态:

  • 客户端:FIN_WAIT2(等待服务端 FIN 报文)
  • 服务端:CLOSE_WAIT(永久卡住,无法走完挥手流程)
tcp        0      0 127.0.0.1:49958  127.0.0.1:9090  FIN_WAIT2
tcp        0      0 127.0.0.1:9090   127.0.0.1:49958  CLOSE_WAIT  5038/./dict_server
  • IN_WAIT2:客户端已发 FIN、收到服务端 ACK,等待服务端 FIN;
  • CLOSE_WAIT:服务端收到客户端 FIN 并回复 ACK,但迟迟不发送 FIN,挥手流程停滞。

3.7.1CLOSE_WAIT 底层原理(结合四次挥手)

  1. 客户端主动close()发送 FIN → 服务端 TCP 内核收到 FIN;
  2. 内核自动回复 ACK,服务端连接进入CLOSE_WAIT状态;
  3. 协议规则:进入CLOSE_WAIT后,必须由应用层主动调用 close (),内核才会发送 FIN 报文,进入LAST_ACK完成挥手;
  4. 若应用层不关闭 socket 文件描述符:内核不会发送 FIN,连接永久卡在CLOSE_WAIT,占用内核资源、文件描述符。

3.7.2大量 CLOSE_WAIT 带来的线上危害

  1. 文件描述符泄漏:持续堆积会触发too many open files,无法接受新客户端连接;
  2. 内存占用:内核持续维护失效连接状态,消耗服务器内存;
  3. 客户端连接卡死:客户端长期停留在FIN_WAIT2,无法正常释放连接。

服务端读取客户端数据返回失败(代表客户端关闭)后,业务代码未调用close()释放通信 socket,属于典型 IO 逻辑 BUG。

Q:为什么内核收到 FIN 后不会自动发送 FIN,必须等应用层 close?

A:TCP 是全双工协议,客户端仅关闭发送通道,服务端可能仍有剩余业务数据需要发给客户端;内核无法判断业务是否还有数据待发送,因此将关闭决定权交给应用层,只有调用close()才代表业务无数据要发送,内核才会发送 FIN。

3.8滑动窗口

传统一发一收模型:发送 1 段数据,必须等待 ACK 返回才能发送下一段。 在网络往返时延 RTT 较长时,大量时间浪费在等待应答上,吞吐极低。

3.8.1 滑动窗口核心思想

一次性发送窗口内所有数据,将多段报文的等待时延重叠,大幅提升传输效率。

  • 窗口大小:无需等待 ACK 即可连续发送的最大字节数,示例窗口 = 4000 字节(4 个 1000 字节分段);
  • 发送逻辑:窗口未满时持续发包;收到对应 ACK 后,窗口向右滑动,释放缓冲区空间,补充发送新数据;
  • 内核配套:操作系统维护发送缓冲区,缓存所有未收到 ACK 的数据,用于丢包重传;
  • 性能关系:窗口越大,单位时间传输数据量越高,网络吞吐率越高。

  • 初始窗口覆盖1001~4000四段数据,一次性全部发送;
  • 收到ack=2001(代表 1001~2000 已完整接收);
  • 窗口向右滑动一格,覆盖2001~5000,此时可发送5001~6000新分段。

场景 1:数据报文抵达,但 ACK 丢失

TCP 采用累计确认机制,后序 ACK 可以覆盖前面所有已接收数据。 例如ack=4001会直接确认 1~3000 全部数据,中间丢失的ack=2001/3001无需重传,不会影响传输,无额外开销。这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认

场景 2:数据报文直接丢失(快重传机制)

  1. 假设1001~2000报文丢失,主机 B 持续收到后续分段,会重复回复ack=1001
  2. 发送端连续收到3 次重复 ACK,无需等待超时定时器,立刻重传丢失的1001~2000分段;
  3. 重传后接收端补齐数据,返回最新累计 ACK,传输恢复。
  4. 这个时候接收端收到了1001之后,再次返回的ACK就是7001了(因为2001-7000)接收端其实之前 就已经收到了,放到了接收端操作系统内核的接收缓冲区中

💡 快重传价值:跳过漫长的超时退避等待,快速修复丢包,大幅降低延迟。

3.9流量控制

接收端处理数据能力存在上限,若发送端发包速率过快,会填满接收内核缓冲区,引发丢包、连锁超时重传,严重降低传输效率。 流量控制的核心逻辑:由接收端告知发送端自身剩余接收容量,限制发送端最大发送数据量,避免接收缓冲区溢出。

3.9.1流量控制完整工作流程

1. 窗口大小传递规则

TCP 头部内置 16 位「窗口大小」字段,接收端每次回复 ACK 时,将当前接收缓冲区剩余空间填入该字段,同步给发送端:

  • 窗口数值越大,代表接收端空闲缓存越多,允许发送更多数据,吞吐越高;
  • 接收端缓存即将占满时,主动缩小窗口值,通知发送端降低发送速度;
  • 发送端严格遵循:单次可发送数据总量 ≤ 接收端通告窗口大小。
2. 零窗口场景(接收缓冲区完全占满)
  1. 接收端缓存写满,ACK 中窗口置0
  2. 发送端立即停止发送业务数据;
  3. 为避免「窗口更新 ACK 丢失」导致永久断连,发送端会周期性发送窗口探测报文
  4. 接收端收到探测包后,回复最新窗口值;若缓存释放,窗口恢复非 0,发送端恢复传输。

3.9.1窗口扩大因子(解决 16 位窗口上限问题)

1. 原生限制

TCP 头部窗口仅 16 位,理论最大值 2^16-1 = 65535 字节,高速网络下窗口过小会限制吞吐。

2. 扩展方案(Window Scale)

TCP 头部 40 字节可选字段中携带窗口扩大因子 M,实际窗口计算公式: 真实窗口 = 头部16位窗口值 << M 通过左移操作,可将窗口上限提升至 GB 级别,适配万兆、高速链路传输。

  • 为什么需要窗口探测包? 若接收端窗口从 0 恢复为非 0 的 ACK 报文在网络中丢失,发送端会永久停发数据。窗口探测包可主动触发接收端返回最新窗口,打破死锁。
  • 滑动窗口的两个限制是什么? 发送端实际发送上限 = min (拥塞窗口 cwnd, 接收通告窗口 rwnd),同时受流量控制、拥塞控制双重约束。
  • 窗口扩大因子何时协商? 仅在三次握手的 SYN 报文携带协商,连接建立后无法修改 M 值。

3.10拥塞控制

滑动窗口解决了接收端处理能力限制(流量控制),但无法解决中间网络链路拥堵问题: 网络中存在多台主机共享链路,若连接刚建立就大量发包,会瞬间填满路由器缓存,造成大面积丢包、全网延迟飙升。 拥塞控制核心思路:先少量发包探测网络状态,逐步提升发送速率,一旦检测到拥堵立刻减速,平衡传输速度与网络负载。

  • 拥塞窗口 cwnd:由发送端维护,代表当前网络允许发送的最大字节;
  • 接收窗口 rwnd:流量控制,由接收端通告,代表接收缓冲区剩余容量;
  • 实际发送窗口 = min (cwnd, rwnd),发送速率同时受网络、接收端双重限制;
  • 慢启动阈值 ssthresh:区分「慢启动指数增长」与「拥塞避免线性增长」的临界值。

3.10.1阶段 1:慢启动(Slow Start)

1. 初始规则
  • 连接建立初期,cwnd = 1
  • 每收到 1 个完整 ACK,cwnd += 1一轮 RTT 窗口直接翻倍,指数级快速扩张;
  • “慢启动” 仅指初始发包量小,实际窗口增长速度极快。
2. 终止条件

cwnd >= ssthresh,退出慢启动,进入拥塞避免阶段

初始 cwnd=1,发送 1 段数据;收到 ACK 后 cwnd 变为 2,一次性发送 2 段;收到 2 个 ACK 后 cwnd 变为 4…… 以此类推,窗口指数上涨,快速探测网络承载上限。

3.10.2阶段 2:拥塞避免(Congestion Avoidance)

1. 增长规则(加法增大)

越过 ssthresh 阈值后,不再指数翻倍: 每经过 1 个完整 RTT 往返周期,cwnd += 1,窗口线性缓慢增长,平缓试探网络极限,避免瞬间压垮链路。

此时图表中曲线斜率明显放缓,从陡峭指数曲线变为平缓斜线。

TCP 通过两种丢包事件区分拥堵程度,执行不同减速策略:

3.10.3场景 1:超时重传(严重网络拥堵)

  1. 判定逻辑:报文丢失且长时间无 ACK 返回,触发超时定时器;
  2. 处理规则:
    • ssthresh = cwnd / 2(乘法减小,阈值减半)
    • cwnd = 1,重新回到慢启动阶段,极低速率重新探测网络;
  3. 适用场景:链路严重拥塞、大面积丢包。

3.10.4场景 2:收到 3 次重复 ACK(轻度丢包,快重传触发)

  1. 判定逻辑:单段报文丢失,但后续报文正常抵达,接收端持续返回重复 ACK;
  2. 处理规则:仅将ssthresh = cwnd / 2,cwnd 直接等于新 ssthresh,进入拥塞避免,无需重置到 1;
  3. 优势:网络仅轻微波动,无需退回到极低发送速率,传输吞吐下降幅度更小。
维度流量控制 Flow Control拥塞控制 Congestion Control
管控对象接收端主机缓冲区整条中间传输网络链路
控制窗口接收窗口 rwnd(接收端通告)拥塞窗口 cwnd(发送端计算)
增长逻辑随接收缓存空闲空间动态变化慢启动指数增长、拥塞避免线性增长
触发减速条件接收缓冲区即将占满网络丢包(超时 / 重复 ACK)
核心目标防止接收端缓存溢出丢包防止路由器链路拥堵、全网雪崩

3.11延迟应答

接收端不收到数据就立刻回复 ACK,而是短暂等待一段时间再应答,带来两大收益:

1.通告更大接收窗口,提升吞吐 举例如图中场景:接收缓冲区总大小 1M,一次性收到 500K 数据

  • 立刻应答:缓冲区剩余 500K,ACK 携带窗口 = 500K;
  • 等待 200ms 再应答:应用层快速消费完 500K 数据,缓冲区空闲回到 1M,ACK 携带窗口 = 1M; 窗口数值越大,发送端可一次性发送更多数据,整体传输效率更高。

2.减少网络中 ACK 报文数量,降低带宽开销 支持累计确认,连续多段数据合并为单条 ACK 回复,避免每收到一段数据就单独回包,减少往返小包数量。 时序图示例:收到1~1000、1001~2000两段数据,仅回复一条ack=2001,而非两次独立 ACK。

为避免 ACK 长时间滞留、触发发送端超时重传,系统设置两条硬性约束,满足任意一条就立即回复 ACK:

  1. 数量限制:每收到 2 个分段,强制回复一次 ACK(通用系统默认 N=2);
  2. 时间限制:最大等待超时 200ms,无论收到几段数据,超时必须应答。 不同操作系统数值略有差异,但 200ms、每 2 段应答是行业通用标准。

3.12捎带应答

承接上一节延迟应答,绝大多数 C/S 业务是「一问一答」交互模型:客户端发送请求,服务端一定会返回业务响应数据。 如果单独发送一条纯 ACK 报文,会额外消耗一次网络往返开销;捎带应答将确认信息和业务回复合并在同一个 TCP 报文段传输,省去独立 ACK 小包。

3.13面向字节流

创建 TCP socket 时,操作系统内核会同步分配两块独立缓冲区:发送缓冲区接收缓冲区,这是 TCP 字节流特性的底层支撑。

3.13.1 发送缓冲区工作逻辑

  1. 应用调用write/send写入数据,数据不会立刻发往网络,先存入内核发送缓冲区;
  2. 大数据场景:缓冲区数据超出 MSS 最大分段大小,内核自动拆分为多个 TCP 报文发送;
  3. 短数据场景:少量字节会在缓冲区暂存,等待凑齐足量数据、或延迟应答时机统一发送(Nagle 算法),减少小包数量。

3.13.2 接收缓冲区工作逻辑

  1. 网卡收到对端 TCP 报文,内核将数据存入接收缓冲区;
  2. 应用调用read/recv时,仅从内核缓冲区拷贝数据,和对方发送时的分段、调用次数完全解耦。

3.13.3 全双工定义

一条 TCP 连接同时持有发送、接收双向缓冲区,两端可同时收发数据,互不阻塞、互不干扰,该特性称为全双工。

3.13.4面向字节流核心特征:读写完全解耦

缓冲区隔离了应用层读写操作,发送方write调用次数、单次写入长度,和接收方read完全不需要一一对应:

  1. 发送侧示例:写入 100 字节,可以write(fd, buf, 100)一次写完,也可以循环 100 次每次写 1 字节;
  2. 接收侧示例:读取这 100 字节,可一次性read(buf,100)读完,也可以分 100 次每次读 1 字节;
  3. 核心本质:TCP 只传输连续字节序列,不保留应用层数据包边界

3.14粘包问题

3.14.1粘包核心成因

1. 底层根本原因

TCP 是面向字节流协议,头部仅有序列号,不存在 UDP 那样的「报文长度」字段,内核只负责把字节按序号排序存入缓冲区,不会保留应用层数据包边界:

  • 传输层视角:数据是一段段独立 TCP 报文;
  • 应用层视角:读取到的是连续无分割的字节流,无法区分哪一段是完整业务包。
2. 两类粘包场景
  1. 发送粘包:多次短数据写入发送缓冲区,内核合并为单条 TCP 报文发出;
  2. 接收粘包:多条 TCP 报文的数据堆积在接收缓冲区,一次read读取多段业务数据; 最终应用层分不清包与包的分割点,出现半包、粘包故障。

3.14.2三种通用解决方案(核心思路:人为定义包边界)

方案 1:定长数据包

约定每个业务数据包固定字节长度,读取时按固定大小循环截取。 适用场景:结构体固定、指令长度统一的简单通信; 示例:struct Request固定大小,每次从缓冲区读取sizeof(Request)字节。

方案 2:变长包 + 长度头(工业主流方案)

数据包头部预留固定长度字段(如 4 字节 uint32),存储后续包体的字节长度; 读取逻辑:

  1. 先读取 4 字节长度值len
  2. 再从缓冲区读取len字节作为完整包体; 优势:支持任意长度可变消息,解析稳定,HTTP、RPC 框架均采用该思路。
方案 3:特殊分隔符分割

在每条业务包末尾添加唯一自定义分隔符(如\r\n#); 约束:业务正文内部不能出现该分隔符,否则会出现拆包错乱; 适用场景:文本类协议(HTTP、Redis 协议)。

3.14.3关键对比:UDP 不存在粘包问题

  1. UDP 头部自带报文长度字段,内核完整保留报文边界;
  2. 内核交付规则:一次recv只能读取一个完整 UDP 报文,不会出现半段数据;
  3. 应用层特征:要么收到完整数据包,要么收不到,不存在多包合并、半包的情况;
  4. 补充:UDP 仅存在丢包问题,无粘包 / 半包故障。

Q:粘包是 TCP 协议本身的缺陷吗?

A:不是。TCP 设计目标是可靠传输字节流,不关心应用层业务边界;粘包是应用层未做分包处理导致的业务问题,需要上层自定义协议解决。

Q:Nagle 算法会加重粘包吗?

A:会。Nagle 算法会缓存短数据凑大包发送,加剧发送侧粘包;实时交互场景(游戏、IM)可关闭该算法。

3.15TCP的异常情况

3.15.1 进程正常终止

程序调用close()或进程退出,操作系统自动回收 socket 文件描述符,内核发送 FIN 报文,完整执行四次挥手流程,和主动正常关闭无区别。

3.15.2 机器重启

系统内核销毁所有 socket 资源,同样会发送 FIN 完成挥手,流程与进程终止一致。

3.15.3 机器断电 / 网线断开(无 FIN 报文)

  1. 对端不会收到任何 FIN 报文,内核连接状态暂时保留;
  2. 触发两种检测机制释放失效连接:
    • TCP 保活定时器:内核周期性发送探测报文,多次无应答则判定连接失效,释放资源;
    • 应用层写入检测:当程序调用write发送数据时,内核检测链路不可达,直接返回 RST 复位连接。
  3. 上层应用补充保活:如 HTTP 长连接、IM 软件(QQ)会自定义心跳包,断线后主动重连。

3.16TCP 完整核心机制小结

TCP 设计复杂的根本目标:兼顾传输可靠性与网络吞吐性能

3.16.1 可靠性保障机制(杜绝丢包、乱序、重复、损坏)

  • 校验和:检测报文传输过程中数据损坏;
  • 序列号:为每个字节编号,实现有序接收、重复包过滤;
  • 确认应答 ACK:告知发送端已接收的数据边界;
  • 超时重传:报文丢失后自动补发,兜底可靠传输;
  • 完整连接管理:三次握手建立、四次挥手断开,保证连接生命周期合法;
  • 流量控制:限制发送速率,防止接收端缓冲区溢出。

3.16.2 性能优化机制(降低延迟、提升吞吐、减少小包)

  • 滑动窗口:批量发送多段数据,重叠 RTT 等待时延;
  • 快重传:收到 3 次重复 ACK 立刻重传,跳过超时等待;
  • 延迟应答:合并多条 ACK、等待应用释放缓存以通告更大窗口;
  • 捎带应答:将 ACK 搭载在反向业务数据上,省去独立 ACK 小包;
  • 拥塞控制:慢启动 + 拥塞避免,探测网络承载上限,避免全网拥堵。

3.16.3 配套定时器机制

TCP 内置多组定时器驱动全流程:超时重传定时器、保活定时器、TIME_WAIT 2MSL 定时器、零窗口探测定时器。

3.17TCP 与 UDP 对比 & 场景选型

核心选型逻辑

不存在绝对优劣,二者是适配不同业务需求的工具,根据实时性、可靠性要求取舍。

TCP 适用场景

要求数据 100% 送达、有序不丢失,对延迟容忍度较高: 文件下载、数据库交互、网页访问、配置同步、金融事务。

UDP 适用场景

追求超低延迟、允许少量丢包,对实时性要求优先于可靠: 直播视频、语音通话、网络游戏、广播 / 组播消息。

关键区分

维度TCPUDP
连接属性面向连接,握手 / 挥手无连接,直接发包
可靠性可靠有序,无丢包无重复不可靠,可能丢包乱序
头部开销20~60 字节,控制字段多仅 8 字节,极简头部
边界特性字节流,存在粘包问题面向报文,天然保留包边界
内置控制流量 / 拥塞控制、重传、窗口无任何内置控制逻辑

3.18经典面试题:如何用 UDP 实现可靠传输

思路:在应用层复刻 TCP 全套可靠机制,自主实现以下核心逻辑:

  1. 自定义序列号:为每条报文分配序号,保证接收端有序重组、过滤重复包;
  2. 自定义 ACK 确认应答:接收端返回确认号,告知发送端已接收数据;
  3. 超时重传定时器:发送报文后计时,无 ACK 则自动重发;
  4. 滑动窗口、流量控制、拥塞控制(可选,优化吞吐);
  5. 心跳保活、连接握手 / 断开逻辑(可选,管理连接状态)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值