﻿# 零拷贝(Zero-Copy)—— 发数据时别让 CPU 白搬

> 高并发服务器大量做的一件事是"把数据从磁盘/内存发到网络"(静态文件、视频、日志、消息)。传统 `read()+write()` 这条路，数据被 CPU 来回拷贝好几次、用户态内核态来回切换好几次——纯属浪费。**零拷贝**就是把这些无谓的拷贝和切换省掉，让单机扛更多连接、更高吞吐。


> 本篇讲：传统发文件到底拷了几次、切了几次，每次拷贝和切换的物理代价有多大；mmap / sendfile / splice / SG-DMA 各自省掉哪一段、怎么做到的、为什么省了就有量级差异；以及零拷贝的适用边界与 kTLS 等演进方向。

## 一、传统 read + write 发文件：4 次拷贝、4 次切换

### 1.1 流程与时序

把一个文件通过 socket 发出去，最朴素的写法是 `read(file)` 到用户缓冲区、再 `write(socket)`：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "磁盘\n控制器" as DISK
participant "内核\npage cache" as PC
participant "用户缓冲区\n(堆/栈)" as USER
participant "内核\nsocket 缓冲区" as SOCK
participant "网卡\nDMA 引擎" as NIC
== read() 阶段 ==
DISK -> PC   : ① DMA 拷贝\n磁盘控制器→page cache\n(CPU 不参与数据搬运)
note right: DMA 期间 CPU 可以做别的事\n传输完成后 DMA 控制器发中断通知
PC   -> USER : ② CPU 拷贝\npage cache→用户缓冲区\n(CPU 执行 memcpy 逐字节搬运)
note right #FFCDD2: read() 返回，切回用户态\n上下文切换第 2 次
== write() 阶段 ==
USER -> SOCK : ③ CPU 拷贝\n用户缓冲区→socket 缓冲区\n(CPU 再次逐字节搬运)
note right #FFCDD2: write() 返回，切回用户态\n上下文切换第 4 次
SOCK -> NIC  : ④ DMA 拷贝\nsocket 缓冲区→网卡 FIFO\n(网卡自己取，CPU 不参与)
note over DISK, NIC #FFCDD2
  <b>开销总结</b>
  共 4 次数据拷贝：2 次 DMA(①④) + <b>2 次 CPU 拷贝(②③)</b>
  共 4 次上下文切换：read(用户→内核→用户) + write(用户→内核→用户)
  共 2 次系统调用：read() + write()
  用户态代码流程：open() → read() → write() → close()
