【Linux第二十三章】传输层

前言 🚀

学到网络时,很多人会把 UDPTCP、滑动窗口、三次握手、四次挥手、拥塞控制这些概念分开背,但真正写网络程序时会发现:这些知识点其实都围绕同一个核心问题展开------传输层到底如何把应用层的数据,稳定、正确、尽可能高效地从一端交付到另一端。

从这个角度看,传输层并不只是"在 IP 上面再包一层协议"那么简单。它既要解决端口寻址、报头与数据分离、缓冲区管理、报文排序这些基础问题,也要继续处理可靠性、流量控制、拥塞控制、异常断连和连接管理等一整套通信细节。

这篇文章就按照这条主线,把这份《传输层》笔记里的内容重新整理起来:先看 UDP 如何完成简单直接的数据报传输,再看 TCP 如何在此基础上引入连接、确认、窗口和重传机制,最终实现"既可靠又尽量高效"的数据传输。


一. 传输层在网络中到底负责什么 🧠

传输层处在应用层和网络层之间,核心职责是:在主机到主机通信的基础上,进一步完成进程到进程的数据交付。

1.1 端口号与通信标识

传输层通过端口号来标识具体应用进程。一个端口号在同一时刻通常只能绑定一个进程,但一个进程可以绑定多个端口号,因此端口号解决的是"当前数据应该交给哪一个应用"的问题。

从实践上看,端口还存在权限和分配规则:

  • 0-1023 属于知名端口,绑定通常需要更高权限
  • 1024-65535 通常属于普通或动态分配端口范围

而一条通信在更完整的意义上,常常会被一个五元组标识:

  • IP
  • 源端口
  • 目的 IP
  • 目的端口
  • 传输层协议类型

也正因为如此,传输层不仅要"发数据",还要负责把数据准确归属到正确的连接或通信对象上。


二. UDP:简单直接的数据报协议 🧱

UDP 的设计目标非常明确:尽量简单地把应用层交下来的数据报发送出去,不主动承担复杂的可靠性控制。

2.1 UDP 报头为什么这么短

UDP 报头固定只有 8 字节,由四个 16 位字段组成:

  • 源端口
  • 目的端口
  • 长度
  • 校验和
cpp 复制代码
struct udphdr {
    __be16 source; // 源端口
    __be16 dest;   // 目的端口
    __be16 len;    // UDP总长度(报头+数据)
    __be16 check;  // 校验和
};

其中,长度字段描述的是整个 UDP 报文的总长度 ,也就是报头和数据加起来的总字节数。由于报头长度固定是 8 字节,所以接收方只要知道总长度,就能很容易把报头与有效载荷区分开来。

需要注意的是,长度正确并不等于报文一定完整可靠。长度字段只能告诉你"这里应该有多长",真正的数据是否被损坏,还要结合校验和等机制来判断。

2.2 UDP 的封装特点:不拆分,也不合并

UDP 的一个非常重要的特征是:应用层交给它多长的数据报,它基本就按这个边界原样发送出去。

也就是说,UDP 不会像 TCP 那样把多次发送拼成一条连续字节流,也不会主动帮你把一条消息再拆成应用层语义上的多个小块。它天然保留"报文边界"。

这也是为什么很多实时通信或短消息场景喜欢用 UDP:发送方发出去的是一条完整消息,接收方看到的也是一条完整消息,边界明确,理解成本低。

2.3 UDP 的缓冲区特征

从这份笔记的表述来看,UDP 没有像 TCP 那样强调"真正意义上的发送缓冲控制语义",sendto() 更像是把数据直接交给内核,后续再由协议栈继续向下传递。

而在接收侧,UDP 会有接收缓冲区,用来暂存到达的报文。这里有两个后果要特别注意:

  • UDP 不保证报文的严格到达顺序
  • 如果接收缓冲区满了,后续到达的 UDP 数据可能直接被丢弃

💡 避坑指南:
UDP 的"简单"并不等于"什么都不用管"。

它只是把很多复杂问题留给了应用层自己处理,例如顺序、丢包、重传和拥塞适配。


三. UDP 报文是如何在内核里被组织起来的 🔍

单从协议格式看,UDP 报头好像只是几个字段;但真正进入操作系统协议栈之后,它必须有一个能够同时容纳"报头 + 数据"的载体,这就引出了 sk_buff

3.1 sk_buff 是协议栈中的通用报文载体

Linux 网络协议栈里,struct sk_buff 是非常核心的数据结构。它可以理解成协议栈内部用于承载网络报文的一种通用缓冲区对象。

