Skip to content
Go back

零拷贝——为什么Kafka和Nginx这么快?

零拷贝:为什么 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:

  1. 不能修改数据:数据从内核直接到网卡,如果想对数据做加密/压缩/加 header,必须先读到用户态。TLS 加密场景下零拷贝不生效
  2. 必须从文件到 socket:两个 fd,源必须是文件(可 mmap 的),目标是 socket
  3. 需要内核 2.4+:SG-DMA 需要较新的内核和网卡支持

总结

方式系统调用数据拷贝上下文切换CPU 参与
传统 read+write2 次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+ 和网卡支持。


Share this post on:

Previous Post
页面置换算法——从OPT到LRU到Clock的演进
Next Post
进程间通讯IPC全览:七种方式的原理与选型