end note
@enduml
```

### 1.2 每次拷贝和切换的物理代价

**两次 CPU 拷贝为什么"纯浪费"？**

数据只是"路过"用户空间——应用既没修改也没检查数据内容，CPU 却要执行两次全量 `memcpy`。以发一个 1MB 文件为例：

| 操作 | 代价分析 |
|------|---------|
| CPU 拷贝② page cache→用户 | 1MB memcpy，约 0.5-1μs（视 CPU 频率和 cache 命中），污染 L1/L2 cache |
| CPU 拷贝③ 用户→socket | 又 1MB memcpy，再次污染 cache |
| 两次拷贝合计 | **CPU 时间 ≈ 1-2μs，cache 被污染两次**——本来可以留给业务逻辑的 L1/L2 被拷贝的数据冲掉 |

这里的核心矛盾：**CPU 拷贝不仅占时间，更"隐性"地冲掉了 cache 里业务逻辑需要的热数据**（见 [../cache/cache-organization.md](/concepts/cache/cache-organization.md)）。高并发下这个效应被放大——每连接都做两次无谓拷贝，CPU cache miss 率飙升。

**四次上下文切换的代价：**

| 切换节点 | 发生了什么 |
|---------|-----------|
| read() 进入内核 | 保存用户态寄存器/栈指针 → 加载内核栈 → 切换页表(可能)→ 权限提升(Ring 3→0) |
| read() 返回用户 | 反向操作，恢复用户态上下文 |
| write() 进入内核 | 同上 |
| write() 返回用户 | 同上 |

每次切换的硬开销约 **1-2μs**（保存/恢复寄存器 + TLB 刷新），此外还有软开销：切换后 L1/L2 cache 是冷的，分支预测器要重新学习（见 [../process/context-switch.md](/concepts/process/context-switch.md)）。四次切换 = **4-8μs 纯开销 + 不可见的 cache 预热代价**。

**DMA 拷贝为什么"免不了"但也值得优化？**

磁盘→page cache 和 socket→网卡 这两次 DMA 是硬件直接读写内存，不经过 CPU 数据通路。但 DMA 仍然占用**内存带宽**——如果高并发下大量 DMA 并发，内存总线会成为瓶颈。SG-DMA（§2.3）通过减少 DMA 的趟数来缓解这个问题。

**一句话：传统 read+write 的 CPU 和 cache 全浪费在"搬运"上，而这些资源本该用于处理更多请求。**

## 二、零拷贝技术：逐级把拷贝和切换省掉

### 2.1 mmap + write：省掉"内核→用户"那次拷贝

**原理：** 用 `mmap()` 把文件映射到用户虚拟地址空间，建立用户虚拟地址→page cache 物理页的直接映射。`write()` 时直接从映射区把数据写入 socket，**不再需要"先读到用户缓冲区"这步**。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "磁盘\n控制器" as DISK
participant "内核\npage cache\n(用户 mmap\n可直读)" as PC
participant "用户\n地址空间\n(mmap 映射区)" as USER
participant "内核\nsocket 缓冲区" as SOCK
participant "网卡\nDMA 引擎" as NIC
== mmap() 建立映射 ==
USER <-> PC : mmap 建立虚拟→物理映射\n(page table 条目指向 page cache 物理页)
note right: mmap 只是建映射，不拷贝数据\n缺页时才触发磁盘 DMA
== 首次访问触发缺页 ==
DISK -> PC : ① DMA 拷贝\n磁盘→page cache\n(缺页中断触发，仅首次)
== write() 阶段 ==
PC   -> SOCK : ② CPU 拷贝\npage cache→socket 缓冲区\n(CPU 直接从 page cache 读)
note right #FFF9C4: 省掉了 page cache→用户缓冲区的拷贝\n但仍然需要 CPU 拷到 socket 缓冲区
SOCK -> NIC : ③ DMA 拷贝\nsocket 缓冲区→网卡
note over DISK, NIC #FFF9C4
  <b>mmap+write 开销</b>
  3 次拷贝(1 CPU + 2 DMA)，比传统少 1 次 CPU 拷贝
  但上下文切换仍是 4 次(mmap+write 两次调用)
  且 mmap 首次访问有缺页开销
end note
@enduml
```

**代码示例：**

```c
// mmap + write 发文件
int fd = open("file.dat", O_RDONLY);
struct stat st;
fstat(fd, &st);
// 映射整个文件到用户地址空间——page cache 成为用户可直接读的内存
char *mapped = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
int sock = socket(AF_INET, SOCK_STREAM, 0);
// ... connect ...
// write 时从 mmap 映射区读——CPU 从 page cache 直接拷到 socket 缓冲区
write(sock, mapped, st.st_size);   // 只有 1 次 CPU 拷贝(page cache→socket)
munmap(mapped, st.st_size);
close(fd);
```

**深入分析：**

| 方面 | 详情 |
|------|------|
| 省了什么 | ②号 CPU 拷贝（page cache→用户缓冲区）没了——用户通过虚拟地址直接看到 page cache |
| 没省什么 | ③号 CPU 拷贝（page cache→socket 缓冲区）还在——`write()` 的协议栈必须把数据拷到 socket 缓冲区才能做 TCP 分段/校验和 |
| 上下文切换 | 仍然是 2 次系统调用（mmap + write）= 4 次切换——mmap 省了拷贝但没省切换 |
| 额外代价 | mmap 建立页表映射有开销（修改页表 + TLB 刷新）；首次访问触发缺页中断 |
| 适用场景 | 需要**顺便读取/小改数据**的场景——比如在转发前看一眼文件头做路由决策 |

