前置知识: 计算机基础

零拷贝

8 min中级

零拷贝技术:sendfile、mmap、splice 的原理与性能对比。

前置知识

  • 用户态与内核态、系统调用的开销(见 中断与系统调用);
  • 文件读写经由页缓存(page cache)的事实(见 文件系统 inode);
  • DMA 的概念:设备与内存之间不经 CPU 搬运数据的机制。

学习目标

  • 数清”读文件并发送到网络”这条路径上发生了几次拷贝、几次上下文切换;
  • 理解 sendfile / mmap+write / splice 三条优化路线各自的拷贝次数与适用边界;
  • 知道 scatter-gather DMA 如何把 sendfile 压到”零 CPU 拷贝”;
  • 理解 Kafka、Nginx 等系统使用零拷贝的场景与限制。

1. 概念引入:快递为什么要倒五次手

一个类比:把一份文件从档案室(磁盘)寄给外地的收件人(网卡)。最朴素的流程是:档案员把文件抄到便签(内核缓冲区),转交前台(用户缓冲区),前台再抄回给收发室(socket 缓冲区),收发室最后交给快递车(网卡)。同一段内容被抄写了四次,且每一步都要”找人签字”(上下文切换)。

“零拷贝”并非绝对一次都不拷贝,而是指:数据不在内核与用户态之间来回搬运,CPU 不再为字节搬家买单——把拷贝留给 DMA,把 CPU 留给业务逻辑。

2. 传统路径:read + write 的四次拷贝

把磁盘文件发给网络套接字,最常见的写法:

char buf[4096];
while ((n = read(file_fd, buf, sizeof(buf))) > 0)   /* 系统调用 1 */
    write(sock_fd, buf, n);                          /* 系统调用 2 */

这条朴素路径的完整旅程:

flowchart LR
    D["磁盘"] -- "1. DMA 拷贝" --> K["内核页缓存"]
    K -- "2. CPU 拷贝" --> U["用户缓冲区 buf"]
    U -- "3. CPU 拷贝" --> S["socket 缓冲区"]
    S -- "4. DMA 拷贝" --> N["网卡"]
步骤由谁执行说明
1 磁盘 -> 页缓存DMA绕过 CPU
2 页缓存 -> 用户 bufCPUread 返回时搬运
3 用户 buf -> socket 缓冲区CPUwrite 时搬运
4 socket 缓冲区 -> 网卡DMA绕过 CPU

加上系统调用本身:read 一次用户态->内核态切换,返回一次;write 同理——共 4 次拷贝、4 次上下文切换。其中第 2、3 两次纯 CPU 拷贝对业务毫无意义(数据在用户态根本没被加工),发送 1GB 文件就要白白搬家 2GB 内存带宽。

3. 零拷贝的三条路线

3.1 mmap + write:共享映射省一次拷贝

mmap 把页缓存直接映射进用户地址空间,write 从映射区发送:

char *addr = mmap(NULL, len, PROT_READ, MAP_SHARED, file_fd, 0); /* 映射页缓存 */
write(sock_fd, addr, len);   /* 页缓存 -> socket 缓冲区,仅一次 CPU 拷贝 */
munmap(addr, len);

拷贝从 4 次降到 3 次(DMA、CPU、DMA),省去”页缓存 -> 用户 buf”的搬运;上下文切换仍是 4 次。代价与风险:

  • 映射大文件会增加页表与 TLB 压力(见 内存分段与分页);
  • 若发送过程中文件被截断,映射区访问触发 SIGBUS,需要信号处理或保持文件句柄稳定。

适合需要对数据做部分加工再发出的场景——mmap 保留了用户态直接读数据的能力,这是纯 sendfile 做不到的。

3.2 sendfile:一条系统调用直达网卡

Linux 2.1 引入 sendfile,把”读 + 写”合并为一次系统调用,且数据全程不出内核:

#include <sys/sendfile.h>
/* out_fd 为 socket,in_fd 为文件;数据在内核内直接流转 */
sendfile(sock_fd, file_fd, &offset, count);

拷贝降为 3 次(DMA、内核内 CPU 拷贝页缓存->socket 缓冲区、DMA),上下文切换降为 2 次。若网卡支持 **scatter-gather(SG-DMA)**并开启 ethtool -K eth0 sg on,内核只把”缓存描述符”(地址 + 长度)追加到 socket 缓冲区,由网卡 DMA 直接从页缓存取数——CPU 拷贝彻底消失:

方案CPU 拷贝DMA 拷贝上下文切换
read + write224
mmap + write124
sendfile122
sendfile + SG-DMA022

限制:sendfile 只做纯转发,out_fd 在经典用法下须是支持该功能的套接字(或设备),不能对 out_fd 做 fsync 之类的精细控制;发送途中无法修改数据(如加密),这也是 TLS 场景早期不能直接受益的原因。

3.3 splice:以管道为中介的零拷贝

splice 在任意两个文件描述符(其一必须是管道)之间移动数据,同样全程留在内核:

int pfd[2];
pipe(pfd);
splice(file_fd, &off, pfd[1], NULL, len, SPLICE_F_MOVE); /* 文件 -> 管道 */
splice(pfd[0], NULL, sock_fd, NULL, len, SPLICE_F_MOVE); /* 管道 -> socket */

它比 sendfile 通用(输入/输出可以是任意文件、socket、管道),nginx、HAProxy 等都曾用它实现文件到 socket 的零拷贝。代价是需要额外建管道、两次调用,编码复杂度更高。

