Theory · OS · Zero Copy

零拷贝

从 4 次拷贝到 2 次:mmap+write · sendfile · SG-DMA · splice —— Kafka 与 Nginx 高吞吐的内核基石

问题

文件发网卡的默认路径要 4 次拷贝 4 次切换——其中 2 次 CPU 拷贝纯属"数据路过用户态交学费"

方案谱系

mmap+write 省一次 · sendfile 省用户态往返 · SG-DMA 连 socket 缓冲都跳过 · splice 用管道中转

边界

"零"的是 CPU 拷贝与用户态中转——数据要改(加密/压缩)就得回用户态,零拷贝立刻失效

定位:OS 系列第十二篇收官。主线:以"拷贝次数 × 上下文切换次数"为坐标系,把四种方案排成一条演化线;边界意识(数据要处理就不能零拷贝)是应用层判断题的答案。

Baseline · 4 Copies 4 Switches

传统 read + write:四次拷贝、四次切换

传统 read 加 write 发送文件的四次拷贝路径 泳道图:用户进程调用 read 陷入内核,DMA 把磁盘数据拷进页缓存,CPU 再从页缓存拷到用户缓冲区,read 返回;进程调用 write 陷入内核,CPU 从用户缓冲区拷进套接字缓冲区,DMA 把数据拷到网卡,write 返回。合计四次拷贝、四次用户态与内核态之间的上下文切换。 磁盘 内核空间 用户空间 网卡 文件数据 页缓存 Page Cache 用户缓冲区 应用 buf 套接字缓冲区 socket buf 网卡 read() write() DMA 拷贝 ① CPU 拷贝 ② CPU 拷贝 ③ DMA 拷贝 ④ 切换 1:进内核 切换 2:回用户 切换 3:进内核 切换 4:回用户 浪费在哪:数据只在用户缓冲区"路过"了一趟(②③)—— 应用不修改内容时,这两次 CPU 拷贝 + 两次切换是纯开销 ②③ 是用户态↔内核态的搬运(CPU 参与、占 CPU 时钟);①④ 是 DMA 直接与设备交互(不占 CPU)——"零拷贝"省的是前一类
基准图必须讲透:4 拷贝(2 DMA + 2 CPU)+ 4 切换。浪费定位在"数据路过用户态"(②③)。DMA 与 CPU 拷贝的区分是理解所有后续方案的钥匙。

Step 1 · 3 Copies 4 Switches

mmap + write:让页缓存直接"就是"用户内存

机制

mmap 把文件映射进用户地址空间——用户指针与页缓存是同一块物理内存(虚拟内存 deck 的机制复用)。于是"用户缓冲区"不再存在独立副本:磁盘 → 页缓存(DMA)→ 套接字缓冲(CPU)→ 网卡(DMA),3 次拷贝、4 次切换

省了什么、剩什么

省掉"页缓存 → 用户缓冲"的那次 CPU 拷贝(数据不再进用户独占内存)。但仍有一次系统调用往返 ×2(mmap 不是零成本),且 write 的 CPU 拷贝还在。

适用与代价

适合"要读取文件内容做处理再发"的场景(比 read 省一次拷贝);代价:mmap 大量小文件映射开销、映射的写缺页、以及 munmap 后的 TLB 抖动。Ceph/Kafka 早期索引等场景用它。

// C 版骨架
buf = mmap(NULL, len, PROT_READ,
           MAP_SHARED, fd, 0);
write(sockfd, buf, len);  // CPU 拷 1 次
munmap(buf, len);

// 剩余拷贝:DMA 磁盘→页缓存 ①
//          CPU 页缓存→socket ②
//          DMA socket→网卡  ③
为什么不是终点:省掉一次拷贝,但"进内核两次"的结构没变——只要用户态还参与搬运,切换税就一分不少。真正的目标:让内核一口气把"文件→网卡"做完
面试金句:"mmap+write 用虚拟内存机制把'用户缓冲区'和页缓存合并成一块——省的是拷贝,省不掉的是系统调用。"
mmap+write 定位为"第一步优化":3 拷贝 4 切换。重点讲清"合并用户缓冲区与页缓存"的机制来历(虚拟内存 deck 联动)。局限引出 sendfile。

Step 2/3 · 2 Switches

sendfile:一条系统调用干完;SG-DMA 再砍一刀

sendfile(3 拷贝 2 切换)