### 2.2 sendfile：用户态完全不碰数据

**原理：** `sendfile(out_fd, in_fd, offset, count)` 让内核**在内部**直接从 page cache 把数据灌进 socket 协议栈——数据全程不进入用户地址空间，一次系统调用完成全部传输。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "磁盘\n控制器" as DISK
participant "内核\npage cache" as PC
participant "用户态\n(只发指令)" as USER
participant "内核\nsocket 缓冲区" as SOCK
participant "网卡\nDMA 引擎" as NIC
== sendfile() 一次调用 ==
USER -> PC : sendfile(sock_fd, file_fd, &off, count)\n用户→内核(第 1 次切换)
note right: 一次系统调用完成全部传输\n数据全程不进用户空间
DISK -> PC : ① DMA 拷贝\n磁盘→page cache\n(如已在 cache 则跳过)
PC   -> SOCK : ② CPU 拷贝\npage cache→socket 缓冲区\n(内核内部 memcpy)
note right #FFF9C4: 内核态的 CPU 拷贝\npage cache→socket 缓冲区\n仅此一次，不经用户空间
SOCK -> NIC : ③ DMA 拷贝\nsocket 缓冲区→网卡
USER <- PC  : sendfile() 返回\n内核→用户(第 2 次切换)
note over DISK, NIC #FFF9C4
  <b>sendfile 开销</b>
  3 次拷贝(1 CPU + 2 DMA)
  <b>仅 2 次上下文切换</b> ← 关键改进
  仅 1 次系统调用
  数据全程在内核态流动，不污染用户态 cache
end note
@enduml
```

**代码示例：**

```c
// sendfile：内核内部搬运，用户态零触碰
int file_fd = open("file.dat", O_RDONLY);
int sock_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... connect/accept ...
off_t offset = 0;
ssize_t sent = sendfile(sock_fd, file_fd, &offset, st.st_size);
// 数据：page cache → socket 缓冲区 → 网卡
// 用户态：没看到数据的一个字节
close(file_fd);
```

**深入分析：**

| 方面 | 详情 |
|------|------|
| 省了什么 | 用户态完全不参与数据流动——没有用户缓冲区，没有"拷进/拷出用户空间"的步骤 |
| 切换降为 2 次 | 只有 sendfile() 进入内核和返回用户两次——比 read+write 少了一半 |
| Cache 收益 | 数据不经过用户空间 = 不污染用户态的 L1/L2 cache。用户态业务逻辑的热数据保持在 cache 里 |
| 仍然有 1 次 CPU 拷贝 | page cache→socket 缓冲区仍然需要内核态 memcpy——因为 socket 缓冲区是独立的 `sk_buff` 结构，需要做 TCP 分段 |
| 为什么必须拷到 socket 缓冲区 | TCP 协议栈需要独立的 `sk_buff` 链表来管理发送窗口、重传队列、TSO/GSO 分段——page cache 的页不能被 TCP 栈直接"接管" |

**实际应用：**

- **Nginx**：`sendfile on;` 启用后，静态文件直接走 sendfile，CPU 占用大幅下降
- **Kafka**：`FileRecords.writeTo()` 底层用 `FileChannel.transferTo()` → 最终调 sendfile，消息段零拷贝发送
- **Tomcat/Spring Boot**：可通过 `TransferTo` 利用 sendfile 发送静态资源

### 2.3 sendfile + SG-DMA：真正的零 CPU 拷贝

**原理：** 网卡支持 **Scatter-Gather DMA** 时，内核不再把 page cache 数据**拷到** socket 缓冲区，而是只构造一个**描述符列表**（scatter-gather list）告诉网卡"数据在 page cache 的这些物理地址、这些长度"，网卡 DMA 引擎自己去收集（gather）并发送。CPU 完全不做数据搬运。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "磁盘\n控制器" as DISK
participant "内核\npage cache" as PC
participant "用户态\n(只发指令)" as USER
participant "内核\nsocket\n(sk_buff 只存\n描述符,不存数据)" as SOCK
participant "网卡\nSG-DMA 引擎" as NIC
== sendfile() + SG-DMA ==
USER -> PC  : sendfile(sock_fd, file_fd, &off, count)
DISK -> PC  : ① DMA 拷贝\n磁盘→page cache\n(如已在 cache 则跳过)
PC   -> SOCK : 内核只传递<b>页描述符</b>\n{物理地址, 长度, 偏移}\n不拷贝数据本身!
note right #C8E6C9: <b>零 CPU 拷贝！</b>\n只传几十字节的元数据\n不需要 memcpy 整个数据块
SOCK -> NIC : ② SG-DMA 收集\n网卡根据描述符列表\n从 page cache 直接 DMA 取走数据
note right #C8E6C9: 网卡自己从内存不同位置\n收集(gather)数据片段\nCPU 完全不参与数据通路
USER <- PC  : sendfile() 返回
note over DISK, NIC #C8E6C9
  <b>sendfile + SG-DMA 开销</b>
  <b>2 次 DMA 拷贝，0 次 CPU 拷贝</b>
  2 次上下文切换，1 次系统调用
  这才是名副其实的"零拷贝"
end note
@enduml
```

