零拷贝是什么?它是如何实现的?
简化版
零拷贝(Zero-Copy)是指在「读文件 → 发网络」这类数据传输中,减少 CPU 参与的数据拷贝次数和用户态/内核态的切换次数。传统方式要经过「磁盘→内核缓冲区→用户缓冲区→socket 缓冲区→网卡」多次拷贝,其中数据根本没被应用处理,却在内核态和用户态之间来回拷了好几遍,纯属浪费。零拷贝(sendfile、mmap)让数据不经过用户空间、直接在内核里流转,省掉了这些无谓的 CPU 拷贝——Kafka、Netty、Nginx 都靠它实现高吞吐。
详细版
传统「读文件发网络」的开销(read + write):
① DMA 拷贝:磁盘 → 内核读缓冲区(page cache)
② CPU 拷贝:内核读缓冲区 → 用户缓冲区 ← 应用其实没用这数据
③ CPU 拷贝:用户缓冲区 → socket 缓冲区 ← 又拷回内核
④ DMA 拷贝:socket 缓冲区 → 网卡
共 4 次拷贝(2 次 DMA + 2 次 CPU)+ 4 次上下文切换。问题在于:数据只是「从磁盘搬到网卡」,应用根本没处理它,但它却在内核态↔用户态之间白白拷了两趟(②③),浪费 CPU 和切换开销。
零拷贝的优化:
mmap+write:用mmap把内核缓冲区映射到用户空间,应用「看得到」数据但不用真的拷贝到用户缓冲区——省掉第②次拷贝。变成 3 次拷贝。sendfile:直接告诉内核「把这个文件发到这个 socket」,数据全程不经过用户空间,在内核里从读缓冲区直接送到 socket 缓冲区。变成 3 次拷贝(还剩 ①→socket→网卡 里的一次 CPU 拷贝)+ 2 次上下文切换。sendfile+ DMA gather(SG-DMA):更进一步,网卡的 DMA 直接从内核读缓冲区「聚集」数据发送,连内核缓冲区→socket 缓冲区的 CPU 拷贝也省了——只剩 2 次 DMA 拷贝、0 次 CPU 拷贝,这才是「真·零拷贝」(CPU 零参与拷贝)。
完整版教学
一、先看清「传统方式浪费在哪」
理解零拷贝,关键是先看清传统方式的浪费。一个常见需求:把服务器上的文件通过网络发给客户端(如下载、Kafka 发消息给消费者)。传统写法是 read 文件到内存、再 write 到 socket,背后发生 4 次拷贝:
- ① 磁盘 → 内核 page cache(DMA,必要);
- ② 内核 page cache → 用户缓冲区(CPU);
- ③ 用户缓冲区 → socket 缓冲区(CPU);
- ④ socket 缓冲区 → 网卡(DMA,必要)。
问题出在 ② 和 ③:数据从磁盘到网卡,应用根本没有对它做任何处理(不解析、不修改,只是转发),但它却被拷到用户空间又原样拷回内核——这两次 CPU 拷贝和随之的用户态/内核态切换,纯属浪费。零拷贝要消灭的就是这种「无谓的过路拷贝」。
二、为什么叫「零拷贝」——零的是 CPU 拷贝
一个常见误解:以为零拷贝是「一次拷贝都没有」。其实不是——①④ 那两次 DMA 拷贝是省不掉的(数据总得从磁盘进内存、从内存到网卡,这是硬件必需)。
零拷贝的「零」,指的是零次 CPU 参与的拷贝——把 ②③ 那两次由 CPU 执行的、在用户态和内核态之间的拷贝干掉。DMA(直接内存访问)拷贝由专门的硬件控制器完成、不占用 CPU,所以它们不算在「零拷贝」要消除的范围里。
所以准确说:零拷贝 = 消除 CPU 拷贝 + 减少用户态/内核态切换,让数据尽量只由 DMA 在内核里流转,CPU 不插手搬运。
三、方案一:mmap + write(省一次拷贝)
mmap 的思路是内存映射——把内核的读缓冲区(page cache)直接映射到用户进程的地址空间:
- 应用调
mmap后,能像访问自己内存一样访问内核缓冲区里的数据,但数据并没有真的被拷贝到用户空间(只是建立了映射); - 于是
read那次「内核→用户」的 CPU 拷贝(②)就省掉了; - 之后
write时,直接从这个映射的内核缓冲区拷到 socket 缓冲区。
结果:从 4 次拷贝降到 3 次(省了 1 次 CPU 拷贝)。适合需要在用户态修改数据后再发送的场景(因为映射后应用能读写数据)。
四、方案二:sendfile(数据不进用户态)
如果应用只是转发文件、根本不需要看/改数据(如静态文件下载、Kafka 转发消息),那连「映射到用户空间」都多余。sendfile 一步到位:
sendfile(out_fd, in_fd, ...); // 直接把 in_fd(文件)发到 out_fd(socket)
它告诉内核:「把这个文件的数据直接发到这个 socket」。数据全程在内核里流转,压根不经过用户空间——省去了「内核→用户→内核」的往返。上下文切换也从 4 次降到 2 次。
进一步,配合网卡的 SG-DMA(分散-聚集 DMA),网卡能直接从内核读缓冲区「聚集」数据发送,连「内核读缓冲区→socket 缓冲区」这次 CPU 拷贝也省了——最终只剩 2 次 DMA 拷贝、0 次 CPU 拷贝,达到真正的零拷贝。
五、零拷贝在哪些地方大放异彩
零拷贝是高吞吐系统的标配:
- Kafka:消费者拉消息时,Kafka 用
sendfile(FileChannel.transferTo)把日志文件数据直接发给消费者,不经过用户态——这是 Kafka 高吞吐的关键之一; - Netty:提供零拷贝能力(
FileRegion底层用sendfile,还有CompositeByteBuf逻辑上的零拷贝——组合多个 buffer 而不拷贝数据); - Nginx:发送静态文件用
sendfile,大幅提升静态资源吞吐; - Java:
FileChannel.transferTo()(底层 sendfile)、MappedByteBuffer(底层 mmap)。
注意:Netty 说的「零拷贝」有两层含义——一是操作系统层的
sendfile(避免内核用户态拷贝),二是应用层的(如CompositeByteBuf把多个小 buffer 逻辑组合成一个而不真拷贝、slice共享底层数组),别混淆。
六、常见误区与追问
| 考点 | 正确口径 |
|---|---|
| 传统拷贝 | 磁盘到内核,再到用户态,再回内核 socket |
| 零拷贝目标 | 减少用户态/内核态拷贝和上下文切换 |
| 典型技术 | sendfile、mmap、splice、DMA |
traditional:
disk -> kernel buffer -> user buffer -> socket buffer -> NIC
sendfile:
disk -> kernel page cache -> socket/NIC
user space not copying payload
零拷贝不是一次拷贝都没有,而是尽量避免数据在用户态和内核态之间来回搬运。
- 误区:零拷贝等于没有任何拷贝。 数据仍可能经 DMA、页缓存或内核缓冲区移动,重点是减少 CPU 参与的内存拷贝。
- 误区:所有业务都适合 sendfile。 sendfile 适合文件直接发送;如果要压缩、加密或修改内容,可能仍要进入用户态处理。
- 误区:零拷贝只优化带宽。 它还减少 CPU 消耗、上下文切换和内存带宽压力。
- 追问:mmap 和 sendfile 区别是什么? mmap 把文件映射到用户空间,sendfile 更偏文件到 socket 的内核内传输。
- 追问:Kafka 为什么强调零拷贝? 日志文件顺序读并发送给消费者,非常适合 page cache 加 sendfile 降低复制成本。
- 追问:TLS 会影响零拷贝吗? 传统用户态 TLS 需要加密处理,可能打断 sendfile 路径;内核 TLS 可改善部分场景。
七、加强记忆
零拷贝是减少「读文件发网络」中 CPU 参与的拷贝和用户态/内核态切换。传统 read+write 有 4 次拷贝(2 DMA + 2 CPU),其中「内核→用户→内核」两次 CPU 拷贝纯属浪费(数据没被应用处理)。mmap+write 映射内核缓冲区省 1 次 CPU 拷贝;sendfile 让数据全程不进用户态;配合 SG-DMA 可达 0 次 CPU 拷贝(真零拷贝)。「零」指零 CPU 拷贝(DMA 省不掉)。Kafka/Netty/Nginx 靠它高吞吐,Java 对应 transferTo/MappedByteBuffer。