sendfile(out_fd, in_fd, …)内核内把页缓存数据直接拷到套接字缓冲:磁盘 → 页缓存(DMA)→ 套接字缓冲(CPU)→ 网卡(DMA)。用户态完全不碰数据——切换从 4 次降到 2 次(一次 sendfile 进出)。

SG-DMA gather(2 拷贝 2 切换)

网卡支持scatter-gather DMA 时:套接字缓冲不再放数据,只放描述符(内存指针 + 长度),DMA 直接从页缓存"聚拢"数据到网卡——CPU 拷贝归零,只剩 2 次 DMA。这才是完整的"零拷贝"。

限制(面试必问)

① 数据对应用不可见——要改内容(加密/压缩/改 header 之外的 body)就不能用;② 早期 out_fd 必须是 socket(文件→网卡的方向);③ TLS 加密后失效(数据必须过用户态加密)。

// 一条调用替代 read+write
#include <sys/sendfile.h>
// out: socket, in: 文件
sendfile(sockfd, filefd,
         &offset, count);

// Nginx 配置开关:
// sendfile on;
// tcp_nopush on;  <-- 配合整包发送

// Java NIO 的同一机制:
// FileChannel.transferTo()
两次切换 vs 四次:每个请求省 2 次上下文切换 + 1~2 次拷贝,静态文件服务在高 QPS 下吞吐显著提升——这就是 Nginx sendfile on 与 Kafka 消费路径的理论收益来源。
面试金句:"sendfile 把'搬运工'全部留在内核;SG-DMA 让连内核的搬运都交给 DMA——零拷贝的终点是 CPU 手里没有数据。"
sendfile 2 切换 + SG-DMA 0 CPU 拷贝是本篇主菜。三限制(不可见/socket 方向/TLS)是应用判断题的题眼。Nginx/Java 的配置与 API 对应关系给工程锚点。

splice · pipe as Relay

splice:用管道在内核里"中转"的通用零拷贝

机制

splice(fd_in, off, fd_out, off, len, flags) 借助一个内核管道(pipe)做中转:数据在内核缓冲之间"移动"(实际是页引用转移),不经过用户态——任意两个支持管道操作的 fd 之间都能用(socket↔socket、socket↔文件、文件↔文件)。

两步 vs sendfile 一步

典型用法两次调用:fd_in → pipe,pipe → fd_out。代价是多一次中转的簿记;收益是方向不受限(sendfile 只能文件→socket),还能和 tee 配合做"一处读多处分发"(管道内容复制引用)。

工程现状

Nginx 的 tcp_proxy/stream 模块、一些代理与中继用 splice 做纯转发;但绝大多数场景 sendfile 已够用——splice 是"socket 到 socket 纯转发"场景的首选

方案拷贝(DMA/CPU)切换方向限制
read+write2 DMA + 2 CPU4任意
mmap+write2 DMA + 1 CPU4文件→任意
sendfile2 DMA + 1 CPU2文件→socket
sendfile+SG2 DMA + 0 CPU2文件→socket(需网卡支持)
splice2 DMA + 0 CPU2+(两次调用)任意(经 pipe 中转)
面试金句:"sendfile 是专线(文件→网卡),splice 是内核高速路网(任意端点)——代价是必须路过 pipe 这个中转站。"
冷知识加分:还有 copy_file_range(文件→文件零拷贝,跨文件系统视内核支持)与 tee(管道内容引用复制)——零拷贝家族在 Linux 里覆盖了所有方向的组合。
splice 的定位(任意方向 + pipe 中转)与总表给出全谱系对比。copy_file_range/tee 是家族冷知识,为 Go 视角页的 io.Copy 优化埋线。

When Zero-Copy Doesn't Apply

零拷贝的边界:数据一碰就要回来

核心判据:数据要不要被 CPU 改

纯转发(静态文件、消息原样投递、日志搬运)→ 零拷贝有效;要加密(TLS)、压缩、改写、解析 → 数据必须进用户态被 CPU 处理 → 零拷贝失效。TLS 是最常见杀手:HTTPS 服务器的 sendfile 收益被加密抹平。

Kafka 的真实取舍(高频)

consumer 拉取路径默认用 sendfile——但开启压缩或 SSL 后自动退回普通路径(数据要压缩/加密)。面试答"Kafka 用零拷贝"时要补上这个条件分叉,才是完整答案。