**Scatter-Gather 描述符示例：**

```bash
sendfile 发送一个文件，它在 page cache 中可能不连续：
物理页 A: 0x12345000 (offset 0,    len 4096)  ← 文件第 0-4095 字节
物理页 B: 0x87654000 (offset 4096, len 4096)  ← 文件第 4096-8191 字节
物理页 C: 0xAABB3000 (offset 8192, len 2048)  ← 文件第 8192-10239 字节
内核构造的 sk_buff 不包含数据副本，只包含：
  frags[0] = { page: A, offset: 0,    size: 4096 }
  frags[1] = { page: B, offset: 4096, size: 4096 }
  frags[2] = { page: C, offset: 8192, size: 2048 }
网卡 SG-DMA 引擎遍历 frags[]，从这三个物理地址分别取数据
在硬件层拼接成以太网帧发出——CPU 全程零参与
```

**代码层面**：用户代码和普通 sendfile 完全一样，内核在 `tcp_sendmsg` 路径中检测网卡是否支持 `NETIF_F_SG`，支持则走 scatter-gather 路径（只传页引用，不拷贝）。

**深入分析：**

| 方面 | 详情 |
|------|------|
| 关键硬件依赖 | 网卡必须支持 scatter-gather（`ethtool -k eth0 | grep scatter-gather`），几乎所有现代网卡都支持 |
| CPU 做了什么 | 只构造描述符列表（几十到几百字节的元数据），不碰数据本身 |
| 为什么 socket 缓冲区还能工作 | `sk_buff` 结构有 `frags[]` 数组，每个 frag 指向一个 page cache 页 + 偏移 + 长度——不需要把数据拷到线性缓冲区里 |
| 内存带宽节省 | 少了 1 次 memcpy 对内存总线的占用。10Gbps 网卡满速时，1 次 memcpy = 额外 10Gbps 内存带宽消耗 |
| 与 TSO/GSO 的关系 | SG-DMA 与 TSO（TCP Segmentation Offload）配合：内核把大块数据描述符给网卡，网卡自己做 TCP 分段——CPU 既不用拷贝也不用分段 |

**这是现代 Linux sendfile 的实际行为**——只要网卡支持 SG，`sendfile()` 自动走这条路径。

### 2.4 splice：通过管道在两个 fd 间零拷贝