当应用层发来 "hello" 这样的数据时,内核会把:

  • UDP 报头
  • 应用层数据
  • 后续协议层可能继续加上的封装内容

组织到 sk_buff 这类结构所管理的内存里,形成一个可在协议栈中继续传递和处理的完整报文。

3.2 缓冲区在 UDP 封装里的意义

这里的缓冲区不只是"放数据的地方",它至少承担三层作用:

  1. 数据存储:为报头和载荷提供连续空间,使它们能作为一个整体被处理。
  2. 内存管理:通过指针等机制高效组织数据,减少不必要的拷贝。
  3. 协议栈衔接:为网络层、链路层继续封装提供统一输入。

因此,UDP 虽然协议本身简单,但它在操作系统内部依然需要依赖统一的缓冲区体系完成真正的数据组织与传递。


四. TCP:在字节流之上实现可靠传输 🗺️

相比 UDPTCP 的核心目标不是"尽快把一条报文丢出去",而是:在复杂网络环境中,尽量保证数据有序、可确认、可重传、可流控地被送达。

4.1 TCP 报头比 UDP 复杂得多

TCP 报头至少 20 字节,而且可通过选项字段继续扩展。常见关键字段包括:

  • 源端口、目的端口
  • 序号 seq
  • 确认序号 ack_seq
  • 首部长度
  • 标志位
  • 窗口大小
  • 校验和
  • 紧急指针
  • 选项字段

4.2 首部长度字段为什么重要

TCP 报头并不是固定长度,因为它可能带选项。因此它必须有一个明确字段告诉接收方:"报头到哪里结束,数据从哪里开始。"

这个字段只有 4 位,但单位不是字节,而是 4 字节为单位 。例如首部长度字段值为 5,表示实际首部长度为:

5 × 4 = 20 字节

这样一来,接收方就能先解析报头控制信息,再准确找到后面的有效载荷。

4.3 TCP 发送的永远是完整报文段

笔记里特别强调了一点:哪怕当前没有业务数据,TCP 也至少会发送一个带报头的完整报文段。因此从协议视角看,控制报文和数据报文本质上都是 TCP 报文段,只不过载荷可能为空。


五. TCP 为什么能做到"可靠" 💻

很多初学者会把"可靠传输"理解成"只要发出去,对方就一定收到"。但更准确的理解应该是:

TCP 的可靠性不是单次动作的绝对保证,而是一整套机制组合起来,最终尽可能保证数据按预期到达。

5.1 ACK 并不是"百分之百保证"

ACK 的真正含义是:

接收方已经按序收到了某个序号之前的所有数据。

它不是"这一瞬间 100% 绝对安全"的证明,而是发送方继续推进状态的依据。真正让 TCP 可靠起来的,是下面这套组合:

  • 序号
  • 累积确认
  • 超时重传
  • 去重与按序重组

5.2 序号为什么重要

网络中报文可能乱序到达,如果没有序号,接收方就无法判断:

  • 这是不是旧报文
  • 这是不是重复报文
  • 这段数据应该放在字节流中的什么位置

所以,TCP32 位序号给字节流中的字节编号,从而建立"传输中的顺序语义"。

5.3 确认序号为什么要单独存在

确认序号表示"该序号之前的数据都已经收到了 "。例如,若对方返回确认序号 2001,就表示 1-2000 这些字节都已经被正确接收。

序号和确认序号不能合成一个字段,原因在于:TCP 是全双工的。通信双方都可能一边发数据,一边对历史数据做确认,所以一个报文段里既要带"我自己的发送序号",也要带"我对你那边数据的确认序号"。

5.4 超时重传如何补足可靠性

发送方把已发送但尚未确认的数据暂存在发送缓冲区对应的滑动窗口区域中。如果在规定时间内没收到确认应答,就会重新发送。

而接收方借助序号可以识别重复报文,避免同一份数据被上层重复交付。

💡 避坑指南:
TCP 的可靠性不是"对方一定收到",而是"我能知道哪些收到了,哪些没收到;没收到还能继续补发"。


六. TCP 是面向字节流的,这决定了很多编程行为 🧩

TCPUDP 最本质的差别之一,不只是"有连接 / 无连接",更关键的是:

  • UDP 面向数据报
  • TCP 面向字节流

6.1 字节流意味着什么

对于 TCP 来说,应用层写入的数据最终会被看作一串连续字节。发送方可能分多次 write/send,接收方也可能一次读到多份历史报文拼接起来的连续内容。

因此,TCP 在上层表现出来的不是天然分包的数据消息,而是一条没有边界的连续数据流。

