零拷贝:为什么 Kafka 的文件传输比传统方式快一倍?
一句话结论(30s)
零拷贝的本质是「让数据尽量留在内核态、让 DMA 直接搬运」——传统 read()+write() 要 4 次拷贝 + 4 次上下文切换,其中「内核→用户→内核」的两次 CPU 拷贝纯粹是浪费,因为数据只是借用户态路过而从未被修改。最关键的设计是 sendfile() 把两次系统调用合为一次,数据从 Page Cache 直接发到网卡,全程不进入用户态;Linux 2.4 的 scatter-gather DMA 之后连 CPU 拷贝也省略,做到零 CPU 拷贝。核心权衡是:零拷贝极快(Kafka 吞吐提升约 2 倍、CPU 降约 40%),但要求「从文件到 socket」且不能中途修改数据,TLS 加密/压缩等场景无法生效。
核心原理(2min)
传统 read()+write():磁盘 DMA 到内核 Read Buffer → CPU 拷到用户态 buf → CPU 拷回内核 Socket Buffer → DMA 到网卡,共 4 次拷贝 + 4 次上下文切换,其中第 2、3 次拷贝是纯浪费。sendfile()(Linux 2.1)合并为一次系统调用:磁盘 DMA 到内核 Read Buffer → CPU 只把描述符/指针从 Read Buffer 关联到 Socket Buffer → DMA 到网卡,数据不进用户态,降到 3 次拷贝。Linux 2.4 引入 scatter-gather DMA 后第 2 步 CPU 拷贝也省略——内核把 Page Cache 中的页引用(struct page 指针 + offset + len)附加到 Socket Buffer,网卡经 SG-DMA 直接从 Page Cache 收集数据发送,物理内存中数据只有一份。Kafka 的 FileChannel.transferTo() 底层即 sendfile,消费者拉消息时数据从 Page Cache 直达网卡。
底层深入(5-10min)
传统文件传输:4 次拷贝 + 4 次上下文切换
从磁盘读一个文件并通过网卡发送出去,传统方式(read() + write()):
read(fd, buf, size):
1. CPU 复制: 磁盘 → 内核 Read Buffer (DMA)
2. CPU 复制: 内核 Read Buffer → 用户态 buf (CPU)
→ 上下文切换: 内核态 → 用户态
write(sockfd, buf, size):
→ 上下文切换: 用户态 → 内核态
3. CPU 复制: 用户态 buf → 内核 Socket Buffer (CPU)
4. CPU 复制: 内核 Socket Buffer → 网卡 (DMA)
→ 上下文切换: 内核态 → 用户态
4 次数据拷贝 + 4 次上下文切换。其中第 2、3 次拷贝(内核 → 用户 → 内核)完全是浪费——数据只是借用户态”路过”了一下,从未被修改。
思考:4 次拷贝里,哪几次是「浪费」?——第 2、3 次(内核→用户→内核)。数据从磁盘进内核是必须的,从内核发到网卡也是必须的,唯独「借用户态路过」这两次拷贝毫无意义——应用从头到尾没改过一个字节,却要 CPU 亲手把数据搬进搬出。零拷贝的出发点就是砍掉这两次。
sendfile 的零拷贝
Linux 2.1 引入 sendfile(),把两次系统调用合并为一次:
sendfile(sockfd, fd, NULL, size); // 一次系统调用
sendfile 流程:
1. DMA: 磁盘 → 内核 Read Buffer
2. CPU: 内核 Read Buffer → 内核 Socket Buffer (只拷贝描述符)
3. DMA: 内核 Socket Buffer → 网卡
数据从来没有进入用户态。 应用程序全程不接触数据,只是告诉内核”把这个文件的内容发到那个 socket”。
Linux 2.4 进一步优化——支持 scatter-gather DMA 后,步骤 2 的 CPU 拷贝也可以省略(只传递描述符/指针),做到真正意义上的零 CPU 拷贝。
思考:sendfile 把 4 次拷到 3 次,省掉的是哪一步?——省掉了「内核 → 用户态 buf → 内核」的往返。read+write 必须让数据进一次用户态再出来;sendfile 里数据根本不进用户态,应用只把「源 fd + 目标 fd」告诉内核,内核直接在内部把数据从 Read Buffer 关联到 Socket Buffer。少一次用户态中转,就少两次 CPU 拷贝和两次上下文切换。
内核层面发生了什么
零拷贝 sendfile 流程:
磁盘 → [DMA] → Page Cache (内核缓冲区)
↓
内核将 Page Cache 中的页引用附加到 Socket Buffer
同时记录 Offset + Length
↓
网卡通过 SG-DMA 直接从 Page Cache 收集数据并发送
数据在物理内存中只有一份——就是 Page Cache 中的那一份。Socket Buffer 只存了对它的引用(struct page 指针 + offset + len)。
思考:为什么 Linux 2.4 之后连 CPU 拷贝都能省掉?——因为 SG-DMA(scatter-gather DMA)让网卡可以直接「收集」分散的数据。内核不再把数据复制到 Socket Buffer,而是把 Page Cache 里那一页的
struct page指针 + offset + len 挂到 Socket Buffer 上,网卡经 SG-DMA 按这些引用直接从 Page Cache 把数据抓走。物理内存里数据始终只有一份,CPU 彻底不碰数据。
为什么 Kafka 这么快
Kafka 消费者从磁盘读取消息时,使用 FileChannel.transferTo()(底层就是 sendfile):
// Kafka 源码简化
public long transferTo(long position, long count, WritableByteChannel target) {
return fileChannel.transferTo(position, count, target);
}
消费者拉取消息,数据直接从 Page Cache → 网卡,完全绕开用户态。Kafka 官方测试:零拷贝模式下,消息传输吞吐量提升 ~2 倍,CPU 使用率降低 ~40%。
sendfile 的局限
不是所有场景都适合用 sendfile:
- 不能修改数据:数据从内核直接到网卡,如果想对数据做加密/压缩/加 header,必须先读到用户态。TLS 加密场景下零拷贝不生效
- 必须从文件到 socket:两个 fd,源必须是文件(可 mmap 的),目标是 socket
- 需要内核 2.4+:SG-DMA 需要较新的内核和网卡支持
总结
| 方式 | 系统调用 | 数据拷贝 | 上下文切换 | CPU 参与 |
|---|---|---|---|---|
| 传统 read+write | 2 次 | 4 次 | 4 次 | 是 |
| sendfile (2.1) | 1 次 | 3 次 | 2 次 | 1 次 CPU 拷贝 |
| sendfile + SG-DMA (2.4) | 1 次 | 2 次 | 2 次 | 零(全 DMA) |
核心思想:最好的搬运是不搬运。 让数据尽可能留在内核态,让 DMA 直接处理数据移动,CPU 只做控制。
章末提问
追问 1:传统 read+write 传输文件为什么慢?有几次拷贝和上下文切换?
回答思路:结论先行——因为要 4 次数据拷贝 + 4 次上下文切换,其中 2 次 CPU 拷贝纯属浪费。因为数据要「磁盘→内核 Read Buffer(DMA)→用户态 buf(CPU)→内核 Socket Buffer(CPU)→网卡(DMA)」,中间「内核→用户→内核」那两步只是借用户态路过、数据从未被修改,却让 CPU 亲手搬运并触发多次上下文切换。
追问 2:sendfile 是怎么做到零拷贝的?
回答思路:结论先行——sendfile 把两次系统调用合为一次,数据从 Page Cache 直接到网卡,不进入用户态。因为应用只告诉内核「源 fd + 目标 fd」,内核在内部把 Page Cache 中的页引用(struct page 指针 + offset + len)附加到 Socket Buffer,网卡经 SG-DMA 直接从 Page Cache 收集数据发送;物理内存中数据只有一份,CPU 零拷贝。
追问 3:零拷贝有什么局限?TLS 场景为什么用不了?
回答思路:结论先行——局限是「不能中途修改数据」且必须「从文件到 socket」。因为 sendfile 的数据从内核直达网卡,若要做加密/压缩/加 header 就必须先把数据读回用户态处理,此时零拷贝不生效;TLS 正好需要加密,所以用不了 sendfile。另外源必须是可 mmap 的文件、目标是 socket,且 SG-DMA 需要内核 2.4+ 和网卡支持。