小文件的负收益

几 KB 的小文件:mmap 建映射 / sendfile 的固定开销可能超过省下的拷贝;且小块数据 DMA/CPU 拷贝本来就快。零拷贝是大块数据(MB 级)的最优解,不是万金油

与 Page Cache 的关系

零拷贝方案都仍走 Page Cache(sendfile 从页缓存取数据)——热数据命中缓存才零拷贝,冷数据首次读仍有磁盘 I/O。它优化的是"搬运",不是"读取"。

场景能不能零拷贝方案
静态文件 HTTPsendfile (+SG-DMA)
Kafka 明文消费sendfile
Kafka + TLS/压缩不能退回普通读写
HTTPS 静态站不能(加密)内核 TLS(kTLS)可恢复
纯转发代理splice
改写/压缩中转不能mmap 或 read+write
kTLS(版本敏感加分):把 TLS 对称加密下沉到内核(握手仍在用户态),数据在内核内加密后直接 DMA 出网卡——HTTPS 场景重新享受零拷贝(Nginx/FreeBSD 已支持,Linux 4.13+ kTLS)。
边界页是应用判断题的答案库:TLS/压缩/改写三大失效条件 + Kafka 条件分叉 + 小文件负收益。kTLS 是"零拷贝与安全共存"的现代解法。

Kafka · Nginx in Production

工业应用:Kafka 与 Nginx 的零拷贝路径

Kafka:为什么这么快(本篇视角)

三大内核红利之一:顺序写(日志段文件追加, Page Cache 预读友好)+ Page Cache 复用(不自己管缓存,热数据天然在页缓存)+ sendfile(consumer 拉取:页缓存 → socket 一条调用)。三者叠加 = 磁盘速度接近网络速度。

Kafka 的零拷贝触发条件

fetch 明文 + 无压缩转换时走 sendfile;开启 SSL 或压缩(broker 端解压)后退回普通路径。运维判断:看 broker 是否终结 TLS、compression.type 是否在 broker 端转换。

Nginx:静态资源的黄金组合

sendfile on(零拷贝)+ tcp_nopush on(攒满整包再发,配合文件大块传输)+ directio(超大文件绕 Page Cache 防污染,与第 10 篇 O_DIRECT 呼应)——三层开关各管一个文件大小段。

Kafka 消费路径的零拷贝链路 链路图:消费者发起 fetch,broker 从页缓存经 sendfile 直接把日志段数据送进网卡的套接字缓冲,DMA 传到消费端;全程数据不出内核,只有两次系统调用级别的切换。 消费者 fetch 请求 Broker sendfile 页缓存 日志段热数据 网卡 磁盘 顺序段文件 DMA 页引用直出 冷数据才读盘(顺序读) 磁盘速度 ≈ 网络速度 的错觉,就是这三层叠加的产物
面试金句:"Kafka 的高吞吐 = 把文件系统的美德全占了:顺序写喂饱预读、Page Cache 管热点、sendfile 省搬运——三层都建立在前面几篇的机制上。"
Kafka 链路图 + 触发条件分叉(明文/无压缩才 sendfile)。Nginx 三开关(sendfile/tcp_nopush/directio)按文件大小段分工。与 Kafka deck 的互链点。

io_uring · The Future

io_uring:把"异步"与"少系统调用"合体

痛点回顾

Linux 传统 AIO 名存实亡(仅 O_DIRECT、语义残缺);epoll 是同步语义(数据搬运还要自己 read);每个请求至少一次系统调用(提交 I/O 本身的开销)。想要真异步 + 低调用税——io_uring(5.1+,2019)应运而生。

机制:两个共享环形队列

内核与用户态共享内存:提交队列 SQ(用户放请求)与完成队列 CQ(内核放结果)。日常操作零系统调用:写 SQ ring → 内核后台消费 → 结果进 CQ → 用户收割;只在高水位时用 io_uring_enter 催一下。

能力与现状

覆盖文件/socket/定时器/带缓冲读写(IORING_OP_PROVIDE_BUFFERS 演化出的 buffer pool);配套零拷贝类操作(send zerocopy 6.0+)逐步落地。已在存储引擎(如新的 LSM 引擎)、高性能代理中生产使用——epoll + Reactor 的长期挑战者,但生态与调试成熟度仍需时间。

