零拷贝技术详解:mmap、sendfile与splice的适用场景对比
老实说我以前一直觉得“零拷贝”是个营销味很重的词好像只要把这四个字挂在架构图上性能就能翻倍。直到有一次做文件网关用strace数系统调用次数发现一次普通的文件转发内核居然把数据在用户态和内核态之间反复倒腾了好几遍我才认真去翻 Zero-Copy 相关的东西。回头看零拷贝不是什么黑魔法它就是把传统 I/O 路径上那些“明明可以不做的搬运工作”删掉。但怎么删、删完之后有什么副作用、什么场景才值得删这里面的门道比函数名复杂得多。这篇文章我就按自己的理解把零拷贝这条路上的几笔账、每个方案的适用边界和坑都拆开讲一遍看完你至少能在技术评审会上说清楚“为什么这里用 sendfile那里用 splice”。1. 传统 I/O 的四笔账先看清零拷贝在消灭什么想理解零拷贝的价值得先把传统 I/O 路径上的开销一笔一笔算清楚。很多人写网络服务上来就是 read 加 write代码很直觉申请一个用户态 buffer从磁盘读出数据再写进 socket。这个流程本身没什么错错的是我们对底层发生的事毫不知情。1.1 read write 路径上的隐藏搬运工假设你要把一个文件从磁盘完整地发给客户端。最朴素的写法大概是int fd open(data.bin, O_RDONLY); int sock accept(listen_fd, NULL, NULL); char buf[4096]; ssize_t n; while ((n read(fd, buf, sizeof(buf))) 0) { write(sock, buf, n); }看着就两步读、写。但内核里实际发生了什么粗略拆一下一次完整的 read write 循环里至少包含这几次搬运磁盘控制器用 DMA 把数据从磁盘扇区搬运到内核页缓存page cache这是第一次搬运CPU 不参与所以成本相对低。CPU 把内核页缓存里的数据复制一份到用户态 buffer这是第二次搬运也是 read 调用真正要干的事。代价是一块一块地把数据从内核地址空间拷到用户地址空间量越大越费 CPU。CPU 把用户态 buffer 的数据再复制到 socket 发送缓冲区这是第三次搬运对应 write 调用。网卡控制器用 DMA 从 socket 发送缓冲区把数据拿走发到网络上这是第四次搬运。也就是说你的业务代码只发出了一次 read 加一次 write数据却在内存之间整整搬了四次。其中两次是 CPU 全程参与的“手动挡”复制一次用户态到内核态一次内核态回用户态。这两次 CPU 复制是传统路径里最大的浪费。因为整条链路里压根没有任何人真正需要“在用户态看这份数据”你只是在中间搭了一个驿站把文件内容从磁盘挪到网口而已。既然没人需要看这个驿站就是纯开销。1.2 更扎心的隐性成本上下文切换和系统调用数据搬运之外还有一笔容易被忽略的账系统调用次数和上下文切换。上面的代码调用了 read 一次、write 一次。我故意把 buffer 设成 4096 字节假设文件是 100 MB那就是两万多次系统调用。每次 read 都要从用户态陷入内核态执行完再切回来每次 write 又是一轮。一次陷入 一次切回就是两次上下文切换两万多次循环就是四万多次切换。上下文切换不是免费的它涉及保存/恢复寄存器、刷新 TLB、调度器介入每次大概要几十到一百纳秒级别CPU 还有流水线惩罚。很多时候你以为是磁盘速度拖了后腿把strace打开一看热点全在read/write的系统调用开销上。所以零拷贝的第一层价值非常明确要么减少 CPU 参与的数据复制次数要么减少系统调用次数最好两者都减。后面所有方案本质上都是围绕这两个目标在做文章。1.3 DMA 是真正的幕后功臣先把它的概念固定下来讲零拷贝之前必须先把 DMADirect Memory Access这个概念固定住否则看 sendfile 的原理会一头雾水。DMA 的意思是数据搬运这件事可以由磁盘控制器或网卡控制器自己完成不用 CPU 一条指令一条指令地搬。CPU 需要做的只是告诉设备“你把数据从哪搬到哪搬多少”然后就可以去干别的事了设备搬完了再发个中断通知一下。I/O 设备用 DMA 直接访问内存这件事非常古老但它是零拷贝能不能成功的关键因为零拷贝最终要做的就是让数据在“设备到内核内存”和“内核内存到设备”之间移动时尽量靠 DMA 完成而不是靠 CPU 去 memcpy。如果 DMA 不存在任何零拷贝方案都撑不起来。理解了这个底层逻辑你再去看所有零拷贝方案就会觉得特别清晰所谓零拷贝并不是真的零次数据复制而是把 CPU 参与的复制减到零剩下的全是 DMA 在干活。系统开销从“CPU 满载搬数据”变成“设备自己搬、CPU 忙别的”吞吐量自然就上来了。2. mmap 方案省掉一次拷贝换来一堆新的开销零拷贝不只有 sendfile 一种实现。最容易被误以为“这不是零拷贝”的其实是 mmap。很多人在技术讨论里提到零拷贝只认 sendfile把 mmap 排除在外这是对零拷贝的一种误解。2.1 mmap 到底省掉的是哪一步mmap 的思路很简单既然数据在内核页缓存里已经有了用户态又有这么多人想看这份数据那不如直接把文件映射到进程的地址空间。这样文件内容在内存里的存在方式对用户态和内核态来说就是同一块物理内存。用 mmap 重写上面的转发逻辑大概是int fd open(data.bin, O_RDONLY); char *addr mmap(NULL, file_size, PROT_READ, MAP_SHARED, fd, 0); ssize_t n write(sock, addr, file_size);这里最明显的效果write 直接从映射出来的那块内存取数据丢进 socket 发送缓冲区。原来 read 阶段“内核页缓存 - 用户态 buffer”的这次 CPU 拷贝没了。因为你不再把数据复制到自己的 buffer而是直接把内核页缓存当成自己的内存来用。所以 mmap 的零拷贝是“半零拷贝”系统调用从 read write 变成了 mmap write少了一次 CPU 复制但 write 阶段仍然有一次 CPU 把数据从映射内存搬到 socket 发送缓冲区的操作。这个方案在真实项目里很有存量市场因为 Kafaka 的日志存储、RocketMQ 的 commitlog 读写底层都大量用了 mmap。它不像 sendfile 那么极端把用户态完全排除在数据路径之外而是保留了“我能看到文件内容”的能力——这一点对需要额外处理数据头的场景非常重要。2.2 mmap 的隐藏税page fault 和脏页回写但 mmap 不是无代价的。它把 read 的复制费省掉了却把一笔钱转移到了别处页错误page fault。文件映射到内存后并不是所有页码都在物理内存里。你访问到哪个页内核才把对应的页从磁盘加载进内存这个过程就是 major page fault / minor page fault。第一次访问文件数据的业务往往要触发一大批页错误每个页错误都要经历磁盘 I/O、缺页中断、建页表项开销一点也不小。还有一个容易被忽略的问题进程退出或有大量写入时mmap 映射的脏页需要回写到磁盘。如果文件特别大、内存又紧张脏页回写会把磁盘带宽吃得干干净净。很多用 mmap 做日志存储的系统在内存压力大的时候性能雪崩根源就在这里。所以 mmap 省掉的 CPU 复制换来的是更复杂的虚拟内存管理和 I/O 调度压力。它适合“文件长期驻留、读多写少、内存不是极端瓶颈”的场景不适合那种一次读完就扔的一次性转发。2.3 判断标准什么时候该选 mmap 而不是 sendfile我个人的经验是三点看齐需要随机访问和多次访问文件内容而不是一次性顺序读完。比如 Kafka 里既要追加写、又要随机消费mmap 让用户态可以直接定位到任意 offset 读取。需要看看数据、改改数据再发出去而不是原样转发。sendfile 完全不让用户态碰数据你没法加个自定义 header那就不如 mmap。能接受 file size 以内的映射。映射一个超大文件会占用大量地址空间32 位进程或者地址空间紧张的进程容易踩坑。如果业务只是“把磁盘文件原封不动扔给网络对端”我强烈建议不要硬用 mmap直接看下一节的 sendfile 就行。很多项目性能没提上去就是选错了零拷贝的“种类”把 mmap 用在一个根本不需要看数据的场景里白白承担了页错误和脏页回写的成本。3. sendfile真正的硬件级零拷贝但别忽略三条硬约束提到零拷贝大多数人先想到的就是 sendfile。它确实是最典型的“CPU 不碰数据”的实现所以我把它单独拎出来把原理和限制都讲透。3.1 sendfile 的数据路径到底长什么样sendfile 的系统调用原型是这样的ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它的真正价值在于只要 out_fd 是 socket并且内核和网络设备支持 SG-DMAScatter-Gather DMA数据路径就可以压缩成磁盘 - DMA 拉进页缓存 - 网卡 DMA 直接从页缓存拉走在这条路径上数据始终待在内核态用户态全程不参与。页缓存里的数据直接被网卡 SG-DMA 合并成报文发走CPU 只负责发指令和回收状态不负责逐字节复制。这个过程在内核里有个专门的实现叫 sendpage处理器利用网卡的 scatter-gather 能力把内存里不连续的页一次性发给网卡省去了把所有页拼进同一个缓冲区的那次昂贵复制。对实际业务来说sendfile 的直观收益就是从磁盘文件到客户端数据全程没有离开内核内存系统调用从 read write 两个变成 sendfile 一个。3.2 刚踩过的坑小文件场景下 sendfile 反而更慢也许有人会觉得sendfile 这么高效那所有静态文件服务都应该用。我一开始也是这么想的直到做了一个小文件批量转发的压测结果大跌眼镜几 KB 级别的小文件sendfile 比 read write 没有明显优势甚至略慢。原因不复杂。sendfile 的优化主要靠 DMA 把数据直接从页缓存拉给网卡但小文件往往刚 open 完还没进页缓存或者数据量小到网卡发一个包就够了DMA 设备的设置、中断处理、协议栈处理这些固定开销反而占了主导。而那些固定开销read write 也有但用户态可以活用 buffer 来分摊。所以小文件场景下sendfile 的真正对手不是读代码的直觉而是文件系统页缓存预读和协议栈聚合逻辑。小文件频繁转发性能瓶颈常常在打开文件、关闭文件、页缓存 miss 这些地方零拷贝能优化的那一层根本不显眼。3.3 sendfile 的三条硬伤每条都对应一个真实场景第一out_fd 必须是一个 socket。sendfile 的设计初衷就是“把磁盘文件内容尽快发到网络对端”所以它规定目标描述符必须是 socket。你要是想把数据从文件“零拷贝”到另一个文件sendfile 直接给你返回错误。这限制堵死了“文件到文件”的复制场景也让很多试图用 sendfile 做本地文件复制优化的人碰了一鼻子灰。第二数据对用户态是不可见的。你想在发送过程中插入几行日志、改个协议头、加个加密字段全部做不到。因为你压根看不到数据更别提修改。所有需要在传输路径上“动手脚”的业务sendfile 都无能为力。第三发送成功不等于发送完成。sendfile 返回时数据只是进了 socket 发送缓冲区或者说更精确点是进了内核的发送队列并不意味着客户端已经收到了。对需要精确确认对端已接收的业务这会造成语义上的困扰你必须在应用层另做确认否则字节流的安全性没有保障。这三条硬伤决定了 sendfile 的适用面静态文件服务、文件下载、视频流、CDN 回源这一类“原样转发”业务它是当之无愧的首选只要业务想让 CPU 看一眼数据它的优势就报废了。3.4 sendfile 和 splice 之间的关系一条容易被遗忘的进化线其实 sendfile 并不是所有 Linux 内核版本都支持从文件直接到 socket。早期网络设备和 SG-DMA 支持不普遍时内核退而求其次用 splice 的方式在内部把页缓存数据“黏”进 socket 发送缓冲区。后来硬件升级了真正的 SG-DMA 路径才成为主流。现在你在内核源码里看到的 sendfile 实现已经大量依赖 splice 的机制来拼接页。换句话说splice 并不只是 sendfile 的替代品而是后者在内核实现上的基石之一。理解这一点对接下来 splice 的应用场景会很有帮助。4. splice管道中转站打通任意 fd 之间的零拷贝很多文章讲完 sendfile 就收工了但实际项目里你会遇到一个尴尬文件到文件需要零拷贝怎么办两个 socket 之间需要零拷贝怎么办sendfile 直接拒绝。splice 就是为这个问题设计的。4.1 splice 的设计思路让内核缓冲区在管道里“流动”splice 的原型ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);它要求 fd_in 和 fd_out 中至少有一个是管道pipe。内核会为管道维护一个缓冲区splice 的工作就是把这两个文件描述符的数据在内核里通过管道缓冲区进行转移整个过程不经过用户态也不需要 CPU memcpy。你可以把 splice 理解为在内核态里安了一个“传送带”数据从一个文件描述符一端放上去另一端取出来。因为传送带本身就在内核内存中所以数据不需要绕道用户态这个遥远的仓库。它和 sendfile 最大的区别splice 的两个 fd 没有“必须一个是 socket”的限制。文件、socket、设备都可以只要管道作为中转站参与进来。也就是说文件到文件、socket 到文件、socket 到 socketsplice 都能处理。4.2 实战里最常用的 splice 场景代理和日志流我实际用过 splice 的典型场景有两个。一个是做流媒体转发服务。上游返回的 HTTP 流下游要转发给多个客户端。如果用 read write 实现数据每经过一次转发环节就要被拷贝好几遍用 splice 配合 pipe 做中转数据在内核里直接从一个 socket 流向另一个 socket系统开销肉眼可见地降了下来。另一个场景是日志聚合。边缘节点需要把本地日志文件持续传送到中心服务器。文件到 socket 的链路sendfile 也能做但如果中间还需要把同一份数据“即发送又留档”sendfile 就办不到了。splice 可以先把数据从文件拼到 pipe然后拆两路一路 splice 到 socket一路 splice 到存档文件。数据始终没进用户态但能一鱼两吃。4.3 splice 的代价和熟人之间的坑splice 也不是没有代价。最直接的问题是管道缓冲区。管道本身有容量限制splice 一次能搬的数据量受 pipe buffer 限制大文件传输往往要循环多次 splice 调用。而且每次 splice 的调用同样需要系统调用、上下文切换只是不再有 CPU 数据复制。还有一个很常见的坑flags 参数里的SPLICE_F_MOVE。我最初理解它以为是“移动整个页”后来翻内核文档才发现这个 flag 只是提示内核尝试复用页如果条件不满足内核可以默默忽略它并不会真的把页从源地址“夺走”。别指望它能带来跨越性的性能提升。splice 另一个隐蔽问题在于阻塞行为。当对端 socket 发送缓冲区满了splice 会阻塞在管道写端而不会像 read write 那样让你有机会对“这一批数据发了没有”有明确的控制。在并发连接数很高的代理服务里这可能导致线程被长时间卡住。我当时就是在这里栽的跟头后来加了一堆超时控制和判活逻辑才算稳住。5. 实战决策表什么场景该用哪种零拷贝以及如何验证原理讲得再多最后还是要落到“我这个项目到底用不用、用哪个”的问题上。我不会给你一个“所有项目都应该上零拷贝”的无脑结论但会按真实项目里最常见的几类场景给一张我自己在做技术选型时反复用的决策表。5.1 四类常见业务形态的选型参考业务形态典型代表推荐方案原因原样转发磁盘文件到网络静态文件服务器、视频流、CDN 回源sendfile数据路径最短用户态完全无关需要读改数据再发送网关签名、格式转换、自定义协议封装mmap 或 read write必须让 CPU 看到数据两个 fd 之间的零拷贝中转代理服务、流式转发、日志双写splice pipe超出 sendfile 能力范围高频小包转发RPC 报文、实时信令传统 read write 或 io_uring零拷贝的 DMA 优势被固定开销抵消这里要特别解释一下表格最后一行。很多人一听说 io_uring 就兴奋觉得它一定比 sendfile 高级。io_uring 引入了共享环形缓冲区来减少系统调用但它并不是传统意义上的零拷贝它更多是在解决“大量提交的 I/O 请求”时减少上下文切换和系统调用。如果你只是高频发小包零拷贝的收益可能并不明显倒是 io_uring 的批处理能力更匹配痛点。5.2 借 strace 和 perf 验证零拷贝是否真的生效技术选型不能靠“听起来快”能不能落地要看证据。我提供一套最简单的验证组合拳。先用 strace 统计系统调用次数。跑一次你的零拷贝代码然后strace -c ./your_app如果 sendfile/splice 方案生效你应该看到一个非常漂亮的数据sendfile 调用次数极少每次大块传输一次read 和 write 的调用次数基本归零。如果还是动辄几万次 read/write说明你的零拷贝根本没走通可能 fallback 到了传统路径。再用 perf 看 CPU 热点perf top重点观察有没有明显的 memcpy 相关符号在顶部。如果数据复制确实从 CPU 路径里消失了你会看到 CPU 开销更多集中在网络协议栈处理和 DMA 中断处理上而不是复制函数上。另外还可以对比两条路径的 CPU 利用率同一个文件分别用传统 read/write 和 sendfile 各转发一遍看 CPU 时间差。我做静态文件服务压测时sendfile 路径下 CPU 利用率能比传统路径低 30%~40%吞吐反而更高。这个数字没有普适性但那种“CPU 终于闲下来了”的体感是真实的。5.3 别高估零拷贝它不解决协议栈开销和磁盘 I/O最后一条经验也是我踩过很多次坑之后才真正想明白的零拷贝优化的是“内存和缓冲区之间的复制”但对协议栈开销、磁盘 I/O 延迟、网络拥塞这些更宏观的问题是无能为力的。如果数据库磁盘本身就是瓶颈文件在磁盘上连顺序读都跑不动那你用 sendfile 还是 mmap 根本没区别数据还没进内核页缓存就先卡在磁盘了。如果网络对端处理能力差发出去了也是对端瓶颈零拷贝的能力辐射不到对端。所以做性能优化别陷入“用了零拷贝就起飞”的执念。先用 profiler 看清楚瓶颈在哪一层是在数据复制是在系统调用次数还是在磁盘队列长度。只有确认在数据复制这一层零拷贝才是对症下药。我自己现在做方案的原则是先分层测再对症选。传输路径清晰、数据不需要用户态处理优先 sendfile需要用户态碰数据退而求其次 mmap需要跨越任意 fd 做中转上 splice小包高频场景老老实实考虑 io_uring 或传统路径。没有银弹但每一条路径都有自己的最优解。