**原理：** `splice()` 借一个**管道**作中转，在两个文件描述符之间移动数据。关键：管道不存数据副本，只存**页引用**——内核把源 fd 的 page cache 页"接到"管道上，目标 fd 再从管道取走这些页引用。整个过程是页所有权的转移，而非数据拷贝。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "用户态\n(发指令)" as USER
participant "源 fd\npage cache\n(socket/文件)" as SRC
participant "管道 pipe\n(只存页引用\n不存数据)" as PIPE
participant "目标 fd\nsocket 缓冲区\n(只存引用)" as DST
participant "网卡\nDMA 引擎" as NIC
== splice(源→管道) ==
USER -> SRC  : splice(src_fd, &off, pipe_fd[1], NULL, len, SPLICE_F_MOVE)
note right: 源 fd→管道：内核把 page cache 的\n物理页引用"嫁接"到管道缓冲区\n不拷贝数据，只转移页所有权
SRC  -> PIPE : 页引用转移\n{page, offset, len}\n管道获得这些页的"使用权"
== splice(管道→目标) ==
USER -> PIPE : splice(pipe_fd[0], NULL, dst_fd, NULL, len, SPLICE_F_MOVE)
note right: 管道→目标 fd：目标 fd 的\nsk_buff 直接接管这些页引用\n同样不拷贝
PIPE -> DST  : 页引用再次转移\nsk_buff->frags[] = {page, offset, len}
DST  -> NIC  : DMA 拷贝\n网卡从 page cache 直接取数据
note right #C8E6C9: CPU 全程零数据拷贝！
note over SRC, NIC #C8E6C9
  <b>splice 开销</b>
  0 次 CPU 拷贝，仅 DMA 拷贝
  页引用在三个对象间传递，数据原位不动
  <b>两个 fd 可以都是 socket</b> ← sendfile 做不到的
end note
@enduml
```

**代码示例——socket→socket 代理转发：**

```c
// splice 实现零拷贝代理：client_fd → server_fd
int pipefd[2];
pipe(pipefd);  // 创建管道，内核分配 pipe_buffer 环形队列
while (1) {
    // 第一步：客户端数据 → 管道（页引用，非数据拷贝）
    ssize_t n = splice(client_fd, NULL, pipefd[1], NULL, 4096,
                       SPLICE_F_MOVE | SPLICE_F_MORE);
    if (n <= 0) break;
    // 第二步：管道 → 服务端（页引用再次转移）
    splice(pipefd[0], NULL, server_fd, NULL, n,
           SPLICE_F_MOVE | SPLICE_F_MORE);
    // 数据自始至终在 page cache 的同一组物理页上
    // 只是在 client sk_buff → pipe_buffer → server sk_buff 之间传递了所有权
}
```

**深入分析：**

| 方面 | 详情 |
|------|------|
| 核心机制 | `splice` 移动的是 `struct page *` 引用和页所有权——pipe_buffer 和 sk_buff 的 frags 都指向同一物理页 |
| 与 sendfile 的区别 | sendfile 要求一端必须是文件（page cache），splice 两端都可以是 socket——可以做 socket↔socket 零拷贝代理 |
| 为什么需要管道 | 内核需要"中间人"持有页引用，确保在两个 splice 调用之间物理页不被回收。管道就是这中间人 |
| 两次系统调用 | splice 每次只移一个方向（源→管道 或 管道→目标），做代理需要两次 splice = 2 次系统调用 |
| 管道大小限制 | 默认 pipe capacity 是 16 页(64KB)，可通过 `fcntl(fd, F_SETPIPE_SZ, size)` 调大 |
| SPLICE_F_MOVE | 提示内核"可以转移页所有权而非增加引用计数"，减少原子操作开销，但内核不保证一定 move |
| 适用场景 | 代理/网关（如 HAProxy 的 splice 模式）、文件拷贝（cp 命令）、日志管道转发 |

**splice 内核实现要点（简化）：**

```bash
splice_file_to_pipe(in, pipe):
  for each page in in->page_cache:
    pipe->bufs[i].page = page;      // 只传指针！
    pipe->bufs[i].offset = ...;
    pipe->bufs[i].len = ...;
    get_page(page);                  // 增加引用计数，防止被回收
  // 没有 memcpy