对比epoll + read/writeio_uring
等待就绪epoll_wait 阻塞CQ 通知(可等可轮询)
数据搬运用户自己 read/write(同步)内核完成(真异步)
每请求 syscall≥ 2 次可 0 次(SQ/CQ 共享)
生态成熟(Redis/Netty/Go)快速成长中
面试口径:"io_uring = 共享内存环形队列替代系统调用:提交与收割都走内存,真异步由内核完成——它是'少系统调用 + 真异步'两条优化主线的合流点。"
趋势判断(答题收尾):"epoll 仍是当下默认解(生态成熟),io_uring 是架构级未来(存储与网关先行)。面试报出两者分工即可展示视野。"
io_uring 页收束两条优化线:epoll 解决"等"(批量化),io_uring 合并解决"等+搬+调用税"。版本锚点 5.1(2019)/ 6.0 send zerocopy。不夸大替代论。

Go × Zero Copy

Go 的零拷贝:藏在 io.Copy 的分派里

io.Copy 的优化分派

io.Copy(dst, src) 不是傻瓜循环:dst 实现 ReaderFrom / src 实现 WriterTo 就走专用路径——TCPConn.ReadFrom 会检测 src 是否 *os.File,是则内核 sendfile(Linux),file→file 走 copy_file_range。写对接口 = 白拿零拷贝。

什么写法会"错过"优化

① src 被 bufio/compress 包装(类型断言失败,退回用户态循环);② src 是 bytes.Buffer/内存(本来就没有零拷贝可言);③ 中途 io.TeeReader 加日志——数据必须过 CPU,回到基线路径。

验证与场景

静态文件下载服务:io.Copy(w, f) 直通 sendfile(配合 http.ServeContent 底层同理);TLS 连接上自动失效(tls.Conn 没有 ReaderFrom 直通)——与第 6 页的边界完全一致。strace 看 sendfile 调用即可验证。

// 白拿零拷贝的正确姿势
f, _ := os.Open("video.mp4")
defer f.Close()

// TCP 明文:内部走 sendfile
io.Copy(conn, f)

// 文件 → 文件:copy_file_range
dst, _ := os.Create("copy.mp4")
io.Copy(dst, f)

// 错过优化的写法:
// io.Copy(conn, bufio.NewReader(f))
//          ↑ 包装后类型断言失败
http.ServeContent(标准库细节):静态文件服务的 Range/Content-Type 处理底层正是 sendfile 直通——Go 的 http 静态服务性能接近 Nginx 的原因之一。
面试金句:"Go 的零拷贝不是显式 API 而是接口分派——别包装 io.Reader,让 io.Copy 看到 *os.File,内核 sendfile 自动接管。"
Go 岗核心页:ReaderFrom/WriteTo 分派 + 三种"错过优化"的写法。ServeContent 细节与 strace 验证给面试演示抓手。与第 6 页边界(TLS 失效)呼应。

Interview QA

高频追问:零拷贝

1 · 传统 read+write 发文件有几次拷贝几次切换?

4 拷贝 4 切换

4 次拷贝:DMA 磁盘→页缓存、CPU 页缓存→用户、CPU 用户→socket 缓冲、DMA socket→网卡;4 次切换:read 进出 + write 进出。其中 2 次 CPU 拷贝对应数据"路过用户态"的纯开销。

2 · DMA 拷贝和 CPU 拷贝的区别?零拷贝省的是什么?

省 CPU 拷贝

DMA 是设备与内存直接搬运(不占 CPU 时钟);CPU 拷贝要占 CPU、消耗内存带宽。零拷贝省的是 CPU 拷贝与用户态中转——"零"指 CPU 手里不再过数据,不是没有任何拷贝。

3 · mmap+write 和 sendfile 各省了什么?

3/4 vs 3/2

mmap+write:省 1 次 CPU 拷贝(用户缓冲与页缓存合一),4 切换不变;sendfile:把搬运收进内核,切换降到 2 次,仍有 1 次 CPU 拷贝;加 SG-DMA 后 CPU 拷贝归零(2 次全 DMA)。

4 · 为什么 HTTPS 场景 sendfile 失效?怎么办?

加密要碰数据

TLS 要对明文加密——数据必须进用户态被 CPU 处理,零拷贝的前提(CPU 不碰数据)被破坏。解法:kTLS 把对称加密下沉内核,数据在内核内加密后 DMA 直出,重新零拷贝。

5 · Kafka 说用零拷贝,开启压缩后呢?

