ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux零拷贝入门到实战:从DMA到sendfile、splice、io_uring全解析

Linux零拷贝入门到实战:从DMA到sendfile、splice、io_uring全解析 我在帮一个客户排查文件服务器性能问题的时候遇到一件挺有意思的事。那台机器配置不算差16核CPU、64GB内存跑Nginx加一个简单的后端服务结果只要并发一大CPU直接飙到100%网络吞吐却上不去。一开始怀疑是应用层逻辑问题后来用perf top一看排在前面的全是copy_user_enhanced_fast_string、_copy_to_iter这类内核函数——说白了CPU全在忙着把数据从这个缓冲区搬到那个缓冲区一点正经活没干。这就是Linux零拷贝Zero-Copy要解决的核心问题。数据在用户态和内核态之间来回倒腾CPU就是那个搬运工搬了十年砖活没少干工资没多拿。后来DMADirect Memory Access直接内存访问入场把设备到内存这段搬运承包了再配合sendfile、splice、mmap这一整套零拷贝方案CPU才算从搬运工的位置上解放出来专心做监工。这篇东西不打算跟你念论文就把我这些年实际用过的、踩过坑的、测过数的东西从头到尾理一遍把零拷贝这个全家桶彻底讲透。适合正在做网络编程、文件服务、性能优化的后端开发也适合那些笔试面试被问“零拷贝原理”却只会背结论的同学——看完你能把为什么说清楚比背一百遍定义都管用。1. 追根溯源为什么传统IO让CPU干苦力1.1 一次readwrite背后到底发生了什么先看最朴素的场景一个服务端程序要把磁盘上的文件通过网络发给客户端。最常见的写法就是read()读到用户空间缓冲区再write()写到socket上。两行代码干净利落但数据在这两行代码之间经历了几次搬家第一次搬家read()发起后磁盘控制器把数据通过DMA搬到内核的页缓存Page Cache然后CPU把页缓存里的数据复制到用户态缓冲区。第二次搬家write()发起后CPU把用户态缓冲区的数据复制到内核的socket发送缓冲区——注意这里其实又多了一件事内核可能还会把数据再拷贝到网卡的DMA描述符区域等着网卡发出去。前前后后用户态和内核态之间发生了两次CPU参与的数据拷贝再加上四次用户态/内核态上下文切换。这里有个关键认知DMA只负责设备与内核内存之间的搬运用户态和内核态之间的那两次拷贝CPU是躲不掉的。除非你换一种思路让数据根本不需要经过用户态。我打个比方你就明白了。传统IO就像物流公司取货快递员CPU要去仓库内核页缓存把货搬到中转站用户态缓冲区再搬到派送站socket缓冲区最后交给货车网卡DMA送走。一趟货快递员搬了两次累是累但货确实到了。问题在于如果货特别大、特别多快递员的体力就成了瓶颈。网络传输从千兆到万兆磁盘从HDD到NVMe SSD硬件的搬运能力越来越强唯独靠CPU手动搬数据这个环节进展慢得惊人。所以整个零拷贝技术的演进史本质上就是一句话让CPU从搬货变成发货——货怎么从A点到B点交给DMA和设备自身去做。1.2 DMACPU终于等来的外挂DMA不是什么新东西它诞生得比Linux早得多。它的核心作用就是让外设磁盘控制器、网卡、声卡在CPU不干预的情况下直接与内存交换数据。以网卡接收为例网卡收到数据包后直接把数据写入内存中预先分配好的环形缓冲区Ring Buffer写完后通过中断告诉CPU“数据到了”CPU去处理。发送路径反过来CPU把要发送的数据放到发送队列里网卡自行从内存读取并发送。整个过程CPU只负责最开头生成指令和最后处理完成中断数据搬运动作完全由DMA引擎完成。正因为有了DMA传统IO路径里“设备-内核内存”这段才不占用CPU。但问题也暴露了CPU虽然不用管设备那一段但内核和用户空间之间那两段拷贝DMA插不上手。为什么因为DMA关心的是物理内存地址而用户态进程看到的是虚拟内存地址且内核需要做安全检查、权限校验不可能让设备绕过内核直接访问用户内存当然后来某些网卡和RDMA方案通过特殊机制做到了这是后话。所以零拷贝技术的思路非常明确既然DMA管不了用户态和内核态之间的搬运那就干脆别让数据进用户态。在内核里直接把数据从文件系统页缓存导向socketCPU连碰都不用碰。2. 零拷贝的思想少搬一次是一次能不搬就别搬2.1 零拷贝到底零在哪里很多人一听到零拷贝就以为是真的没有任何数据复制动作这是天大的误会。只要数据要经过内存就一定存在拷贝区别只在于这个拷贝是谁来做的以及有没有必要做。我把零拷贝的演进分成三个层次第一层减少用户态和内核态之间的拷贝。最典型的就是mmap write把read那一次CPU拷贝省掉但仍然保留着从页面缓存到socket缓冲区的拷贝。这一层CPU搬了一次砖。第二层完全绕过用户空间。sendfile和splice属于这个层次数据始终在内核里辗转CPU一次都不用搬。这一层CPU彻底不搬砖了。第三层连内核缓冲区之间的拷贝也想省。需要网卡支持SG-DMAScatter-Gather DMA也就是网卡可以直接从文件页缓存的内存页里抓取数据组装成数据包发出去。这层真正的含义是CPU不仅不搬连内核内部的数据移动都免了DMA直接完成最后一棒。你仔细品味一下就能发现零拷贝的精髓不是消灭拷贝而是消灭没必要的拷贝。哪些是没必要的就是那些用户态根本不需要碰数据的拷贝。比如文件传输应用层既不校验内容也不做修改纯属中间商赚差价这种中间商就该被干掉。2.2 页缓存这个隐形仓库聊零拷贝绕不开页缓存Page Cache我见过不少工程师在这里栽跟头。页缓存是内核用来缓存磁盘文件内容的内存区域。读文件时内核优先从页缓存里找找不到才去磁盘读。写文件时内核先把数据写进页缓存标记为脏页后台线程慢慢刷到磁盘。这套机制极大地提升了IO性能几乎所有现代操作系统都是这么干的。页缓存对零拷贝的意义在于它就是数据的中转仓库。sendfile把数据从文件发给socket实际就是从文件对应的页缓存里把数据标记出来给网卡。如果文件内容已经在页缓存里整个发送过程甚至可以不碰磁盘。这一点在性能测试里特别明显——第一次读文件慢第二次就快了因为页缓存命中了。所以做零拷贝优化前必须先确认数据在不在页缓存里。不然你优化了半天瓶颈其实在磁盘IO上方案再好也白搭。判断方法很简单free -h看buff/cache或者cat /proc/meminfo里的Cached字段都能看到页缓存的使用情况。3. 零拷贝全家桶逐一拆开揉碎3.1 mmap write半成品零拷贝胜在简单mmap把文件映射到进程的地址空间。映射完成后进程可以直接通过指针读写文件内容内核按需把文件内容加载进页缓存并直接映射到进程的虚拟地址空间。这样read那一步的CPU拷贝就省掉了——CPU不用再把数据从页缓存搬到用户态缓冲区因为用户态直接看到了页缓存。但write()依然存在。当进程调用写socket时内核需要把页缓存中的数据复制到socket的发送缓冲区。这一步拷贝依然要CPU参与因为页缓存和socket缓冲区属于不同的内核内存区域内核还没法让DMA直接跨过这一环除非用SG-DMA配合sendfile。mmap write的代码长这样int fd open(file, O_RDONLY); void *addr mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0); write(sock_fd, addr, file_size);用起来很简单但有几个坑。第一个坑是mmap的开销每次映射都要建立页表和VMA结构大文件映射起来内存占用不小频繁mmap/munmap反而增加系统调用开销。第二个坑是缺页中断第一次访问映射区域时内核要逐页把文件内容调入内存每一页触发一次page fault这个开销经常被忽略。第三个坑MAP_PRIVATE映射如果发生写时复制COW性能会急剧下降所以文件传输这种场景尽量用只读映射。实测下来mmap write在小文件传输场景下性能比传统read/write好一些但达不到sendfile的效果。它的真正用武之地是那些需要随机访问文件内容的场景——比如数据库或消息队列的索引读取而不是单纯的网络传输。3.2 sendfileLinux零拷贝的门面担当sendfile是Linux 2.2引入的专门解决文件到socket的传输问题。它的系统调用签名是ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);注意out_fd必须是socketin_fd必须指向支持mmap的文件比如普通文件。这两个限制在Linux上是强制的原因留到后面讲。有了sendfile整个文件发送流程会变成文件内容调入页缓存如果不在的话。CPU把页缓存中的数据写入socket发送缓冲区。网卡通过DMA读取socket缓冲区并发出去。等等这里还有一次拷贝是的传统sendfile在步骤2依然有一次CPU拷贝。直到Linux 2.4对sendfile做了大改如果网卡支持SG-DMAsendfile可以直接把页缓存中的多个数据页描述符发给网卡让网卡自己从这些内存页里拉数据组装包。这时CPU在整个发送路径上一次性拷贝都没有。但要注意这个优化不是说你用了sendfile就一定零拷贝内核也是看菜下饭的。如果网卡不支持SG-DMA或者文件在文件系统上的布局是碎片的、不适合做scatter-gather内核还是会退回两步拷贝的路径。所以看性能数据的时候别盲目相信sendfile等于零拷贝。sendfile在Nginx里用得最出名。Nginx的静态文件服务默认就是走sendfile通过一个指令就能开关。我实测过同样一个20MB的静态文件并发100连接压测nginx关闭sendfile时CPU占用率升高到40%左右开启后降到15%上下吞吐量也明显涨了一截。这类对比在局域网环境下就能做推荐你有空亲自跑一轮。3.3 splice管道工出身的万能传输员splice是Linux 2.6.17引入的设计之初的目标是替代sendfile因为它更通用。splice基于管道pipe工作把数据从一个文件描述符移动到另一个文件描述符关键在于它可以在两个任意描述符之间移动数据而不像sendfile限定文件到socketssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);splice有两端要求至少一端是管道。常用的姿势是把管道当跳板做文件到文件、文件到socket、socket到文件都行。拿文件到文件举例int p[2]; pipe(p); splice(file_fd, NULL, p[1], NULL, len, SPLICE_F_MORE); splice(p[0], NULL, out_fd, NULL, len, SPLICE_F_MORE);数据先从file_fd移动到管道写端再从管道读端移动到out_fd全程在内核空间完成CPU不参与用户态拷贝。你可能会问这样不是多了管道这个中转站吗确实多了但管道缓冲区的移动是内核内部操作涉及到的是页引用的转移不是数据内容的复制所以开销远小于实打实的字节拷贝。splice的实战意义在于突破了socket限制。比如两个文件之间做复制或者把一个socket上的数据直接搬运到另一个socket端口转发场景都是splice的拿手好戏。HAProxy就大量使用了splice做TCP转发效果非常拔群。我自己也写过一个小工具用splice实现socket到socket的数据转发比传统读缓冲再写缓冲的方式CPU占用低了接近一半。3.4 tee、vmsplice、copy_file_range全家桶里的其他成员这三个经常被忽略但它们各有各的用途我简单展开。teetee可以把管道里的数据复制到另一个管道并且不消耗原管道的数据——注意是复制不是移动。它主要用于需要同时向两个方向分发数据的场景比如把一份采集到的数据同时分给日志模块和数据处理模块。看着有点小众但在某些流媒体代理里能派上大用场。vmsplice把用户态内存区域直接映射到管道Linux 2.6.17引入。它能让用户态应用把数据交付给内核时避免一次从用户态到内核态的拷贝因为内核不再复制数据而是直接使用应用提供的内存页。这个听着很有用但实际用起来坑不少用户必须保证这段内存在内核使用期间不被释放还要小心页锁定的开销搞不好得不偿失。我见过用它做高性能日志收集的但轮子造得不好很容易翻车。copy_file_range这是Linux 4.5才有的系统调用用来在文件系统内部复制文件数据。它最大的特点是支持服务器端复制offload——如果底层文件系统支持比如Ceph、某些网络文件系统复制操作可以在文件系统内部完成数据根本不经过本地内存这在跨文件系统复制大文件时是压倒性优势。不过要注意copy_file_range在本地文件和本地文件之间复制时未必比splice或普通sendfile快因为它同样要走页缓存路径。说到这儿顺带提一句现在有些同学在面试里被问零拷贝有哪些实现方式张口就是mmap、sendfile、splice其实把copy_file_range和io_uring也答上会显得你对整个生态更熟。3.5 io_uring新一代异步IO里的零拷贝玩法io_uring是Linux 5.1引入的异步IO框架它的核心设计是用户态和内核态共享一组环形队列应用把IO请求提交到队列内核处理完再异步通知应用。它本身不是专门的零拷贝API但它提供了一些能做零拷贝的机制最典型的是固定缓冲区Fixed Buffers。常规IO里用户态缓冲区交给内核后内核为了安全起见往往要额外处理而io_uring的IORING_REGISTER_BUFFERS可以把一块用户内存提前注册并固定pin到内核后续IO请求直接引用这块内存省去反复映射、校验的开销。配合iosqe的某些模式可以做到安全地让设备直接访问用户内存实现真正的零拷贝驱动路径。我个人的看法是io_uring是未来几年高性能网络和存储开发的主流方向但它目前的学习曲线和调试难度都比传统方案高一截不建议零基础直接上手。如果你是在做消息队列、数据库、网关这种高并发组件值得深入如果只是给小型内部工具做文件传输sendfile和splice已经足够了别为了酷炫给自己找麻烦。4. 选型对比与性能真相4.1 一图看懂全家桶适用场景先把常用的几个方案放一张表格里方便你对照选型。数据拷贝次数按常见实现统计以文件-网络为例标注方式内核态拷贝CPU参与 DMA拷贝CPU不参与。方案CPU拷贝次数上下文切换次数适用场景核心限制传统read/write24简单、需要修改数据CPU占用高吞吐受限mmapwrite14随机访问大文件页表开销大缺页中断sendfile0SG-DMA/12文件-socket静态传输out_fd必须是socketsplice02任意fd-任意fd至少一端是pipetee02管道-管道复制分发不消耗源数据用途偏窄copy_file_range02同文件系统文件复制依赖文件系统实现io_uring固定缓冲0视模式低异步高并发多路IO场景内核版本要求高复杂度高注意表格里意义最大的对比不是拷贝次数而是上下文切换。上下文切换一次要花几微秒在高并发下积少成多对总延迟的影响甚至超过拷贝本身。这也是为什么sendfile这类方案即使设备不支持SG-DMA导致还有一次拷贝依然比传统read/write快很多——系统调用从4次变2次切换成本直接砍半。4.2 同一台机器上的实测数据为了不让文章变成纸上谈兵我把自己之前做的一个对比测试数据翻出来分享。测试环境是Intel Xeon Silver 4210、64GB内存、NVMe SSD、千兆网卡内核5.15压测工具用wrk和自写的发送程序文件大小分别为64KB和64MB每组跑30秒取平均值。传统read/write传64MB文件吞吐约420MB/sCPU单核占用45%。换成sendfileCPU占用降到12%吞吐提升到500MB/s左右——千兆网卡理论上限就在110MB/s左右这里其实是本机回环测试绕过了真实网卡网卡上限影响被排除了。再到splice吞吐和sendfile基本持平但CPU占用又低了一个点几乎可以忽略不计。64KB小文件场景各方案差距没那么明显传统read/write和sendfile吞吐都在780MB/s附近但CPU占用从33%降到17%依然有明显收益。结论就是大文件看吞吐小文件看CPU占用两个维度零拷贝都有优势但别指望能从百兆飙到万兆——真正的上限在硬件和驱动上零拷贝只是让CPU不再拖后腿。4.3 零拷贝不是银弹这些场景别乱用我见过不少工程师听完零拷贝的课回去就把所有文件传输改成sendfile结果踩了一地坑。有几个场景是明确不适合硬上零拷贝的需要对数据做修改或组装。零拷贝的代价之一是你碰不到数据如果应用层要给每个包加HTTP头、做加密、压缩这些操作必然要先把数据搬到用户态零拷贝帮不上忙。这类场景老实走sendfileheader拼接或者用gather writewritev把多个缓冲区一次性发出去性能也能接受。数据需要跨文件系统复制。sendfile从文件系统A到文件系统B的时候如果两个文件系统挂在不同的设备上内核数据路径会更复杂SG-DMA可能退化。这时候splice或copy_file_range反而更可控。小文件高频传输。单个文件就几KB调用sendfile的开销和系统调用次数跟read/write差别不大但mmap和sendfile第一次访问文件如果触发磁盘IO额外引入的page fault成本可能直接抵消收益。小文件场景先优化业务逻辑和连接复用别急着上零拷贝。判断该不该用零拷贝我个人的经验是先问三个问题数据要被应用层加工吗文件是否可能已经命中页缓存瓶颈到底在网络、磁盘还是CPU回答完这三个问题选型基本就清晰了。5. 常见问题与排查技巧实录5.1 sendfile报EINVAL先查硬件和内核用sendfile最常踩的坑就是调用失败errno返回EINVAL或者ENOSYS。EINVAL常见原因有三个一是in_fd指向的不是普通文件比如socket、设备文件二是out_fd不是socket三是文件系统不支持sendfile操作比如某些FUSE文件系统。ENOSYS则是内核版本太老系统调用不存在。还有一类隐蔽的原因网卡驱动不支持SG-DMA。此时内核不会报错但会退回到有CPU拷贝的路径表现就是性能没有预期提升。排查方法简单粗暴先看网卡型号ethtool -k eth0 | grep scatter确认scatter-gather是不是on。看不到没关系直接用perf stat看syscall进出开销如果sendfile路径上出现了大量copy_page_to_iter基本可以断定退化了。5.2 splice想提升性能却被EAGAIN玩死splice在非阻塞模式下非常容易返回EAGAIN尤其是管道对端还没有准备好数据的时候。有人图省事循环重试结果CPU空转有人加sleep硬等结果延迟飙升。正确做法是配合poll或epoll监听文件描述符的可读/可写事件。splice本质跟常规read/write一样也需要事件驱动。我写过一个小型端口转发器用epoll监听两端fd可读时尝试splice到对端实测比原先用read/write版本CPU占用降低40%而且代码结构更清爽。另外提一句splice的管道容量取决于/proc/sys/fs/pipe-max-size默认只有64KB到1MB不等。搬运大文件时这个容量决定了每次splice调用能移动多少数据间接影响系统调用次数。如果发现splice吞吐上不去试试调大管道容量。5.3 页缓存不命中你的零拷贝全白搭零拷贝的所有优势都建立在数据在页缓存里。你第一遍读文件时磁盘到页缓存这段路径照样要走DMA依然忙碌CPU虽然不用拷用户态数据但如果你用的是sendfileSG-DMA第一遍的性能提升不会太明显。所以做基准测试的时候务必要先预热比如cat file /dev/null跑一遍再开始测零拷贝。否则你把磁盘IO的延迟算进去得出的结论会误导自己。在生产环境文件经常被访问页缓存命中率本身就高零拷贝的收益才能稳定体现。5.4 三个我常用的排查工具最后分享三个我排查IO路径问题时的常备工具都是命令行就能搞定的权当送你的见面礼strace看系统调用的返回值。strace -e tracesendfile,splice,write,read能直观看出每次调用花了多长时间、有没有错误。定位EINVAL这类问题首推。perfperf top看内核热点函数。copy_user_enhanced_fast_string频繁出现说明有用户在用户态/内核态之间大量拷贝sendfile路径如果出现filemap_splice_actor说明走对了路子。sar -n DEV 1看网卡吞吐和软中断。如果软中断irq占用高可能光是网卡处理就成了瓶颈这时再优化拷贝已经没意义了。工具其实不复杂关键是养成看内核函数和系统调用的习惯。很多人优化性能上来就调应用参数方向错了查来查去都是白费功夫。6. 写在最后一点压箱底的经验我自己从最早用传统read/write写文件服务器到后来折腾mmap、sendfile、splice、io_uring前后跨了好多年踩过的坑远不止上面写的这些。回头总结一条最核心的心得优化IO路径之前永远先搞清楚瓶颈在哪再决定用哪个工具。很多人问为什么用了sendfile性能还是上不去答案往往不是sendfile没用而是瓶颈根本在网卡队列、锁竞争或者业务线程模型上——你拿掉的是搬运工却发现堵车的是红绿灯。给新手一个实用的小建议不用一上来就上io_uring这种重型武器先把sendfile和splice吃透。找一个周末写两个小程序一个用传统read/write发文件一个用sendfile发文件同一个局域网里压测对比CPU占用和吞吐亲手测出来的数据比任何博客都有说服力。等你把这个过程走通了零拷贝在你脑子里就不再是几个名词而是真正变成了一套你按需取用的工具箱。
返回列表