splice_pipe_to_socket(pipe, sock):
  for each buf in pipe->bufs:
    skb_fill_page_desc(skb, i, buf.page, buf.offset, buf.len);
    // sk_buff->frags[] 直接指向 pipe_buffer 的物理页
  // 同样没有 memcpy
```

### 2.5 五种方式全景对比

| 方式 | 总拷贝 | CPU 拷贝 | 上下文切换 | 系统调用 | 数据进用户态 | 适用场景 |
|------|:---:|:---:|:---:|:---:|:---:|------|
| **传统 read+write** | 4 | **2** | 4 | 2 | 是 | 需要读写处理数据 |
| **mmap + write** | 3 | 1 | 4 | 2 | 是(只读) | 需读取/小改数据再发 |
| **sendfile** | 3 | 1 | 2 | 1 | 否 | 纯文件转发(无 SG-DMA) |
| **sendfile + SG-DMA** | **2** | **0** | **2** | **1** | 否 | **文件→socket 最优解** |
| **splice** | 2 | 0 | 4 | 2 | 否 | socket↔socket 代理/任意两 fd |

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>     #C62828
  BackgroundColor<<mid>> #FFF9C4
  BorderColor<<mid>>     #F9A825
  BackgroundColor<<good>> #C8E6C9
  BorderColor<<good>>    #388E3C
}
rectangle "传统 read+write\n4 拷贝(2 CPU)+4 切换\n数据进用户态" <<bad>> as A
rectangle "mmap + write\n3 拷贝(1 CPU)+4 切换\n用户可直读 page cache" <<mid>> as B
rectangle "sendfile\n3 拷贝(1 CPU)+2 切换\n数据不进用户态" <<mid>> as C
rectangle "sendfile + SG-DMA\n2 拷贝(0 CPU)+2 切换\n网卡直接 DMA 收集" <<good>> as D
rectangle "splice\n2 拷贝(0 CPU)+4 切换\n任意两 fd 零拷贝" <<good>> as E
A -down-> B : mmap 省掉\n用户缓冲区拷贝
B -down-> C : sendfile 省掉\n用户态参与+切换
C -down-> D : SG-DMA 省掉\n最后一次 CPU 拷贝
D -right-> E : splice 解除\n"一端必须是文件"的限制
@enduml
```

**各方式的演进关系：** read+write → mmap(省一次 CPU 拷贝) → sendfile(省用户态参与) → sendfile+SG-DMA(省最后一次 CPU 拷贝) → splice(解除文件限制，通吃任意两 fd)

## 三、为什么零拷贝对高并发这么重要：量化分析

### 3.1 CPU 拷贝的实际开销

以 10Gbps 网卡满速发送 1MB 文件为例：

| 方式 | CPU 拷贝量 | CPU 时间(仅拷贝) | 说明 |
|------|:---:|:---:|------|
| read+write | 2MB/次 | ~2μs/次 | 10000 次/秒 = 20ms CPU/秒 = 2% CPU |
| sendfile | 1MB/次 | ~1μs/次 | 10000 次/秒 = 10ms CPU/秒 = 1% CPU |
| sendfile+SG-DMA | 0 | 0 | CPU 完全不参与数据搬运 |

看起来 2% vs 1% 差别不大？但真实高并发下：

- **并发连接数**：不是 1 条而是 10,000 条连接同时发文件
- **小文件效应**：大量小文件（4KB-64KB）时，系统调用开销占比远超拷贝本身
- **Cache 污染**：每次 CPU 拷贝把业务逻辑的热数据从 L1/L2 挤出去，miss 率上升 → 业务逻辑变慢

**实际生产数据（Nginx 静态文件 benchmark）：**

| 场景 | read+write | sendfile | sendfile+SG-DMA |
|------|:---:|:---:|:---:|
| 4KB 小文件 QPS | ~50K | ~80K (+60%) | ~120K (+140%) |
| 1MB 大文件吞吐 | ~8Gbps | ~9.5Gbps (+19%) | ~9.8Gbps (+23%) |
| CPU 占用(si%) | ~45% | ~20% | ~8% |