条件分叉

明文且 broker 端不做压缩转换时走 sendfile;开启 SSL 或 broker 端压缩后数据必须过用户态处理,自动退回普通路径。答 Kafka 零拷贝要带上这两个前提条件。

6 · sendfile 和 splice 怎么选?

专线 vs 通用

文件→socket 的单向场景用 sendfile(一次调用、路径最短);socket↔socket 或任意方向用 splice(借 pipe 中转、两次调用)。纯转发代理(不解析内容)是 splice 的典型主场。

7 · io_uring 解决了什么?和 epoll 什么关系?

真异步 + 零调用

epoll 只批量化"等待"(同步语义,搬运还得自己 read);io_uring 用共享 SQ/CQ 环把"提交 I/O"也省掉(可零系统调用)且搬运由内核完成(真异步)。关系:epoll 是当下主流,io_uring 是两条优化线的合流与长期方向。

8 · Go 里 io.Copy 怎么就零拷贝了?什么写法会失效?

接口分派

TCPConn.ReadFrom 检测到 src 是 *os.File 就走 sendfile,file→file 走 copy_file_range。失效:src 被 bufio/compress 包装、tls.Conn 中转、或中途 TeeReader——类型断言失败退回用户态循环。

八题覆盖全篇:基准账、DMA/CPU 区分、三方案对比、TLS 边界、Kafka 条件、splice 选型、io_uring、Go 分派。第 2 题的"零≠无拷贝"是纠偏题。

OS Series · The Big Picture

12 篇收官:一张图串起整个 OS 系列

OS 十二篇知识地图 知识地图:底层是操作系统总览(内核态与系统调用);CPU 分支包含进程线程、调度、IPC、同步与死锁;内存分支包含虚拟内存、页面置换与内存布局;文件与 I/O 分支包含文件系统、I/O 模型与本篇零拷贝;三条分支都通向 Go runtime 的对应实现。 os-overview:内核态 · 系统调用 · 中断 一切机制的总闸门 CPU / 进程管理 process-thread:进程线程协程 process-scheduling:调度 CFS/EEVDF ipc:六大通信方式 thread-sync:锁与原子指令 deadlock:四条件与四策略 内存管理 virtual-memory:分页/TLB/缺页 page-replacement:OPT→LRU→Clock memory-layout:布局与 malloc 一致性主线:隔离 → 按需 → 批发零售 文件与 I/O file-system:VFS/inode/fsync io-model:select→poll→epoll zero-copy:本篇(搬运的极限) 一致性主线:等待 → 搬运 → 落盘 Go runtime:GMP(用户态调度)· mcache/mheap(用户态分配器)· netpoller(epoll 封装)· sync.Mutex(futex 快慢两路)
收官地图:三条分支各自的一致性主线(隔离→按需→批发零售;等待→搬运→落盘)+ 底部 Go runtime 汇合层——与各 deck 的 Go 视角页闭环。

Related & References

相关知识点与参考

OS 系列(本分类)

I/O 模型与 epoll →(本篇的"等待"前传)
文件系统与 Page Cache →(数据从哪来)
虚拟内存 →(mmap 方案的机制地基)
操作系统总览 →(系统调用切换税的出处)

跨领域联动

Kafka 底层原理 →(零拷贝 + 顺序写的完整故事)
Redis 线程模型 →(小对象场景为什么不用零拷贝)

参考来源(本 deck 结论可溯源至下列一手材料)

man 2 sendfile / splice / copy_file_range / mmap各方案语义与限制(out_fd 方向、pipe 中转)的一手定义
OSTEP ch.36(I/O 设备)+ CSAPP ch.10DMA 与拷贝路径的机制基础
kernel Documentation/driver-api(SG-DMA / kTLS)scatter-gather 与内核 TLS 的支持条件
Apache Kafka 文档(SSL/compression 与传输路径)零拷贝触发条件的工程确认
io_uring 官方指南(Axboe)+ kernel 6.x send zerocopySQ/CQ 机制与零拷贝发送的最新进展
Go src/internal/poll(sendfile/copy_file_range 分派)ReadFrom 直通路径的源码依据
xiaolincoding.com《图解系统》zero_copy.html四种方案拷贝次数对比的中文叙述
收尾:man 2 家族是语义正源,Kafka 文档是触发条件的一手确认。总页数 12,OS 全系列 12 篇完结。