零拷贝(Zero-Copy)是 Linux 性能优化里非常常见的一类技术。它的目标不是“绝对没有任何数据移动”,而是尽量减少数据在用户态和内核态之间的复制次数,降低上下文切换开销,并把更多数据搬运工作交给 DMA 或内核内部的数据引用机制完成。
在嵌入式 Linux、边缘网关、文件服务器、日志采集系统、流媒体转发程序中,这类优化尤其常见。原因很直接:CPU 性能、内存带宽和缓存容量往往都更紧张,减少一次不必要的内存复制,收益就可能是可观的。
本文从工程视角梳理零拷贝的核心原理,并结合 mmap、sendfile、splice 给出代码示例和选型建议。
1. 为什么传统 IO 路径开销大
先看一个最常见的场景:把文件内容发送到 socket。
如果应用层采用传统写法:
while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
write(sock_fd, buf, n);
}
这条路径通常会经历下面几个步骤:
- 应用调用
read(),从用户态切换到内核态。 - 磁盘控制器通过 DMA 把数据搬到内核页缓存(page cache)。
- CPU 再把数据从内核缓冲区复制到用户缓冲区。
read()返回,回到用户态。- 应用调用
write(),再次进入内核态。 - CPU 把数据从用户缓冲区复制到 socket 缓冲区。
- 网卡通过 DMA 把数据从 socket 缓冲区发送出去。
write()返回,回到用户态。
从代价上看,这条路径通常包含:
- 4 次上下文切换
- 4 次数据搬运
- 其中 2 次是 DMA 搬运
- 其中 2 次是 CPU 参与的内存复制
真正昂贵的部分往往不是系统调用本身,而是那两次 CPU 拷贝:一次从内核到用户,一次从用户到 socket。
2. 零拷贝到底在优化什么
零拷贝主要优化两件事:
- 减少用户态和内核态之间的数据复制
- 减少系统调用带来的上下文切换
因此,工程上说“零拷贝”,通常表达的是下面这层意思:
- 数据尽量停留在内核空间中流转
- 用户进程尽量不要显式参与中间缓冲
- 能用 DMA 完成的搬运尽量交给 DMA
- 能通过页映射、页引用、描述符传递完成的,就不要再做一次 CPU memcpy
需要注意的是,零拷贝并不总是意味着“0 次物理搬运”。例如从磁盘读数据到内存,本身就离不开 DMA。很多时候所谓零拷贝,指的是“避免用户态参与的那几次 CPU 拷贝”。
3. mmap:减少一次用户态拷贝
3.1 基本原理
mmap() 可以把文件映射到进程虚拟地址空间。映射建立后,用户态访问这段虚拟地址时,本质上是在访问页缓存对应的内存页。
这样一来,应用不需要先 read() 到用户缓冲区,再处理数据,而是直接“看到”内核页缓存中的内容。
当应用采用 mmap + write 把文件发到 socket 时,路径一般变成:
mmap()建立文件页缓存到用户虚拟地址的映射关系。- 访问映射区时,若页未命中,触发缺页异常,内核把文件页读入页缓存。
- 应用调用
write(sock_fd, mapped_addr, len)。 - 内核把映射区对应的数据复制到 socket 缓冲区。
- 网卡 DMA 把数据发出去。
和传统 read + write 相比,mmap 省掉了“内核缓冲区复制到用户缓冲区”这一步。
通常可以理解为:
- 上下文切换:仍然较多
- CPU 拷贝:从 2 次下降到 1 次
- 优势:减少一次数据复制,适合应用还需要读数据内容的场景
3.2 适合什么场景
mmap 更适合下面这类情况:
- 应用不仅要转发文件,还要解析内容
- 文件会被频繁随机访问
- 希望把文件访问接口简化成内存读写模型
它不适合所有“发文件到网络”的场景。因为如果应用根本不需要碰数据内容,只是想把文件原样发出去,那么 sendfile() 往往更直接。
3.3 mmap 示例:映射文件并发送到 socket
下面示例演示如何把文件映射到内存,再发送到 socket。示例重点是说明调用路径,不包含完整的网络建连逻辑。
#define _GNU_SOURCE
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <string.h>
static int send_with_mmap(int file_fd, int sock_fd)
{
struct stat st;
void *map = NULL;
off_t offset = 0;
if (fstat(file_fd, &st) < 0) {
perror("fstat");
return -1;
}
if (st.st_size == 0) {
return 0;
}
map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, file_fd, 0);
if (map == MAP_FAILED) {
perror("mmap");
return -1;
}
while (offset < st.st_size) {
size_t remaining = (size_t)(st.st_size - offset);
if (remaining > SSIZE_MAX) {
remaining = SSIZE_MAX;
}
ssize_t n = write(sock_fd, (char *)map + offset, remaining);
if (n < 0) {
if (errno == EINTR) {
continue;
}
perror("write");
munmap(map, st.st_size);
return -1;
}
if (n == 0) {
fprintf(stderr, "write returned 0 unexpectedly\n");
munmap(map, st.st_size);
return -1;
}
offset += n;
}
// 映射结束后释放地址空间。
if (munmap(map, st.st_size) < 0) {
perror("munmap");
return -1;
}
return 0;
}
3.4 这段代码要注意什么
mmap()建立的是映射关系,不是立即把整个文件读入内存- 真正访问到某个页时,才可能触发缺页并装载数据
write()仍然可能短写,所以必须循环发送- 对大文件做
mmap时,要关注虚拟地址空间和页表开销 - 如果文件会被别的线程或进程修改,要额外考虑一致性问题
4. sendfile:更适合“文件原样发送”
4.1 基本原理
sendfile() 的设计目标非常明确:直接把一个文件描述符中的数据发送到另一个文件描述符,典型场景就是“磁盘文件 -> socket”。
它最大的优势是:应用层不再需要准备用户缓冲区,也不需要执行 read() 和 write() 这一对系统调用。数据传输尽可能在内核空间内部完成。
典型路径如下:
- 应用调用
sendfile(out_fd, in_fd, ...) - 内核把文件数据从磁盘通过 DMA 读入页缓存
- 内核将页缓存数据组织到 socket 发送路径
- 网卡把数据发送出去
从应用视角看,系统调用由两次变成一次,上下文切换也明显减少。
4.2 sendfile 是否是真正零拷贝
这件事不能一概而论,要看内核实现和硬件能力。
- 在较理想的路径上,
sendfile可以避免用户态缓冲区参与,接近真正意义上的零拷贝 - 如果网卡和内核发送路径支持 scatter/gather,数据可以直接引用页缓存中的页,CPU 拷贝会进一步减少
- 如果底层条件不满足,内核仍可能在某些阶段做额外复制
因此更稳妥的说法是:
sendfile 的核心价值是绕过用户态中转,显著减少上下文切换和 CPU 复制;至于是否达到“严格意义上的 0 次 CPU 拷贝”,要看具体平台。
4.3 sendfile 示例:静态文件发送
下面是一个更接近实际工程的写法:
#define _GNU_SOURCE
#include <sys/sendfile.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
static int send_with_sendfile(int file_fd, int sock_fd)
{
struct stat st;
off_t offset = 0;
if (fstat(file_fd, &st) < 0) {
perror("fstat");
return -1;
}
while (offset < st.st_size) {
size_t remaining = (size_t)(st.st_size - offset);
if (remaining > SSIZE_MAX) {
remaining = SSIZE_MAX;
}
// sendfile 的第三个参数 offset 传入的是文件偏移量地址。
// 内核会从该偏移处开始发送,并在成功后自动更新它。
ssize_t n = sendfile(sock_fd, file_fd, &offset, remaining);
if (n < 0) {
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 非阻塞 socket 下应结合 epoll/poll 等待可写后重试。
continue;
}
perror("sendfile");
return -1;
}
if (n == 0) {
// 返回 0 通常意味着已经到达文件尾。
break;
}
}
return 0;
}
4.4 什么时候优先选 sendfile
优先考虑 sendfile 的场景通常是:
- HTTP 静态文件服务器
- 固件包下载服务
- 日志文件透传
- 媒体文件按原样推送
如果应用需要对数据做压缩、加密、协议重组、内容过滤,那么 sendfile 的收益就会下降,因为数据迟早还是要回到用户态参与处理。
5. splice:在内核对象之间搬运“引用”
5.1 基本原理
splice() 常被用于在文件描述符和管道之间移动数据。它的关键点不是把字节复制到用户空间,而是在内核里转移页引用或缓冲区描述信息。
常见用法不是直接“文件到 socket 一步到位”,而是分两段:
file -> pipepipe -> socket
这也是很多高性能转发程序会使用的方式。
5.2 splice 示例:文件经由 pipe 转发到 socket
#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <stdio.h>
static int send_with_splice(int file_fd, int sock_fd)
{
int pipefd[2];
if (pipe(pipefd) < 0) {
perror("pipe");
return -1;
}
for (;;) {
// 第一步:把文件数据“接入”管道。
// SPLICE_F_MOVE / SPLICE_F_MORE 是性能提示,不保证内核一定按提示执行。
ssize_t in = splice(file_fd, NULL, pipefd[1], NULL, 64 * 1024,
SPLICE_F_MOVE | SPLICE_F_MORE);
if (in < 0) {
if (errno == EINTR) {
continue;
}
perror("splice file->pipe");
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
if (in == 0) {
// 文件结束。
break;
}
ssize_t remaining = in;
while (remaining > 0) {
size_t chunk = (size_t)remaining;
if (chunk > SSIZE_MAX) {
chunk = SSIZE_MAX;
}
// 第二步:再把管道中的数据发送到 socket。
ssize_t out = splice(pipefd[0], NULL, sock_fd, NULL, chunk,
SPLICE_F_MOVE | SPLICE_F_MORE);
if (out < 0) {
if (errno == EINTR) {
continue;
}
perror("splice pipe->socket");
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
if (out == 0) {
fprintf(stderr, "splice returned 0 unexpectedly\n");
close(pipefd[0]);
close(pipefd[1]);
return -1;
}
remaining -= out;
}
}
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
5.3 splice 的特点
- 可以避免数据进入用户空间
- 适合做流式转发和管道化处理
- 比
sendfile更灵活,但代码复杂度也更高 - 是否达到严格意义上的“真正零拷贝”,依然受内核路径和底层设备能力影响
在工程上,splice 的价值往往不是“绝对更快”,而是“在不把数据拉回用户态的前提下,允许你把多个内核对象串起来”。
6. mmap、sendfile、splice 应该怎么选
可以按一个很实用的原则来判断:
6.1 应用需要不要“碰数据”
如果需要读、改、解析、过滤数据:
- 优先考虑
mmap - 或者常规
read/write
如果只想原样转发:
- 优先考虑
sendfile - 需要更复杂的内核态数据流编排时考虑
splice
6.2 一个简单的选型表
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 静态文件下载 | sendfile |
路径最短,应用层无需中转缓冲 |
| 文件内容解析 | mmap |
方便随机访问和按内存方式处理 |
| 管道化转发 | splice |
能在 pipe 和 socket 间高效流转 |
| 小数据包频繁处理 | 视情况而定 | 系统调用和页管理开销可能抵消收益 |
| 需要压缩/加密/重组 | 常规 IO 或 mmap |
数据终究要进入用户态处理 |
7. 零拷贝不是银弹
零拷贝能提升性能,但不是无条件成立,也不是所有项目都值得引入。
7.1 小数据场景未必更快
当数据量很小时:
mmap的映射建立和页管理有额外开销sendfile/splice也存在系统调用成本- 普通
read/write可能更简单,实际吞吐差距并不明显
7.2 硬件和驱动能力会影响效果
如果平台的 DMA、scatter/gather、网卡驱动能力有限,那么你在代码里写了“零拷贝接口”,最终路径也不一定是最理想的。
这在嵌入式平台上尤其常见。很多 SoC 的外设能力、驱动成熟度、内核裁剪情况都直接决定了优化上限。
7.3 可观测性和调试复杂度会上升
普通 read/write 的控制流很直观,日志也容易打。
而到了 mmap、sendfile、splice 这类路径:
- 问题可能出在页缓存
- 可能出在文件偏移
- 可能出在 socket 背压
- 也可能出在驱动的 DMA 路径
因此,性能优化和可维护性必须一起考虑。
8. 嵌入式 Linux 项目中的几个落地建议
如果你在做嵌入式软件,下面这些建议更有实际价值:
- 不要先追求“零拷贝”,先确认瓶颈是不是 IO 复制。
- 如果业务只是“文件原样下发”,优先尝试
sendfile。 - 如果业务要解析文件内容,例如固件头、元数据、索引块,优先考虑
mmap。 - 如果是网关转发、录播搬运、管道串接,评估
splice。 - 优化前后都要做压测,重点看 CPU 占用、吞吐、时延和系统抖动。
- 非阻塞 socket、
epoll、页缓存命中率、磁盘类型、网卡队列长度,都会影响最终收益。
9. 一个常见误区
很多文章会把零拷贝简单理解为“完全不拷贝数据”,这个表述并不准确。
更准确的理解应该是:
- 尽量避免用户态缓冲区成为中转站
- 尽量减少 CPU 执行
memcpy - 尽量把数据路径缩短到“页缓存 -> 网络发送路径”
所以零拷贝的本质,不是“神奇地消灭所有搬运动作”,而是“让不必要的那几次复制消失”。
10. 总结
零拷贝的核心目标,是减少 CPU 拷贝和上下文切换,让数据尽可能在内核空间内部高效流转。
mmap的优势是把文件访问变成内存访问,适合“既要传,又要看”的场景sendfile的优势是文件可以原样发送,适合静态内容分发splice的优势是内核态对象之间流转更灵活,适合构造高性能数据通路
工程里最重要的不是背出“几次拷贝、几次切换”,而是根据业务路径判断:
- 数据是否必须进入用户态
- 平台是否支持理想的数据通路
- 引入复杂度后是否真的换来了收益
如果这三个问题已经明确,那么零拷贝技术就不仅是操作系统知识点,而是可以真正落地到系统优化中的工程手段。

Comments NOTHING