> 小文件场景收益更大——因为系统调用和上下文切换的固定开销在小文件场景占比更高，sendfile 一次调用替代两次调用直接砍半。

### 3.2 上下文切换的隐性代价

除了直接的寄存器保存/恢复（~1-2μs），上下文切换还会：

- **冲刷 TLB**：切换地址空间时 TLB 可能被刷新（见 [../cache/tlb.md](/concepts/cache/tlb.md)），后续内存访问大量 TLB miss
- **冲刷分支预测器**：切换进程/线程后，分支预测器记录的是前一个任务的分支模式，新任务需要重新"学习"
- **Cache 是冷的**：切换后的 L1/L2 cache 里全是前一个任务的数据

read+write 的 4 次切换 vs sendfile 的 2 次切换——在高并发（每秒百万次操作）下，这 2 次差异会被放大到 **数十毫秒/秒** 的 CPU 时间。

### 3.3 内存带宽瓶颈

DMA 和 CPU 拷贝共享同一根内存总线。以 DDR4-3200 双通道为例，理论带宽 ~51.2GB/s：

- 10Gbps 网卡满速：1.25GB/s 网络流量
- read+write 方式：额外 2.5GB/s 内存带宽被 CPU 拷贝吃掉（数据在内存里搬了两趟）
- sendfile+SG-DMA：只有 1.25GB/s 内存带宽用于 DMA——省下的 2.5GB/s 可以服务更多连接

当多个 25G/100G 网卡同时工作时，内存带宽可能比 CPU 更先成为瓶颈——零拷贝在这个维度同样关键。

### 3.4 实际应用

| 项目 | 使用的零拷贝技术 | 效果 |
|------|------|------|
| **Nginx** | `sendfile on` | 静态文件吞吐提升 60-140%，CPU 占用大幅下降 |
| **Kafka** | `FileChannel.transferTo()` → sendfile | 消息段直接从 page cache 发到网卡，生产者→消费者零拷贝路径 |
| **HAProxy** | splice（splice 模式） | 代理转发 CPU 占用极低，数据全程不离开内核 |
| **CDN 节点** | sendfile + SG-DMA | 大规模静态资源分发的基础 |
| **Netty** | `FileRegion.transferTo()` → sendfile | Java 生态的零拷贝文件传输 |

## 四、局限：零拷贝不是万能

### 4.1 核心前提：数据原样转发

零拷贝的前提是**应用不碰数据内容**。一旦需要修改/加密/压缩，就绕不开把数据拷进用户空间：

```bash
场景                          零拷贝可用？
──────────────────────────────────────────
静态文件/图片/视频发送           ✅ sendfile
HTTP 代理转发(不解析 body)       ✅ splice
Kafka 消息段落盘后发送           ✅ sendfile
日志文件直接发送                 ✅ sendfile
──────────────────────────────────────────
HTTPS/TLS 加密                  ❌ 数据需在用户态加密
gzip 压缩动态内容                ❌ 需 CPU 压缩数据
视频实时转码                    ❌ 需 CPU 解码/编码
模板渲染(HTML)                  ❌ 需拼接字符串
数据校验/签名                   ❌ 需计算 hash
在转发前修改 header/body         ❌ 需读写数据内容
```

### 4.2 TLS/HTTPS 与 kTLS

HTTPS 是零拷贝的最大"敌人"——TLS 加密必须在发送前完成，传统做法是：

```bash
文件 → read() → 用户态 buffer → TLS 加密 → write() → socket
                  ↑ 数据必须进用户态才能加密
```

