零拷贝原理
假设你需要把一个 1GB 的文件从磁盘发送到网络。一台普通服务器完成这个任务需要多长时间?
如果你认为答案是"取决于网络带宽",那你可能忽略了 I/O 过程中的一个关键瓶颈:数据复制。
传统 I/O 过程中,数据需要经过多次复制。如果不消除这些不必要的复制,即使网络带宽再高,系统也可能被 CPU 拖慢。
传统 I/O 的四次拷贝
当应用程序需要将文件发送到网络时,数据需要经过四次拷贝:
flowchart LR
subgraph 磁盘
D["磁盘"]
end
subgraph 内核缓冲区
K["内核缓冲区\n(Page Cache)"]
end
subgraph 用户空间
U["用户空间\n缓冲区"]
end
subgraph Socket 缓冲区
S["Socket 缓冲区"]
end
subgraph 网卡
N["网卡"]
end
D -->|"DMA 拷贝"| K
K -->|"CPU 拷贝"| U
U -->|"CPU 拷贝"| S
S -->|"DMA 拷贝"| N
style K fill:#ff6b6b
style U fill:#feca57
style S fill:#ff6b6b
详细过程
- 磁盘 → 内核缓冲区:DMA(Direct Memory Access)拷贝,CPU 不参与
- 内核缓冲区 → 用户空间:CPU 拷贝,数据从内核区复制到用户区
- 用户空间 → Socket 缓冲区:CPU 拷贝,数据从用户区复制回内核区
- Socket 缓冲区 → 网卡:DMA 拷贝,CPU 不参与
为什么需要这些拷贝?
第一次拷贝(磁盘 → 内核缓冲区):这是硬件操作,DMA 控制器直接访问内存,CPU 不参与。这是必要的。
第二次拷贝(内核缓冲区 → 用户空间):应用程序需要访问数据,必须把数据放到用户空间。这是必要的。
第三次拷贝(用户空间 → Socket 缓冲区):发送数据需要把数据放到网络协议栈,这是必要的。
第四次拷贝(Socket 缓冲区 → 网卡):硬件操作,DMA 拷贝。这是必要的。
问题在哪?
第二和第三次拷贝是"为了传递数据而复制",而不是"为了使用数据而复制"。应用程序并不需要修改或查看数据,它只是想发送数据。这些复制是多余的。
零拷贝的目标
零拷贝(Zero-Copy)技术旨在减少甚至消除这些不必要的数据复制。
mmap:内存映射
mmap 将文件映射到进程的虚拟地址空间:
flowchart LR
subgraph 进程虚拟地址空间
M["内存映射区\nmmap"]
end
subgraph 物理内存
P["Page Cache"]
end
subgraph 磁盘
D["磁盘"]
end
M <-->|"共享同一物理页"| P
P <-->|"按需加载"| D
读取文件时,数据直接来自 Page Cache,而不是复制到用户空间。写文件时,修改也在 Page Cache 中进行,后台异步刷新到磁盘。
mmap
FileChannel channel = new RandomAccessFile("data.txt", "rw")
.getChannel();
// 将文件映射到内存
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE,
0,
channel.size()
);
// 直接读取内存
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
mmap 的优势
- 减少一次拷贝:数据不需要从内核缓冲区复制到用户空间
- 按需加载:操作系统只加载需要的页面,不是整个文件
- 共享内存:多个进程可以共享同一个映射
mmap 的劣势
- 页面抖动:大文件映射可能导致频繁的页面换入换出
- 关闭延迟:文件关闭后,修改可能还没有刷盘
- 地址空间限制:32 位系统无法映射大文件
sendfile:内核直传
sendfile 是 Linux 2.2 引入的系统调用,它告诉内核:数据从哪里来就直接发到哪里去,全程在内核空间完成。
// sendfile 系统调用
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
flowchart LR
subgraph 磁盘
D["磁盘"]
end
subgraph 内核缓冲区
K["内核缓冲区\n(Page Cache)"]
end
subgraph Socket 缓冲区
S["Socket 缓冲区"]
end
subgraph 网卡
N["网卡"]
end
D -->|"DMA 拷贝"| K
K -->|"CPU 拷贝"| S
S -->|"DMA 拷贝"| N
数据只经过两次 DMA 拷贝,不再经过用户空间。
Java 中的 sendfile
Java 的 FileChannel.transferTo() 方法封装了 sendfile:
sendfile
FileChannel from = new FileInputStream("bigfile.zip")
.getChannel();
FileChannel to = Channels.newChannel(socket.getOutputStream());
long bytesTransferred = from.transferTo(0, from.size(), to);
System.out.println("传输了 " + bytesTransferred + " 字节");
SG-DMA:真正的零拷贝
Linux 2.4+ 的 sendfile 支持 SG-DMA(Scatter-Gather DMA),进一步减少了一次 CPU 拷贝:
flowchart LR
subgraph 磁盘
D["磁盘"]
end
subgraph 内核缓冲区
K["Page Cache"]
end
subgraph 网卡
N["网卡"]
end
D -->|"DMA 拷贝"| K
K -->|"DMA 直接读取"| N
网卡直接从 Page Cache 读取数据,完全不需要 CPU 参与。这就是真正的零拷贝。
零拷贝的收益
零拷贝的适用场景
零拷贝最适合的场景:数据只需要传输,不需要加工。
零拷贝不适合的场景
如果应用程序需要修改数据,零拷贝可能不适用:
// 场景:需要在发送前加密数据
FileInputStream in = new FileInputStream("data.txt");
FileOutputStream out = new FileOutputStream("encrypted.txt");
byte[] buffer = new byte[1024];
while ((len = in.read(buffer)) != -1) {
byte[] encrypted = encrypt(buffer); // 数据必须加载到用户空间
out.write(encrypted);
}
这个场景中,数据必须加载到用户空间才能加密,所以无法使用零拷贝。
零拷贝技术总结
flowchart TB
A["零拷贝技术"] --> B["mmap"]
A --> C["sendfile"]
A --> D["splice"]
A --> E["tee"]
B --> B1["内存映射\n减少一次拷贝"]
C --> C1["内核直传\n减少两次拷贝"]
D --> D1["管道传输\n零拷贝管道"]
E --> E1["管道复制\n零拷贝复制"]
style A fill:#ff6b6b
style B1 fill:#1dd1a1
style C1 fill:#1dd1a1
本章小结
零拷贝的核心思想是:减少不必要的数据复制。
传统 I/O 需要 4 次拷贝(2 次 CPU 拷贝,2 次 DMA 拷贝),零拷贝技术可以将其减少到 2 次甚至 1 次。
不同零拷贝技术的对比:
- mmap:适合需要读取文件内容的场景,内存映射避免了内核到用户的拷贝
- sendfile:适合纯传输场景,数据不需要在用户空间处理
- SG-DMA:真正的零拷贝,网卡直接从 Page Cache 读取
Kafka、RocketMQ、Nginx 等高性能中间件都大量使用零拷贝技术。理解零拷贝,是理解这些系统性能优势的关键。
延伸思考
为什么 Kafka 能达到每秒百万级消息的吞吐量?
答案就在零拷贝 + 顺序写。
- 顺序写:Kafka 追加写入日志文件,充分利用磁盘顺序 I/O 的高性能
- 零拷贝:使用 sendfile 直接从 Page Cache 发送到网卡,避免用户空间复制
- 页缓存:Linux 的 Page Cache 自动缓存热点数据,读取时命中缓存
这三个技术组合在一起,成就了 Kafka 的高吞吐量神话。