零拷贝介绍
1. 核心概念
1.1 基础定义
零拷贝(Zero-Copy)不是指数据完全不发生拷贝,而是指在 I/O 过程中尽量避免 CPU 参与的数据复制,尤其是避免内核态与用户态之间的无效数据搬运。
在操作系统中,数据从磁盘发送到网络通常会经过:
-
磁盘;
-
内核页缓存;
-
用户空间缓冲区;
-
Socket 缓冲区;
-
网卡。
传统 I/O 会让数据在这些区域之间反复复制,而零拷贝的目标是:
-
减少 CPU copy;
-
减少用户态与内核态切换;
-
减少内存带宽消耗;
-
减少 CPU cache 污染;
-
利用 DMA 或 Scatter-Gather DMA 直接搬运数据。
2. 传统 I/O 数据路径
2.1 read + write 模型
典型代码如下:
int fd = open("data.txt", O_RDONLY);
int sock = accept(server_fd, NULL, NULL);
char buf[8192];
while ((n = read(fd, buf, sizeof(buf))) > 0) {
write(sock, buf, n);
}
数据路径如下:
磁盘
│
│ 1. DMA copy
▼
内核页缓存 Page Cache
│
│ 2. CPU copy
▼
用户空间 Buffer
│
│ 3. CPU copy
▼
内核 Socket Buffer
│
│ 4. DMA copy
▼
网卡
2.2 传统 I/O 的问题
| 项目 | 传统 read + write |
|---|---|
| 数据拷贝次数 | 4 次 |
| CPU 拷贝次数 | 2 次 |
| DMA 拷贝次数 | 2 次 |
| 系统调用次数 | 至少 2 次 |
| 上下文切换 | read 和 write 各一次进出内核 |
| 主要问题 | 数据经过用户态,但应用通常并不修改数据 |
核心浪费点在于:
如果应用只是把文件原样发送到网络,那么数据进入用户空间是没有必要的。
3. 零拷贝主要优化点
3.1 减少 CPU 数据拷贝
零拷贝最核心的优化是:
避免 kernel buffer -> user buffer -> kernel socket buffer 这类 CPU 参与的内存复制。
传统 I/O 中,CPU 需要执行类似 copy_to_user、copy_from_user 的内存复制逻辑:
// 简化示意:read 阶段
copy_to_user(user_buf, page_cache_buf, len);
// 简化示意:write 阶段
copy_from_user(socket_buf, user_buf, len);
这些操作会消耗:
-
CPU 时间;
-
内存带宽;
-
CPU cache;
-
TLB 资源。
对于大文件传输、日志传输、消息队列刷盘与网络发送场景,这部分成本非常明显。
3.2 减少上下文切换
传统 I/O 至少需要两个系统调用:
用户态 -> 内核态:read
内核态 -> 用户态:read 返回
用户态 -> 内核态:write
内核态 -> 用户态:write 返回
零拷贝通常可以通过一个系统调用完成文件到网络的传输,例如 sendfile:
sendfile(socket_fd, file_fd, &offset, count);
这样可以减少系统调用和上下文切换成本。
3.3 利用 DMA 执行数据搬运
DMA(Direct Memory Access)允许硬件设备直接访问内存,避免 CPU 逐字节搬运数据。
常见路径:
磁盘 --DMA--> Page Cache
Page Cache --DMA--> 网卡
在支持 Scatter-Gather DMA 的网卡上,内核甚至不需要把文件数据复制到 Socket Buffer,只需要让 Socket Buffer 引用 Page Cache 中的内存页,然后网卡直接从这些内存页读取数据。
3.4 减少内存带宽和 cache 污染
CPU 拷贝大块数据时,会把数据加载进 CPU cache。
但对于文件转发、消息转发、静态资源发送这类场景,应用并不会真正计算这些数据。
因此这类数据进入 CPU cache 反而会挤占热点业务数据,造成 cache 污染。
零拷贝可以减少这种污染。
4. 主流零拷贝实现方式
4.1 mmap + write
4.1.1 基本原理
mmap 将文件映射到用户进程地址空间。
应用访问这段地址时,底层实际上访问的是内核 Page Cache 中的文件页。
示例代码:
int fd = open("data.txt", O_RDONLY);
struct stat st;
fstat(fd, &st);
char *addr = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
// write 时仍然需要从映射区域复制到 Socket Buffer
write(socket_fd, addr, st.st_size);
munmap(addr, st.st_size);
数据路径如下:
磁盘
│
│ 1. DMA copy
▼
内核 Page Cache
▲
│ mmap 映射,不复制数据
│
用户进程虚拟地址空间
│
│ 2. CPU copy
▼
Socket Buffer
│
│ 3. DMA copy
▼
网卡
4.1.2 优化效果
| 项目 | mmap + write |
|---|---|
| 数据拷贝次数 | 通常 3 次 |
| CPU 拷贝次数 | 1 次 |
| DMA 拷贝次数 | 2 次 |
| 是否绕过用户态 Buffer | 是 |
| 是否还需要复制到 Socket Buffer | 通常需要 |
mmap 省掉了:
Page Cache -> User Buffer
但没有省掉:
User Mapping -> Socket Buffer
所以它不是最彻底的零拷贝。
4.1.3 适用场景
mmap 更适合:
-
文件随机读;
-
文件索引;
-
大文件局部访问;
-
消息队列 Consume Queue;
-
数据库 Buffer Pool 之外的文件映射;
-
需要像访问内存一样访问文件内容的场景。
例如 RocketMQ 的 CommitLog 和 ConsumeQueue 就大量使用了 mmap 思路。
4.1.4 主要问题
mmap 不是没有成本,它的核心问题包括:
-
Page Fault 成本;
-
mmap后文件被截断可能触发SIGBUS; -
频繁映射和解除映射成本较高;
-
内存回收时可能触发脏页回写;
-
对顺序大文件网络发送不如
sendfile直接。
4.2 sendfile
4.2.1 基本原理
sendfile 可以直接在内核中完成文件描述符到 Socket 描述符的数据传输。
示例代码:
int file_fd = open("data.txt", O_RDONLY);
int socket_fd = accept(server_fd, NULL, NULL);
off_t offset = 0;
struct stat st;
fstat(file_fd, &st);
sendfile(socket_fd, file_fd, &offset, st.st_size);
用户态只发起一次系统调用,数据不需要进入用户空间。
4.2.2 不支持 Scatter-Gather DMA 的路径
早期或能力较弱的实现中,数据路径类似:
磁盘
│
│ 1. DMA copy
▼
Page Cache
│
│ 2. CPU copy
▼
Socket Buffer
│
│ 3. DMA copy
▼
网卡
此时 sendfile 主要省掉了:
Page Cache -> User Buffer
User Buffer -> Socket Buffer
中的一次用户态往返,但仍可能需要内核内部复制。
4.2.3 支持 Scatter-Gather DMA 的路径
现代 Linux + 支持 Scatter-Gather 的网卡可以做到更彻底:
磁盘
│
│ 1. DMA copy
▼
Page Cache
│
│ Socket Buffer 只保存 page 引用和 offset
▼
网卡 DMA 直接读取 Page Cache
此时 Socket Buffer 中不再保存完整数据副本,而是保存类似如下信息:
page 指针
page offset
data length
网卡通过 DMA 从这些分散的内存页中读取数据并发送。
4.2.4 优化效果
| 项目 | sendfile |
|---|---|
| 数据拷贝次数 | 通常 2 次 |
| CPU 拷贝次数 | 0 次 |
| DMA 拷贝次数 | 2 次 |
| 系统调用次数 | 1 次 |
| 是否经过用户态 Buffer | 否 |
| 典型场景 | 静态文件发送、Kafka 文件日志发送、HTTP 文件下载 |
sendfile 的核心优势是:
文件数据从 Page Cache 直接进入网络发送路径,用户进程只负责描述传输动作,不参与数据搬运。
4.3 sendfile + DMA Gather Copy
4.3.1 Socket Buffer 保存元数据
在更现代的实现中,Socket Buffer 不再拷贝文件内容,而是记录 Page Cache 中文件页的引用:
struct skb_frag {
struct page *page; // 指向 Page Cache 中的物理页
uint32_t page_offset; // 页内偏移
uint32_t size; // 数据长度
};
发送时,网卡驱动根据这些 fragment 组织 DMA 描述符。
4.3.2 数据发送路径
磁盘
│
│ DMA
▼
Page Cache
│
│ skb 记录 page 引用
▼
NIC DMA Descriptor
│
│ DMA Gather
▼
网卡
此时真正的数据移动是:
-
磁盘通过 DMA 写入 Page Cache;
-
网卡通过 DMA 从 Page Cache 读取数据。
CPU 不搬运文件内容,只构造描述符和维护引用计数。
4.4 splice
4.4.1 基本原理
splice 是 Linux 提供的零拷贝系统调用,可以在两个文件描述符之间移动数据,通常借助 Pipe 作为中转。
示例:
int pipefd[2];
pipe(pipefd);
// 文件 -> pipe
splice(file_fd, NULL, pipefd[1], NULL, len, SPLICE_F_MOVE);
// pipe -> socket
splice(pipefd[0], NULL, socket_fd, NULL, len, SPLICE_F_MOVE);
数据路径:
File/Page Cache
│
│ 移动 page 引用
▼
Pipe Buffer
│
│ 移动 page 引用
▼
Socket Buffer
│
│ DMA
▼
网卡
4.4.2 与 sendfile 的区别
| 对比项 | sendfile |
splice |
|---|---|---|
| 输入 | 文件描述符 | 任意支持 splice 的 fd |
| 输出 | 通常是 Socket | 任意支持 splice 的 fd |
| 是否需要 Pipe | 不需要 | 通常需要 |
| 灵活性 | 较低 | 较高 |
| 典型场景 | 文件到网络 | 文件、Pipe、Socket 之间转发 |
splice 更像是通用的数据管道机制,而 sendfile 更像是文件发送的专用优化。
4.5 tee
4.5.1 基本原理
tee 用于在两个 Pipe 之间复制数据引用,而不是复制数据本身。
示例:
// pipe1 中的数据复制引用到 pipe2
tee(pipe1[0], pipe2[1], len, 0);
它的核心特点是:
复制的是 Pipe Buffer 引用,而不是 Buffer 内容。
4.5.2 适用场景
tee 常用于:
-
流量复制;
-
日志旁路;
-
数据一份写网络,一份写文件;
-
代理服务器中做旁路分析。
4.6 Java NIO FileChannel.transferTo
4.6.1 基本原理
Java 中的零拷贝主要通过 FileChannel.transferTo 或 transferFrom 暴露。
示例:
try (
FileChannel fileChannel = FileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ);
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 8080))
) {
long position = 0;
long size = fileChannel.size();
while (position < size) {
// 底层在 Linux 上通常会尝试使用 sendfile
long transferred = fileChannel.transferTo(position, size - position, socketChannel);
position += transferred;
}
}
核心调用链可以理解为:
Java FileChannel.transferTo
-> JDK native 方法
-> Linux sendfile / sendfile64
-> Page Cache
-> Socket Buffer page 引用
-> NIC DMA
4.6.2 易错点
transferTo 可能不会一次传完所有数据,因此必须循环调用:
while (position < size) {
long count = fileChannel.transferTo(position, size - position, target);
if (count <= 0) {
// 防止非阻塞模式下空转
break;
}
position += count;
}
不能假设一次 transferTo 就完成整个文件传输。
4.7 Netty DefaultFileRegion
4.7.1 基本原理
Netty 中常见的零拷贝文件发送方式是 DefaultFileRegion。
示例:
File file = new File("data.txt");
FileChannel fileChannel = new FileInputStream(file).getChannel();
DefaultFileRegion region = new DefaultFileRegion(fileChannel, 0, file.length());
// Netty 底层会尽量使用 FileChannel.transferTo
ctx.writeAndFlush(region);
底层路径通常是:
DefaultFileRegion.transferTo
-> FileChannel.transferTo
-> sendfile
4.7.2 Netty 中“零拷贝”的多个含义
Netty 里“零拷贝”不只指 sendfile,还包括:
| 实现 | 优化点 |
|---|---|
DefaultFileRegion |
文件发送走 sendfile |
CompositeByteBuf |
多个 Buffer 组合成逻辑 Buffer,避免合并复制 |
ByteBuf.slice |
切片共享底层内存 |
ByteBuf.duplicate |
复制视图,不复制数据 |
| Direct Buffer | 减少 JVM 堆到堆外的复制 |
例如:
ByteBuf header = Unpooled.wrappedBuffer("header".getBytes());
ByteBuf body = Unpooled.wrappedBuffer("body".getBytes());
// 逻辑组合,不把 header 和 body 拷贝到一个新数组
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);
这里的零拷贝是应用层 Buffer 视图复用,不是操作系统层面的 sendfile。
5. 几种实现方式对比
| 实现方式 | 是否经过用户态 | CPU 拷贝 | 典型系统调用 | 适用场景 |
|---|---|---|---|---|
read + write |
是 | 2 次 | read, write |
普通 I/O,数据需要业务处理 |
mmap + write |
地址映射经过用户态 | 1 次 | mmap, write |
文件映射、随机访问 |
sendfile |
否 | 通常 0 次 | sendfile |
文件到 Socket |
splice |
否 | 通常 0 次 | splice |
fd 到 fd 转发 |
tee |
否 | 通常 0 次 | tee |
Pipe 数据复制引用 |
Java transferTo |
否 | 通常 0 次 | 底层 sendfile |
Java 文件网络发送 |
Netty DefaultFileRegion |
否 | 通常 0 次 | 底层 transferTo |
高性能文件传输 |
Netty CompositeByteBuf |
是 | 避免应用层合并复制 | 无特定系统调用 | 应用层 Buffer 组合 |
6. 零拷贝的核心限制
6.1 不适合需要修改数据的场景
如果应用必须对数据进行:
-
加密;
-
压缩;
-
编码转换;
-
协议改写;
-
内容过滤;
-
业务解析;
那么数据通常必须进入用户态处理。
此时零拷贝收益会下降,甚至无法使用。
6.2 TLS 会影响 sendfile
普通 sendfile 适合明文传输。
如果使用 TLS,数据需要加密后才能发送,传统路径下加密发生在用户态 SSL 库中,例如 OpenSSL。
路径可能变成:
File
-> Page Cache
-> User Space
-> TLS Encrypt
-> Socket Buffer
-> NIC
因此 HTTPS 静态文件传输通常不能直接使用普通 sendfile 获得完整零拷贝收益。
不过现代系统可能通过:
-
Kernel TLS;
-
NIC TLS Offload;
-
io_uring;
-
特定网卡卸载能力;
部分恢复零拷贝能力。
6.3 Page Cache 不是免费资源
sendfile 和 mmap 都高度依赖 Page Cache。
如果系统内存压力大,可能出现:
-
Page Cache 频繁回收;
-
缺页中断增加;
-
磁盘 I/O 放大;
-
抖动明显。
因此零拷贝并不等于必然高性能,还需要结合内存、磁盘、网络和业务访问模式分析。