**kTLS（Kernel TLS）** 是把 TLS 加密下沉到内核态：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "用户态\n(TLS 握手\n交密钥)" as USER
participant "内核\nkTLS 层" as KTLS
participant "page cache" as PC
participant "网卡\nSG-DMA" as NIC
== TLS 握手(用户态) ==
USER -> KTLS : setsockopt(TLS_TX, keys)\n用户态完成 TLS 握手\n把会话密钥交给内核
== 数据发送(内核态加密+零拷贝) ==
USER -> PC   : sendfile(fd, sock)
PC   -> KTLS : page cache 数据\n(页引用)
KTLS -> KTLS : 内核态 AES-GCM 加密\n(在 sk_buff 的页上原地或新页)
KTLS -> NIC  : 加密后的 sk_buff\n网卡 SG-DMA 直接取
note over PC, NIC #C8E6C9
  加密在内核完成，sendfile 仍然可用
  数据全程不进入用户空间
  拷贝次数 = 2 次 DMA(可能+1 CPU 加密时的新页)
end note
@enduml
```

kTLS 的关键点：

- 用户态只做 TLS 握手（一次性的），之后把会话密钥通过 `setsockopt()` 交给内核
- 内核在 sendfile/splice 路径上直接加密数据
- 配合支持 kTLS offload 的网卡（如 Mellanox ConnectX-5+），加密也可以 offload 到网卡硬件
- Linux 4.13+ 支持 kTLS（`CONFIG_TLS`），需要 OpenSSL 或 gnutls 配合

### 4.3 其他限制

| 限制 | 说明 |
|------|------|
| **sendfile 只能 file→socket** | sendfile 的 in_fd 必须是可 mmap 的（普通文件），out_fd 必须是 socket。不能 socket→file 或 socket→socket |
| **splice 至少一端是管道** | 两个 fd 之间必须通过管道中转，不能直接 fd↔fd |
| **不能做部分修改** | 哪怕只改一个字节，数据就得进用户空间 |
| **小数据可能更慢** | 对于极小数据（几十字节），建映射/管道描述符的开销可能超过直接 memcpy |

## 五、与仓库其他文档的关系

- **mmap 机制**：[../elf/mmap.md](/concepts/elf/mmap.md)（mmap 四象限、page cache 映射、缺页处理）
- **DMA 原理**：[../io/dma.md](/concepts/io/dma.md)（DMA 引擎如何读写内存、SG-DMA 原理、DDIO）
- **切换开销**：[../process/context-switch.md](/concepts/process/context-switch.md)（上下文切换的具体步骤和开销）、[../process/syscall.md](/concepts/process/syscall.md)（系统调用流程与开销）
- **Cache 污染**：[../cache/cache-organization.md](/concepts/cache/cache-organization.md)（L1/L2 cache 结构与替换策略）
- **高并发全局**：[overview.md](/concepts/network/overview.md)（零拷贝是内核层优化的一环）、[thread-models.md](/concepts/network/thread-models.md)（Reactor 线程模型）、[kernel-tuning-net.md](/concepts/network/kernel-tuning-net.md)（内核网络参数调优）
- **收包侧优化**：[../process/interrupts.md](/concepts/process/interrupts.md)（NAPI、GRO/GSO offload——和零拷贝同理：让 CPU 少碰数据）

## 六、一句话总结

> **传统 read+write 发文件要 4 次拷贝（2 次纯浪费的 CPU 拷贝）+ 4 次上下文切换，只因数据"路过"了用户空间。零拷贝逐级省掉这些：mmap 省掉内核→用户那次拷贝（但切换还在）；sendfile 让数据全程不进用户态（切换降到 2 次）；sendfile+SG-DMA 连最后一次 CPU 拷贝也由网卡 DMA 收集省掉（真正零 CPU 拷贝，Nginx/Kafka 基石）；splice 用管道做页引用中转，解除"一端必须是文件"的限制（HAProxy 代理场景）。它省 CPU、省内存带宽、省切换、保 cache 热度，让单机扛更多连接。但只适合"原样转发"——要加密/压缩/改数据就绕不开用户态拷贝，HTTPS 得靠 kTLS 把加密下沉到内核才能重新用上 sendfile。**

