一、零拷贝技术概述

什么是零拷贝

零拷贝,说白了就是让数据在传输过程中,减少甚至避免 CPU 参与数据搬来搬去

我刚开始接触这个概念时,也觉得有点反直觉——数据不拷贝,那怎么传?其实这里的「拷贝」指的是内核态和用户态之间的数据复制。零拷贝的目标,就是消除这种不必要的内存拷贝。

举个例子。你从磁盘读一个文件,然后通过网络发出去。传统做法是:磁盘 → 内核缓冲区 → 用户缓冲区 → 内核 Socket 缓冲区 → 网卡。数据被搬了四次,CPU 全程参与。零拷贝呢?磁盘 → 内核缓冲区 → 网卡,中间直接跳过用户态,CPU 只负责指挥,不负责搬运。

核心思想:让数据在操作系统内部完成流转,不经过应用程序的内存空间。

为什么交易系统需要零拷贝

做量化交易的都懂,延迟就是金钱

我在搭建高频交易系统时,遇到过这样一个场景:行情数据每秒进来几十万笔,每笔都要经过完整的 IO 路径。传统方式下,光是数据拷贝就占了 30% 以上的 CPU 时间。你想想看,CPU 都在忙着搬数据,哪还有精力做策略计算?

交易系统对零拷贝的需求,主要体现在三个层面:

  • 延迟敏感:每笔交易可能就差几微秒,多一次拷贝就多一次延迟
  • 吞吐量要求:行情数据、订单流都是海量数据,拷贝次数越多,瓶颈越明显
  • CPU 资源宝贵:CPU 应该用来跑策略、做风控,而不是当搬运工

我曾经优化过一个订单网关,把传统 IO 换成零拷贝后,单机吞吐量从 5 万笔/秒提升到了 18 万笔/秒。嗯,这个数字我记得很清楚。

传统 IO 与零拷贝的对比

直接看对比表,一目了然:

对比维度 传统 IO 零拷贝
数据拷贝次数 4 次 1-2 次
上下文切换次数 4 次(用户态↔内核态) 2 次
CPU 参与度 全程参与数据搬运 仅参与控制
延迟 高(微秒级) 低(纳秒级差异)
适用场景 通用场景 高性能场景

为什么会差这么多?传统 IO 每次读写都要在用户态和内核态之间切换,每次切换都有开销。零拷贝通过减少切换次数和拷贝次数,直接把延迟砍掉一大截。

我的经验:在交易系统中,哪怕只减少一次拷贝,也能带来 5-10 微秒的延迟改善。别小看这 5 微秒,在高频交易里,这可能是盈利和亏损的分界线。

零拷贝的三种核心实现机制

Linux 下主流的零拷贝实现有三种:mmap、sendfile、splice。我分别说说。

1. mmap(内存映射)

mmap 把文件直接映射到进程的地址空间。应用程序读写文件,就像读写内存一样。数据从磁盘到内核缓冲区后,直接映射到用户空间,省去了从内核到用户的一次拷贝。

// mmap 示例:读取文件并发送
int fd = open("data.bin", O_RDONLY);
struct stat sb;
fstat(fd, &sb);

// 映射文件到内存
void *addr = mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

// 直接发送映射的内存
send(sockfd, addr, sb.st_size, 0);

// 清理
munmap(addr, sb.st_size);
close(fd);

不过要注意,mmap 虽然减少了拷贝,但仍然有一次从内核到用户态的映射操作。而且映射大文件时,会占用大量虚拟地址空间。

避坑指南:我曾经在行情数据回放系统里用过 mmap,结果发现频繁的 page fault 导致延迟抖动。后来改用 sendfile 才稳定下来。mmap 适合大块数据的顺序读写,不适合随机小数据访问。

2. sendfile(零拷贝系统调用)

sendfile 是 Linux 专门为「文件到 Socket」传输设计的。它直接在内核空间完成数据搬运,用户态完全不沾手。

// sendfile 示例:文件直接发送到 Socket
int in_fd = open("data.bin", O_RDONLY);
int out_fd = sockfd;  // 已连接的 Socket

off_t offset = 0;
size_t count = 1024 * 1024;  // 1MB

// 直接在内核空间完成传输
ssize_t sent = sendfile(out_fd, in_fd, &offset, count);

sendfile 只需要两次上下文切换(一次进入内核,一次返回),数据拷贝只有一次(从磁盘到内核缓冲区)。这是目前最常用的零拷贝方案

我个人的习惯是:只要场景是「文件→网络」,优先用 sendfile。它简单、高效、稳定。

3. splice(管道式零拷贝)

splice 比 sendfile 更灵活。它可以在两个文件描述符之间移动数据,不限于文件到 Socket。底层通过管道机制实现,数据不经过用户空间。

// splice 示例:两个 Socket 之间直接转发数据
int pipefd[2];
pipe(pipefd);

// 从 sock_in 读到管道
ssize_t n = splice(sock_in, NULL, pipefd[1], NULL, 4096, SPLICE_F_MOVE);

// 从管道写到 sock_out
splice(pipefd[0], NULL, sock_out, NULL, n, SPLICE_F_MOVE);

splice 的优点是通用性强,可以在任意两个文件描述符之间传输。但缺点也很明显:需要额外维护一个管道,代码复杂度上去了。

三种机制的选择建议:

  • 文件→网络:优先 sendfile
  • 需要随机访问文件内容:用 mmap
  • 网络→网络或自定义场景:用 splice

三种机制的对比总结

机制 拷贝次数 上下文切换 适用场景 复杂度
mmap 2 次 2 次 文件随机访问、共享内存
sendfile 1 次 2 次 文件→网络传输
splice 1 次 2 次 任意 FD 间传输

零拷贝核心逻辑图

下面这张图展示了传统 IO 和零拷贝的路径对比:

传统 IO vs 零拷贝 数据路径对比 传统 IO(4次拷贝) 磁盘 内核缓冲区 用户缓冲区 Socket缓冲区 网卡 ① DMA拷贝 ② CPU拷贝 ③ CPU拷贝 ④ DMA拷贝 零拷贝(1-2次拷贝) 磁盘 内核缓冲区 Socket缓冲区 网卡 ① DMA拷贝 ② 无CPU拷贝(DMA直接传输) ③ DMA拷贝 用户态 内核态 零拷贝的核心:消除用户态与内核态之间的数据搬运 mmap:跳过用户缓冲区,直接映射内核缓冲区到用户空间 sendfile/splice:完全在内核空间完成数据传输

从图上可以清楚看到,传统 IO 的数据路径像走迷宫一样绕来绕去,而零拷贝就是一条直线。嗯,这就是性能差距的根源。

一句话总结:零拷贝不是真的「零」拷贝,而是「零次 CPU 参与的数据拷贝」。DMA 拷贝是硬件完成的,不算在内。

专注资料整理