资讯详情

零拷贝技术详解:从sendfile到splice的优化实践

📅 2026/10/9 12:31:30 | 华诺云谱 👁 阅读
零拷贝技术详解:从sendfile到splice的优化实践
1. 零拷贝到底在解决什么问题我第一次意识到拷贝是有代价的是在压测一个纯静态文件下载接口的时候。业务逻辑只有打开文件、读到内存、写给 socket三五十行代码怎么看都不该是瓶颈。结果每秒请求还没上去CPU 先飙到了 90% 以上整个服务的吞吐被硬生生按在地上。后来把性能剖析拉出来看消耗最大的既不是锁也不是业务计算而是数据在内存里被搬来搬去的那几次拷贝。零拷贝这个概念就是在回答这个问题为什么传输一份数据要反复经过用户态和内核态的复制能不能让数据从磁盘到网卡的过程中尽量少搬动、甚至完全不经过用户空间我先把结论放前面零拷贝不是一种具体的技术而是一类优化思路的统称。Linux 下最常见的落地手段包括 mmap、sendfile、splice以及新兴的 io_uring。它们的共同目标都是减少甚至消除不必要的 CPU 拷贝和不必要的上下文切换。这篇文章会从传统 I/O 链路讲起重点拆解 sendfile 和 splice 的用法、原理和坑最后给一份完整的对比与实测建议。适合所有写网络服务、文件服务、网关的同学阅读。1.1 一次最普通的文件转发数据到底走了几趟假设你写了个最简单的下载服务read 一个文件 fd再 write 到客户端 socket fd。这两行代码背后数据实际经历了四次搬运磁盘到内核页缓存Page Cache这一步由 DMA 完成不需要 CPU 参与数据进入内核的文件页缓存。内核页缓存到用户空间缓冲区read() 系统调用把数据从内核区复制到你的进程缓冲区。这一步是 CPU 拷贝因为你要看数据。用户空间缓冲区到内核 socket 发送缓冲区write() 系统调用把数据从用户区再复制回内核区socket 要发包就必须让数据回到内核。这一步同样是 CPU 拷贝。socket 发送缓冲区到网卡由 DMA 搬运不需要 CPU。如果把用户态和内核态的切换也算进来read() 和 write() 各产生两次上下文切换总共四次。也就是说你本想转发一份数据结果数据在内核和用户之间进进出出白白腾挪了两趟。1.2 两次浪费的 CPU 拷贝在哪里第 2 步和第 3 步是纯粹多余的。文件下载这个场景里用户进程根本不关心文件内容是什么数据不需要被修改、不需要被解析它就是一份要原样送到对端的字节流。可传统 read write 把数据从内核搬到用户空间再从用户空间搬回内核完全是为了过一道手而强制过一道手。这两次拷贝的代价不只是 CPU 执行复制指令的时间。搬运时还会污染 CPU 缓存导致后续指令和数据访存命中率下降频繁的用户态/内核态切换也会让 TLB 失效、增加调度开销。在连接数一多、文件一大的场景里这些开销会线性放大。文件 1GB就等于让 CPU 多搬了 2GB 的死数据。理解这个背景之后零拷贝的思路就非常顺了要么让数据别进用户态要么用硬件DMA代替 CPU 搬运。sendfile 和 splice 走的就是第一条路。2. sendfile最经典的零拷贝入口2.1 sendfile 的 API 长什么样sendfile 是 Linux 2.0 时代就有、2.4 内核开始支棱起来的系统调用设计目标非常直接把一个文件 fd 的内容原样送到一个 socket fd 上全程不经过用户空间。原型如下#include sys/sendfile.h ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);三个容易混淆的点我展开说一下in_fd 必须是文件类 fd底层得支持类似 mmap 的读操作。传统实现里它不允许是 socket旧文档甚至会直接报 EINVAL。out_fd 在 Linux 2.6.33 之前必须是 socket之后的版本放宽到任意 fd但绝大多数实际用法里它就是 socket。这个限制当年坑过我拿 sendfile 往管道里写数据老内核直接给你返回 -1。offset 参数不为 NULL 时它记录从文件的哪个位置开始发调用返回后会被内核更新成下一次起始位置从而实现分段发送大文件为 NULL 时使用文件当前的偏移量并且会移动它。一个最常见的下载服务写法int file_fd open(/data/app.bin, O_RDONLY); off_t offset 0; ssize_t remaining file_size; while (remaining 0) { ssize_t n sendfile(client_fd, file_fd, offset, 64 * 1024); if (n 0) { if (errno EINTR) continue; if (errno EAGAIN) { /* 非阻塞 socket 发送缓冲区已满等待可写事件 */ continue; } perror(sendfile); break; } if (n 0) break; remaining - n; /* offset 由内核自动更新 */ }注意如果 socket 设置了 O_NONBLOCKsendfile 在缓冲区满时会返回 EAGAIN你要等可写事件再重试语义跟 write 一样。这点很多人第一次写会漏结果一个慢客户端拖住整个线程。2.2 sendfile 在内核里省掉了什么传统实现下sendfile 的路径是这样的DMA磁盘到页缓存CPU 复制页缓存到 socket 发送缓冲区DMAsocket 发送缓冲区到网卡。所以 sendfile 在传统实现里依旧有 1 次 CPU 拷贝第 2 步但它把系统调用从 read write 两次压缩成一次上下文切换从 4 次变成 2 次用户空间的两进两出完全消灭。那么零拷贝到底零在哪里关键在于第 2 步还有救。如果网卡支持 SG-DMAScatter-Gather DMA分散/聚集 DMA内核可以把页缓存中的 page 地址和长度直接做成描述符交给网卡去抓数据网卡直接从页缓存里读取并打包发送。此时 CPU 一次都不用搬数据DMA磁盘到页缓存SG-DMA网卡从页缓存直接收集数据发送。这就是真正意义上CPU 零拷贝。现代数据中心里只要网卡和驱动支持 SG 能力这个路径是能走通的不支持的话内核会退化为一次 CPU 拷贝。所以严格来说sendfile 的 CPU 拷贝次数是 0 或 1不是绝对为 0。我补充一个容易被忽略的点sendfile 在小文件、低并发场景下收益并不明显系统调用开销占比高省掉的那点复制时间很容易被掩盖。它最适合的是高并发 大文件这种组合。2.3 sendfile 背后那些你见过的框架这个 API 在真实世界里用得远比你想的多。Nginx 的静态文件响应默认开启 sendfile on文件响应直接走 sendfile配合 tcp_nopush 效果更平滑。Netty 里的 FileRegion比如 DefaultFileRegion.transferTo()底层映射的就是 Linux 的 sendfile。Kafka 在向消费者传输日志段文件时也会调用 FileChannel.transferTo() 走 sendfile。你在 Java 里调用 FileChannel.transferTo()Linux 上最终就是 sendfile 系统调用。所以搞清楚 sendfile 的原理等于看懂了这些框架一半的文件传输优化逻辑。这也是我为什么说零拷贝是后端必懂的基础能力它不是冷门实验而是真实跑在生产链路里的优化手段。2.4 sendfile 的局限局限也很明显只能做原样转发不能对数据做任何加工。想压缩、加密、改报文sendfile 一概帮不上。文件必须能进入页缓存。如果你用 O_DIRECT 直读或者文件被反复改写导致缓存抖动优化会打折扣。旧内核上 out_fd 必须是 socket限制了它的通用性。一次调用能发送的字节数受限于 count 类型和系统偏移量位数大文件要循环调用不能一把梭。3. splice用管道把零拷贝做得更通用3.1 sendfile 不够用的时候就该 splice 出场很多人不知道Linux 2.6.17 之后sendfile 在内核里其实是用一个更通用的机制实现的——splice。splice 的设计目标是把任意两个 fd 之间的数据搬移注意是任意文件、socket、管道都行唯一硬性条件是至少一端得是管道。它的系统调用原型#include fcntl.h 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 分别对应输入和输出off_in/off_out 是各自的偏移量指针传入 NULL 表示使用 fd 当前的偏移。flags 有几个可选项常用的是 SPLICE_F_MORE提示内核后续还有更多数据可以合并发送和 SPLICE_F_NONBLOCK非阻塞。关键约束是两个 fd 中至少有一个要指向管道。splice 一次只能完成一段搬移你要做文件到 socket的完整转发就得先 splice 到管道再从管道 splice 到 socket两次调用。下面是典型的写法int pfd[2]; if (pipe(pfd) ! 0) { perror(pipe); } off_t in_off 0; while (in_off file_size) { ssize_t n splice(file_fd, in_off, pfd[1], NULL, 16 * 1024, SPLICE_F_MORE); if (n 0) break; ssize_t m splice(pfd[0], NULL, client_fd, NULL, n, SPLICE_F_MORE); if (m 0) break; /* n 个字节成功送达in_off 已由内核更新到下一个位置 */ }3.2 两个 splice 之间数据究竟存在哪第一次 splice 把文件页缓存中的 page 挂到管道缓冲区描述符上不是复制数据管道缓冲区里存放的是指向那个页的引用。第二次 splice 从管道读到 socket 时内核把 pipe_buffer 里的 page 直接转移到 socket 的 sk_buff 里同样不需要复制。所以理论上只要网卡支持 SG整条路径下面的 CPU 拷贝为零只有一个 DMA 搬运。很多文章把 splice 描述成通过管道搬运容易让人误以为数据在管道缓冲区里复制了一遍。实际上管道在这里承担的是页引用接力棒的角色传递的是 page 指针和引用计数不是把 1MB 数据从 A 格搬到 B 格。这也是 splice 和老的 sendfile 实现的本质差异。当然splice 也有代价管道缓冲区容量有限数据量一大还是要循环而且完整转发一个文件需要两次 splice系统调用次数比 sendfile 多。输入如果不是页缓存里的东西比如用户态内存零拷贝基本无从谈起。用户态内存得用 vmsplice但那是另一个故事坑更多。splice 的页引用机制要求数据在传输期间不能被改写。如果你同时在另一个线程写同一个文件要小心读到的内容和预期不一致。这个问题 sendfile 也有只是 splice 更隐蔽。3.3 splice 和 tee、vmsplice 组成的全家桶splice 经常和两个兄弟函数一起出现tee()把管道里的数据复制到另一个管道不消费原管道数据适合做同一份数据扇出给多个下游。vmsplice()把用户态内存页映射到管道之后可以让 splice 从管道里把用户页发出去。一个经典组合是vmsplice 到管道再从管道 splice 到 socket这样用户态数据也能免拷贝进入内核。但前提是你必须保证用户内存页在传输完成前不会被改。这个约束在生产上很致命我在项目里只在小范围日志场景试过最后因为要锁定内存页、影响 GC 之类的因素放弃了。3.4 splice 的适用判断我的经验是当你遇到 sendfile 搞不定的任意 fd 组合时——比如从一个 socket 转发数据到另一个 socket、要把文件内容塞进管道给下游模块消费——splice 是首选。但它不是银弹代码复杂度和出错概率比 sendfile 高一截。如果只是文件到 socket优先 sendfile真要追求极致的通用零拷贝还得看 io_uring 那套新东西。4. 零拷贝家族横向对比选型表与隐藏成本4.1 一张表看清各种方案下面这张表我整理过很多次直接在项目里当选型参考方案CPU 拷贝次数DMA 拷贝次数上下文切换次数是否需要用户态参与典型场景read write224是需要处理或解析数据mmap write124部分减少一次拷贝还能读写文件sendfile0 或 122否文件到 socket 大文件传输splice0 或 124两次调用否任意 fd 组合至少一端是管道io_uring02极低否通用高吞吐、零拷贝收发注sendfile/splice 的 CPU 拷贝是否为 0取决于网卡是否支持 SG-DMA 以及内核路径是否走页引用传递。老硬件上的现实情况是先按 1 来预期压测能上去了再开香槟。splice 完整转发一个文件需要两次系统调用所以上下文切换次数跟 read write 一样是 4 次但 CPU 拷贝少了很多。它真正的价值不在减少系统调用而在减少数据复制。4.2 mmap 为什么只省了一次拷贝很多人会把 mmap 也归到零拷贝阵营。它确实能让用户态直接读写页缓存省掉 read 系统调用的那一次内核到用户拷贝但当你 write 这个 mmap 区域到 socket 时内核仍然要把数据从那块内存储区复制到 socket 发送缓冲区CPU 拷贝还剩 1 次上下文切换还是 4 次。再加上 mmap 的隐藏成本第一次访问映射区域时会发生缺页异常如果映射的是大文件缺页带来的开销可能比省下的那次拷贝还大。所以 mmap 适合反复读、随机访问、需要改一部分内容的场景纯转发场景它不如 sendfile 干净。4.3 为什么上下文切换也值得较真有些同学觉得少了两次系统调用能省多少我把数量级摆一下一次用户态/内核态切换在 x86 上的开销大概在 0.5 到 2 微秒之间看起来不多但加上缓存污染和 TLB 失效高并发下会被放大。每秒成千上万个连接的网关场景里省掉几次切换可能就省出了可观的 CPU 时间系统调用次数减少也让追踪、seccomp、BPF 等内核链路上的附加负担更小。我不是说单纯比较微秒数。更准确的说法是零拷贝方案同时挤压了 CPU 拷贝、系统调用、缓存污染三个方向的成本三者共同作用才让它在高吞吐链路上表现出明显优势。只压测单连接发大文件可能差几个百分点并发一上来差距才会拉开。4.4 io_uring 是零拷贝的下一代答案io_uring 作为新一代异步 IO 接口也提供了零拷贝路径。比如 IORING_OP_READ 配合注册缓冲区固定内存页或者 IORING_OP_SEND_ZC 直接发送零拷贝网络数据。它把提交请求和收割结果做成两个环形队列绕开大量系统调用可以说是把零拷贝和异步 IO 结合到了一起。不过 io_uring 的学习曲线和内核版本要求都高很多生产环境还跑在 4.x 老内核上根本用不上。我的建议是新项目、新内核可以认真评估存量系统里sendfile splice 依然是性价比最高的零拷贝答案。5. 实测验证用 strace 和时间命令看零拷贝效果5.1 准备一个可控的测试环境理论说再多不如亲手验证一次。我的做法是准备两个小程序一个用 read write 转发文件一个用 sendfile 转发再通过本地 socket 接数据对比系统调用次数和 CPU 消耗。先造一个 1GB 的测试文件。我建议放到内存盘里测这样能排除真实磁盘 DMA 的影响更纯粹地观察系统调用和 CPU 拷贝mkdir -p /tmp/zc sudo mount -t tmpfs -o size2g tmpfs /tmp/zc dd if/dev/zero of/tmp/zc/test.bin bs1M count10245.2 strace 直接告诉我们系统调用的差距用 strace 跑一遍传统方案能看到大量 read 和 write 交替出现strace -c ./copy_read_write /tmp/zc/test.bin /dev/null输出里 read 和 write 各占一半系统调用。换 sendfile 版本之后再跑strace -c ./copy_sendfile /tmp/zc/test.bin /dev/null系统调用的种类变成了单一的 sendfile总次数明显下降用户态和内核态切换也跟着降下来。虽然 /dev/null 不是网络 fd拉不起真实协议栈开销但用来验证少走了几趟用户态这一步足够了。运行完把 strace 的结果截图存下来配合代码提交到团队文档里这个截图就是你理解零拷贝的第一手证据。5.3 时间维度上的真实感受真实网络场景里单次转发 1GB 文件到回环 socket两种方案在理想条件下的墙钟时间差距可能并不夸张——回环链路延迟低、页缓存命中率高read write 的 CPU 拷贝在绝对时间里占比没你想的那么大。但你把 CPU 密集度拿出来看会发现 sendfile 模式下的用户态 CPU 时间明显低因为 CPU 拷贝被移走了。我自己在 8 核虚机上压过下载接口并发 64 个连接轮流下载同一个 500MB 文件read write 版本的 CPU 总占用比 sendfile 版本高出 20% 以上尾延迟也更高。这个结果说明零拷贝的价值更多体现在同等硬件下腾出 CPU 资源给其他业务而不是简单缩短单文件的传输时间。5.4 一个容易被忽略的性能变量块大小不管用哪种方案每次传给内核的字节数count对性能影响都很大。count 太小系统调用次数多count 太大超出 socket 发送缓冲区后容易阻塞或触发多次内部循环。我习惯在 16KB 到 256KB 之间做网格测试选当前链路最优值。sendfile 的 count 与文件系统块和页缓存的对齐情况有关splice 的 len 和管道容量也强相关别一拍脑袋定成 1MB。6. 落地时会踩的坑先看这份清单6.1 sendfile 的 offset 和 EAGAIN第一个坑是 offset 用错。很多人直接传 NULL结果发送了一部分后文件偏移被改了下次想重新发就乱了。如果同一个文件 fd 要为多个连接服务一定要用独立的 off_t 变量通过指针传给内核别共享文件偏移。第二个坑是阻塞 socket 上 sendfile 可能长期阻塞在网卡慢、接收窗口满的情况下。生产服务务必加上超时和事件循环重试逻辑非阻塞模式下要处理 EAGAIN/EINTR。我见过有人把 sendfile 当作零拷贝所以不会阻塞来用结果一个慢客户端拖住了整个事件循环线程。6.2 splice 的管道容量和阻塞语义splice 两次调用之间数据在管道里躺着。管道容量有限默认 64KB 左右可以通过 fcntl(F_SETPIPE_SZ) 调大如果第二次 splice 迟迟不消费第一次 splice 返回的字节数会受限于管道剩余空间。所以循环条件是必须的千万别假设一次 splice 就能搬完全部。另外两端 fd 的阻塞属性会影响 splice 的返回一端非阻塞、另一端阻塞很容易出现数据被堵在管道里的情况。建议统一设置为非阻塞并用事件循环驱动不要在一个线程里同步死等。6.3 零拷贝与 TLS 加密的矛盾这是我认为最实用的一条一旦上了 HTTPSsendfile 和 splice 就基本失效了。原因很简单TLS 加密发生在用户态的 OpenSSL 或者内核 kTLS 里数据必须先经过用户态拿到明文或者反过来加密零拷贝的全程不进用户态前提不成立。Nginx 里开了 sendfile但后端子请求要 TLS 时实际会退化成非零拷贝路径这个细节经常被忽略。现在的解决方向有两个一是 kTLS让内核协议栈帮忙做加解密解密后再走零拷贝路径二是把静态内容做成预加密的 chunk客户端用对称密钥解密但这要求协议层面的特殊设计。普通业务项目我建议别执着于 HTTPS 下强行零拷贝把精力放在减少用户态非必要缓冲上效果也差不了太多。6.4 老内核、文件系统与监控的兼容最后提醒几件运维层面的事老内核RHEL 6 以前、Ubuntu 14.04 以前对 sendfile 的限制很多splice 的一些 flags 也不支持上生产前先确认内核文档。文件系统要是 O_DIRECT 直 IO数据不经过页缓存零拷贝优势基本没了。很多备份工具默认 O_DIRECT这时候别费劲调 sendfile。监控上零拷贝确实会让用户态 CPU 下降但你要配合 iostat、vmstat 和网卡 ethtool -S 看真实链路。之前出过一个诡异 caseCPU 降了但因为页缓存页被长时间引用内存回收反而变慢导致内存水位异常。排查时别只盯 CPU。在我自己的工程实践里最稳的组合拳是静态大文件转发优先 sendfile动态或任意 fd 转发用 splice两者都配合非阻塞事件循环和 64KB 左右的块大小先用 strace 验证系统调用路径再用压测确认 CPU 收益最后把截图和结论一起沉淀到团队文档。零拷贝不是一个需要炫技的优化它是在数据量大到一定程度之后一份必须掌握的基本功。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