6.2 粘包问题为什么会出现

正因为 TCP 面向字节流,接收方无法仅凭一次 read/recv 的结果判断:

  • 这是完整的一条消息
  • 还是半条消息
  • 还是多条消息拼到了一起

这就产生了所谓"粘包问题"。严格来说,这不是 TCP 出错,而是应用层没有额外定义消息边界。

6.3 常见解决办法

笔记中给出了三类典型方案:

  1. 特殊字符分隔:用特定分隔符标识消息边界
  2. 定长报文:每条消息长度固定
  3. 报头 + 自描述字段:在报头里加长度、类型等信息

其中第三种最通用,因为它既能保留灵活长度,又能让接收方通过协议字段精确切分消息。


七. 滑动窗口:TCP 如何兼顾可靠性与效率 ⚠️

如果 TCP 每发一段数据就必须停下来等 ACK,那效率会非常低。为了解决这个问题,TCP 引入了滑动窗口与流水线发送机制。

7.1 它不是"一发一答",而是可以批量推进

发送方在收到确认之前,并不一定只能发一段数据。只要当前还在可发送窗口范围内,就可以连续发出多个报文段,让"发送"和"等待确认"重叠起来执行。

这样做的好处是:网络传输时间不会被白白浪费,整体吞吐量会高很多。

7.2 滑动窗口在缓冲区中的位置

从实现视角看,滑动窗口可以看作发送缓冲区中的一个区域,大体分成三部分:

  • 已发送且已确认
  • 已发送但未确认
  • 尚未发送但处于可发送范围内

窗口位置通常可用两个概念来理解:

  • win_start:确认序号,对应左边界
  • win_end = win_start + win:窗口右边界

7.3 窗口为什么会变大、变小甚至变成 0

窗口大小并不是固定不变的,它会根据接收方当前处理能力动态调整:

  • 接收方缓冲区空余多,窗口可以变大
  • 接收方处理不过来,窗口会变小
  • 若窗口为 0,发送方需要暂停继续发送

所以,滑动窗口既是效率机制,也是流量控制的基础机制。


八. 流量控制:让发送方不要把接收方"压垮" 🧱

流量控制关注的不是"网络是否堵了",而是:

接收方当前还能不能处理得过来。

8.1 接收窗口是接收方驱动的

接收方会在报头的窗口字段里告诉发送方:自己当前还有多少接收能力。发送方根据这个值,动态决定还能继续发多少数据。

因此,流量控制是明显的"接收方驱动"机制。

8.2 为什么需要它

如果发送方持续无节制地发数据,而接收方的接收缓冲区已经接近满了,那么即使链路本身没问题,也可能导致数据丢失或大量重传,得不偿失。

8.3 延迟应答如何帮助流量控制更合理

笔记中举了一个很典型的例子:假设接收端缓冲区是 1M,刚收到 500K 数据。

  • 如果立刻应答,可能只能回一个较小窗口
  • 如果稍微等一会儿,接收方可能已经处理掉这 500K,就能回一个更大的窗口

所以,延迟应答的意义之一,就是争取更多时间整理状态,一次确认更多内容,同时更充分地反映接收能力。


九. 拥塞控制:别把网络本身"打爆" 🔍

流量控制关心的是接收方,拥塞控制关心的是整个网络。

9.1 什么叫网络拥塞

当网络中出现大面积丢包时,发送方通常会判断:问题不只是对端处理不过来,而是路径中的路由、交换、链路等某处已经开始承受不住。

这时候如果还继续提速,只会让情况更糟,所以 TCP 需要主动降速。

9.2 实际发送窗口取谁

笔记里给出的结论非常重要:

实际滑动窗口大小取 min(拥塞窗口, 对方窗口)

也就是说,真正能发多少,既要看接收方接受能力,也要看当前网络状况。

9.3 慢开始与拥塞避免

TCP 不会一上来就把速率拉满,而是采用一套渐进探测策略:

  • 慢开始:初期窗口按指数规律增长,快速探测网络承载能力
  • 拥塞避免:超过阈值后改成线性增长,避免增长过快
  • 乘法减小:一旦检测到拥塞,降低阈值并重置窗口,重新探测

这套策略的本质就是:前期积极利用带宽,中后期克制地维持稳定。


十. 快速重传、延迟应答、捎带应答:可靠性之外的性能优化 💻

TCP 的可靠性机制并不意味着它只会"傻等超时"。为了兼顾效率,它还会叠加一些优化手段。

10.1 快速重传