4. 完整示例:观察并对比真实开销

/* sendfile_demo.c:传统 read/write 与 sendfile 发送同一文件 */
#define _GNU_SOURCE
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <time.h>

static long now_ns(void) {
    struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000000000L + ts.tv_nsec;
}

int main(void) {
    const char *path = "bigfile.bin";   /* 先 dd if=/dev/zero of=bigfile.bin bs=1M count=256 造文件 */
    struct stat st; stat(path, &st);
    off_t off = 0; char buf[1 << 16];
    int fd = open(path, O_RDONLY);

    /* 方式一:read + write,数据绕经用户态 */
    long t0 = now_ns();
    ssize_t n;
    while ((n = read(fd, buf, sizeof(buf))) > 0)
        write(STDOUT_FILENO, buf, n);   /* 此处以标准输出代替 socket 演示 */
    printf("read/write 耗时 %ld ms\n", (now_ns() - t0) / 1000000);

    /* 方式二:sendfile,数据全程留在内核 */
    lseek(fd, 0, SEEK_SET); off = 0;
    t0 = now_ns();
    while (sendfile(STDOUT_FILENO, fd, &off, st.st_size) > 0)
        ;
    printf("sendfile   耗时 %ld ms\n", (now_ns() - t0) / 1000000);
    close(fd);
    return 0;
}

用 strace -c 分别统计两种方式一次系统调用计数,可以看到 sendfile 路径的 syscall 次数与 copy 类调用显著减少。在真实 socket + 大文件压测下,sendfile 相比 read/write 的吞吐提升通常在 20%-80% 之间(取决于文件大小、缓存命中与网卡 SG-DMA 支持情况,不必迷信固定数字)。

5. 应用实例

5.1 Kafka

Kafka 消费者拉取消息是典型的”磁盘文件 -> 网络”纯转发。当消费者请求的数据与磁盘文件布局对齐(顺序段文件、无转换)时,broker 走 FileRecords.writeTo -> sendfile,CPU 几乎不参与数据搬运——这是 Kafka 顺序写 + 零拷贝两大招牌之一。注意:消息需要转换(如压缩、加密代理层介入)时该路径会退化为普通拷贝。

5.2 Nginx

nginx.conf 中 sendfile on; 使静态文件响应对应 socket 直送;配合 tcp_nopush 在发送前攒够一个段,减少小包。动态内容(代理、压缩)则天然用不上零拷贝,因为数据必须先被修改。

5.3 用不上的场景

  • 需要修改数据:加密、压缩、协议转换都会引入用户态加工,零拷贝的前提”原样转发”不成立。TLS 流量在内核 TLS(kTLS)出现前无法用 sendfile;Linux 4.17+ 的 kTLS 使 sendfile 重新可用于加密流。
  • 小文件、短连接:连接建立与 TLS 握手的开销远大于拷贝本身,优化方向应是连接复用。
  • 非 Linux 平台:Windows 对应 API 是 TransmitFile,macOS 是 sendfile(语义略异),代码不可直接移植。

6. 常见陷阱与调试

  • 把零拷贝当万能优化:瓶颈在磁盘随机 I/O 或锁竞争时,减少内存拷贝毫无感知;优化前先用 perf/火焰图确认拷贝确实占大头。
  • 混淆”零 CPU 拷贝”与”零数据移动”:SG-DMA 只是让 CPU 不搬字节,数据仍在内存与网卡间移动两次 DMA。
  • mmap 发送大文件的 SIGBUS:另一个进程/线程在发送期间截断文件,访问映射页即崩溃;要么持有文件不删除(如仅重命名),要么捕获 SIGBUS 做优雅降级。
  • Kafka 上没吃到零拷贝红利:检查是否开启了对数据做变换的配置(如 broker 端重压缩)、消费者是否跨分区乱序拉取导致小段随机读。
  • 验证手段:strace -e trace=sendfile,read,write -p <pid> 直接看进程是否真的走了 sendfile;sar -B/pidstat -d 观察内存带宽与块 I/O 变化。

7. 实战场景

  • 静态资源服务器 / 对象存储网关:文件原样下行是零拷贝的最佳主场,Nginx、Caddy 默认开启。
  • 消息队列与流处理:Kafka、RocketMQ 的存储层都围绕”顺序写 + 原样转发”设计,为的就是让 sendfile/page cache 生效。
  • 服务自身 I/O 库选型:io_uring 提供固定的零拷贝式读写原语,Netty 的 FileRegion 封装 sendfile、堆外内存(DirectBuffer)避免 JVM 内多余的堆拷贝,本质都是同一思想——减少每一程不必要的字节搬家。

小结

初学者要点:

  • 传统 read + write 发文件 = 4 次拷贝 + 4 次上下文切换,其中两次 CPU 拷贝对业务无意义。
  • 零拷贝的目标是让数据不出内核、CPU 不搬字节:sendfile 把系统调用合一(2 次切换),配合 SG-DMA 做到零 CPU 拷贝。
  • mmap + write 适合”要加工再发”的场景;splice 用管道中转,通用但代码更繁琐。

进阶注意:

  • 零拷贝的前提是原样转发;加密、压缩、改写都会破坏前提(kTLS、io_uring 是现代的破局方案)。
  • 实测优先:strace 确认路径、压测对比吞吐,数字随文件大小与硬件波动很大。
  • Kafka/Nginx 的性能神话背后是”顺序 I/O + 页缓存 + sendfile”的系统性设计,而不是某个单一开关。