如果发送方连续收到多个相同确认序号,就说明某段数据很可能丢了。此时无需死等超时,可以提前触发重传。

这种方式相比单纯靠超时更灵敏,也更高效。

10.2 延迟应答

接收方并不一定每来一段数据就立刻回 ACK。适当延后确认,可以:

  • 一次确认更多内容
  • 让窗口更新更充分
  • 减少网络中纯确认报文数量

10.3 捎带应答

由于 TCP 是全双工的,如果接收方正好也有数据要发给发送方,那么就可以把确认信息顺手带在自己的数据报文里发出去。

这样既完成了确认,又完成了自己的数据发送,能进一步减少报文开销。


十一. TCP 的连接管理:三次握手、四次挥手 🗺️

TCP 之所以被称为面向连接,不是说它"很抽象地知道双方连上了",而是它真的通过明确的状态转换过程去建立和释放连接。

11.1 为什么是三次握手

三次握手的本质目的是:以尽量低的成本确认双方通信能力与状态一致性。

笔记中提到的理解很关键:

  • 一次握手成本低,但问题太多,容易被滥用
  • 两次握手不足以确认双方对状态的认知完全一致
  • 三次握手可以在较低成本下确认双方都具备收发能力,并完成初始序号等必要协商

从过程上看,它可以理解成:

  1. 客户端发 SYN
  2. 服务端回 SYN + ACK
  3. 客户端再回 ACK

11.2 为什么四次挥手不能简单压成三次

连接断开不是"一句话宣布结束"就够了,因为 TCP 是全双工的。一个方向不再发送数据,不代表另一个方向也已经准备好停止。

所以,断开实际上是两个单向关闭过程的叠加:

  • 我告诉你:我这边不再发了
  • 你告诉我:你那边也不再发了

这就形成了经典的四次挥手过程。

11.3 连接释放中的关键状态

笔记中特别强调了几个典型状态:

  • FIN_WAIT_1
  • FIN_WAIT_2
  • TIME_WAIT
  • CLOSE_WAIT
  • LAST_ACK

这些状态反映的并不是"协议故意复杂",而是连接两端各自对"我是否还要继续发数据"的不同阶段判断。


十二. TIME_WAITCLOSE_WAITshutdown:断连后的高频坑点 ⚠️

12.1 CLOSE_WAIT 为什么会拖出文件描述符泄漏

如果客户端已经关闭连接,但服务端迟迟不调用关闭逻辑,那么服务端就可能长时间停留在 CLOSE_WAIT 状态。

这意味着应用层并没有把对应连接善后完成,久而久之就可能导致文件描述符泄漏,占用服务器资源。

12.2 TIME_WAIT 为什么必须存在

主动关闭连接的一方会进入 TIME_WAIT。它存在的意义,不只是"礼貌地等一下",而是为了确保:

  • 旧连接中可能延迟到达的历史报文彻底消散
  • 不会污染后续使用相同端口建立的新连接

它的持续时间通常是 2MSL,本质上是在为网络中的旧报文"留出自然过期时间"。

12.3 为什么服务端重启可能提示地址已被占用

如果服务端主动关闭连接后还处于 TIME_WAIT,那么端口在短时间内仍可能无法立即重新绑定。这也是很多人重启服务器时遇到 "Address already in use" 的根源之一。

12.4 SO_REUSEADDR / SO_REUSEPORT 的作用

为减少因端口未完全释放导致的绑定失败,常见做法是:

cpp 复制代码
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

这样可以让地址和端口复用更灵活,降低重启服务时因 TIME_WAIT 残留造成的影响。

12.5 shutdownclose 的区别

shutdown() 用于按方向关闭 套接字的读或写功能,例如 SHUT_WR 表示关闭写通道,不再发送数据,但仍可接收。

cpp 复制代码
#include <sys/socket.h>
int shutdown(int sockfd, int how);

close() 是对文件描述符的整体关闭,引用计数归零后才会真正释放底层资源。


十三. URGPSHRST 这些标志位到底在表达什么 🧩

13.1 URG

URG 置位时,表示报文中带有紧急数据。紧急数据范围由"序号 + 紧急指针"共同确定。

从实践角度看,它更多是提供一种特殊处理语义,而不是日常业务传输的主流手段。

13.2 PSH

PSH 的作用更偏向"优化交付时效性"。它要求接收方尽快把当前数据推送给上层应用,而不是继续在缓冲区里等待更多数据聚合后再交付。

这类语义适合对实时响应要求更高的场景,例如指令输入等。

13.3 RST

RST 表示重置连接,通常用于表明当前连接状态异常或不同步,需要直接重建。

笔记中举的例子很典型:如果三次握手最后一次 ACK 丢失,客户端和服务端对连接是否已经建立成功的认知可能不一致。此时一旦客户端继续发数据,服务端就可能通过 RST 告诉对方:当前状态不对,请重新建立连接。


十四. 连接队列、accept 与内核数据结构:传输层如何真正落地 🧱

应用层通常只会看到几个系统调用,例如 socketlistenacceptsendrecv,但在内核里,真正支撑这些行为的是一整套数据结构。

14.1 accept 为什么返回的是文件描述符

因为在 Linux 里,网络连接最终仍然被统一纳入"文件对象"体系管理。accept() 返回的新连接,本质上就是一个可被进程通过文件描述符表继续引用和操作的内核对象。

14.2 全连接队列是什么

底层 TCP 会维护全连接队列,用来存放已经完成三次握手、但应用层尚未 accept() 取走的连接请求。

笔记中强调,这个队列的容量与 listen 的第二个参数 backlog 有关。它不能无限大,否则会浪费资源;也不能设置得过小,否则在高并发连接到来时可能丢请求。

14.3 进程、文件描述符与 socket 的关系

从内核对象角度看,笔记梳理出了一条很重要的链路:

  • task_struct
  • files_struct
  • file
  • socket
  • sock
  • 更具体的 inet_sock / tcp_sock
  • sk_buff

这条链路说明:网络连接并不是悬空存在的,而是被操作系统通过统一对象模型管理起来,再由缓冲区、队列、定时器和状态机去支撑后续的收发、重传、流控和连接管理。


十五. 传输层知识可以怎样整体串起来 📌

如果把整份笔记压缩成一条主线,可以这样理解:

  • UDP 解决的是简单、直接、有边界的数据报传输
  • TCP 解决的是可靠、有序、可流控、可管理的字节流传输
  • 报头字段负责表达控制语义
  • 缓冲区和 sk_buff 负责承载数据
  • 序号、确认、重传负责可靠性
  • 滑动窗口、延迟应答、捎带应答负责效率
  • 接收窗口负责流量控制
  • 拥塞窗口负责网络适配
  • 三次握手、四次挥手、TIME_WAITCLOSE_WAIT 负责连接生命周期管理

总结 📝

传输层真正解决的,从来都不只是"把数据从一台机器送到另一台机器"这么简单,而是要在应用程序真正能使用的层面,把通信过程组织成可寻址、可解析、可确认、可恢复、可控速、可关闭的一整套机制。

其中,UDP 提供了最轻量的数据报能力:结构简单、边界明确、代价低,但很多复杂问题需要应用层自己承担。TCP 则在此基础上增加了连接、序号、确认、窗口、重传、流控和拥塞控制等机制,用更高的复杂度换来更强的可靠性和更稳定的传输语义。

所以,学习传输层时最重要的不是孤立去记某一个字段或某一种状态,而是建立这样一条主线:应用层把数据交给传输层,传输层通过报头、缓冲区、状态机和控制算法,最终把数据组织成可以正确送达、正确恢复、正确关闭的通信过程。

当这条主线建立起来之后,后面再看 socket 编程、HTTP/HTTPS、高并发服务器模型,甚至 Linux 内核中的网络数据结构时,就不会觉得它们是分散知识点,而会自然落到同一套传输语义框架之中。

相关推荐
水月天涯2 小时前
Mac系统下制作 Ubuntu镜像(小白教程)
linux·ubuntu·macos
Yupureki2 小时前
《Linux网络编程》1.网络基础
linux·运维·服务器·c语言·网络·c++
爱学习的小囧2 小时前
ESXi 重置密码详细攻略(全场景覆盖)
服务器·esxi·vmware·虚拟化
ii_best2 小时前
自动化开发软件[按键精灵] 安卓/iOS脚本,变量作用域细节介绍
android·运维·ios·自动化
kongba0072 小时前
复刻 Claude Code 项目御马术缰绳系统 harness engineering 落地蓝图
java·linux·服务器
m0_694845572 小时前
marimo搭建教程:替代Jupyter的交互式开发工具
服务器·ide·python·docker·jupyter·github
mhkxbq2 小时前
济南H3C服务器升级方案怎么选?R4700G5等多型号来解答
运维·服务器
超级小的大杯柠檬水2 小时前
docker
运维·docker·容器
RisunJan2 小时前
Linux命令-mysqldump(MySQL数据库中备份工具)
linux·数据库·mysql