ARTICLE DETAIL

资讯详情

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

从管道到io_uring:Linux I/O演进与零拷贝技术全解析

从管道到io_uring:Linux I/O演进与零拷贝技术全解析 最近在review一块老服务代码时我又看到了那段熟悉的read write先把文件读到用户态buffer再从buffer写到socket。顺手改成sendfile之后同样是转发CPU占用肉眼可见地掉了一截。这事我干过不止一次但真正让我停下来把问题想明白的是那句追问这些系统调用——管道、mmap、sendfile、splice、io_uring——到底是彼此孤立的小工具还是Linux I/O演进史里一条连续的逻辑线答案是后者。这篇文章想把这条线完整拆给你看。它从最古老的管道开始一路走到今天的零拷贝与异步I/O几乎是服务端核心原语的完整串联。适合正在做后端、网关、代理、存储、中间件的开发者也适合准备把零散I/O知识体系化的朋友。理解这条线你在做技术选型时最大的收获是能准确说出为什么这个场景用这个原语、那个场景不用。1. 管道的诞生一个缓冲区怎么引发一场I/O范式革命管道是整个故事的起点。Unix诞生时有一个很朴素的设计哲学程序应该只做一件事并且把这件事做好程序之间通过某种机制组合起来完成更复杂的任务。那某种机制是什么当时最直接的需求是进程A吐出来的数据怎么流到进程B文件可以但文件需要命名、需要管理生命周期、需要处理并发写。功能太重。于是pipe()出现了。pipe()做的事情非常简单在内核里创建一块缓冲区返回两个文件描述符一个用来写一个用来读。写端往里丢数据读端按顺序取数据先入先出数据在缓冲区里逗留的时间极短。这个模型天然适合一个程序的标准输出接到另一个程序的标准输入这种场景也就是你今天依然在用的cmd1 | cmd2。而它也被称为匿名管道因为它没有文件系统中的名字只能被创建它的进程以及子进程使用。如果需要让任意两个进程通过路径找到管道就需要mkfifo创建的命名管道。管道真正深刻的地方在于它的阻塞语义缓冲区满了写端的write会阻塞直到读端取走数据缓冲区空了读端的read会阻塞直到写端送来数据。这在今天叫做背压backpressure也就是生产者不能无限快于消费者数据流的速率会被最慢的一环自动钳制。你去看现代消息队列、流处理框架、甚至TCP的滑动窗口骨子里都是同一件事缓冲区加阻塞等待。这是I/O演进史上第一次把内核缓冲区和等待这两个概念放到编程模型的中心位置后面所有的改革几乎都在跟这两个概念博弈。还有一个容易被忽略的细节是管道缓冲区的大小。Linux 2.6.11之前它是4KB也就是一个内存页的大小。后来调到了64KB还可以通过fcntl(fd, F_SETPIPE_SZ, size)调整。缓冲区大小直接影响吞吐一个4KB的管道写端每次写到4KB就阻塞一次意味着生产者和消费者之间来回唤醒的次数极多CPU开销大。64KB以后一次阻塞前能灌入的数据量翻了16倍唤醒次数大幅下降。今天你写shell里的复杂流水线感受不到这个区别是因为内核早就把它优化过了。有人可能觉得管道是上个世纪的古董。但你去翻现代服务端的底层管道依然无处不在systemd-journald靠管道接收日志进程管理工具靠管道通知子进程状态Nginx的master进程和worker进程之间也用了各种事件通知机制socketpair本质上就是管道语义的扩展。它没有退场只是变成了基础设施。我在第一章花了这么多篇幅讲管道是因为它是理解后续所有内容的起点。你记住两个关键词内核缓冲区和阻塞/唤醒。接下来传统I/O的低效、零拷贝的解法、异步I/O的进化全部围绕这两个词展开。2. 传统read/write的双重代价四次拷贝与四次切换现在进入服务端最常见的场景客户端请求一个文件服务端把文件从磁盘读出来通过网络发回去。绝大多数人第一版代码长这样char buf[64 * 1024]; while ((n read(file_fd, buf, sizeof(buf))) 0) { write(sock_fd, buf, n); }逻辑没错但它干了很多无用功。我们把一次完整的数据传输从磁盘到网卡拆开看数据一共被搬了四次磁盘控制器通过DMA把数据从磁盘搬到内核的page cache页缓存。这次搬运不经过CPUCPU不参与数据复制。内核把page cache里的数据复制到用户态缓冲区也就是你声明的buf。这一步是CPU拷贝。你的程序调用write内核把用户态缓冲区里的数据再复制到socket发送缓冲区。这一步同样是CPU拷贝。网卡通过DMA把socket发送缓冲区里的数据取走发到线路上。CPU不参与。第2步和第3步是纯CPU搬运完全冗余——因为你根本没改过这份数据只是让它从磁盘路过内存再走向网卡。不仅如此read和write各是一次系统调用每次系统调用都会有用户态到内核态、再从内核态回到用户态的切换。read write一轮就是4次切换。切换本身有开销还会污染TLB和CPU缓存后续指令执行能力跟着下降。量化一下成本一次用户态/内核态切换在普通x86服务器上是几百纳秒到一两微秒量级一次CPU拷贝则消耗内存带宽。单路服务器的内存带宽通常也就几十GB/s到一百多GB/s你每复制一份1GB数据就要消耗大约10~20ms的内存带宽资源。传统路径上数据被CPU复制了两遍这个带宽消耗直接转嫁到CPU头上。所以你的服务明明没跑多少业务逻辑CPU却烧得很高一查perf全是copy_user_enhanced_fast_string多半就是这种转发路径没优化。更关键的一点是服务端大部分场景下用户进程根本不需要看数据。它只是个搬运工数据是什么内容不重要重要的是别丢、别乱序、及时送到。既然不用碰数据为什么还要把它从内核搬到用户态再从用户态搬回去零拷贝技术的靶心就是这一进一出。当然page cache本身是有价值的。第一次读文件时数据从磁盘进page cache第二次读同一个文件就直接命中缓存避免重复走磁盘。所以传统read/write并非一无是处它真正浪费的是用户态中转这一步。有人会想到O_DIRECT绕过page cache让磁盘直接读进用户态buffer。但O_DIRECT把缓存和预读的收益也一起扔掉了在大多数服务端场景下得不偿失。正确的方向不是绕过缓存而是减少在缓存和用户空间之间的倒腾。从这一章开始你应该建立这样一个分析框架:一个问题场景先问数据需不需要经过用户态再问系统调用次数能不能减少。后续的mmap、sendfile、splice、io_uring本质都是在这个框架下做优化。3. 零拷贝三连mmap、sendfile、splice各自解决与遗留的问题3.1 mmap省掉一次CPU拷贝但换来了页错误零拷贝的第一次尝试是mmap。它把文件直接映射到进程的虚拟地址空间进程访问这段内存时内核按需把文件内容从磁盘加载到page cache并让page cache的页面直接出现在进程的地址空间里。于是一次read原本要做的内核page cache - 用户buffer的CPU拷贝就被省掉了因为用户态看到的内存本质上就是page cache那一页的映射。char *mapped mmap(NULL, file_size, PROT_READ, MAP_SHARED, file_fd, 0); // 直接对 mapped 做读取或通过 write(sock_fd, mapped offset, len) 发送省掉一次CPU拷贝听起来很划算但实际用起来有一堆代价。最典型的是缺页中断第一次访问刚映射的页面CPU会产生page fault内核才真正把磁盘数据读进来。读大文件时大量缺页会让首轮访问明显变慢。而且write从mmap区域往socket写数据仍然要做一次CPU拷贝从用户态地址空间把数据复制到socket buffer。所以mmap并不是完整的零拷贝方案它只是把四次搬运中的一次CPU拷贝省了。实践中我见过不少团队用mmap做文件共享和随机读效果不错但用它来做网络发送的不少都踩了坑。其中一个坑是文件被截断或删除时访问悬空映射会触发SIGBUS进程直接崩溃必须自己注册信号处理函数兜底。另一个坑是脏页回写时机不受应用控制如果你想确认数据真正落到磁盘还得靠msync或fsync。所以我会把mmap定位成适合把文件当内存来用的场景而不是网络转发场景的首选。3.2 sendfile让文件到socket变成一次系统调用真正直击文件发给socket这个服务端最常用路径的是sendfile。它把打开文件、循环read、write的全部业务逻辑收进一次系统调用off_t offset 0; ssize_t sent sendfile(sock_fd, file_fd, offset, file_size);在内核里sendfile直接把page cache中的页面引用挂到socket buffer上。网卡支持scatter-gather DMA时数据甚至不需要在内核里再做一次CPU拷贝网卡DMA引擎可以直接从page cache页面中把数据取走。这时数据路径变成磁盘DMA到page cachepage cache引用直接转移给socket buffer网卡DMA从page cache取数发出。全程CPU零拷贝只有一次系统调用也就是只有两次用户态/内核态切换。对比一下传统路径和sendfile路径差异非常清楚对比项read writesendfile系统调用次数21用户态/内核态切换42CPU拷贝次数20依赖SG-DMA数据是否经过用户态是否用户态能否修改内容能不能Nginx里有一个著名的sendfile配置项默认是开启的就是告诉内核静态文件直接用sendfile发别搬进nginx进程里再搬出去。对象存储、CDN源站、文件下载服务凡是从文件到socket的场景sendfile基本是第一选择。但sendfile有两个边界。第一它要求一端是可以被sendfile语义处理的文件描述符另一端传统上要求是socket所以从socket转发到socket这种代理场景它干不了。第二它仍是同步阻塞模型如果socket发送缓冲区满了sendfile会阻塞在系统调用里应用进程在这一刻无法处理其他连接。高并发下的解法通常是配合多路复用使用但它本身不具备异步能力。3.3 splice管道机制把零拷贝推广到任意两个fd既然文件到socket可以零拷贝那任意fd到另一个fd呢现实中大量场景是socket到socket或者文件到pipe到socket这种多段链路。Linux为此提供了splice。它做的事情和sendfile类似在两段文件描述符之间搬移数据不经过用户态缓冲区。int p[2]; pipe(p); // 把 in_fd 的数据移动到管道 splice(in_fd, NULL, p[1], NULL, len, SPLICE_F_MOVE); // 再把管道的数据移动到 out_fd splice(p[0], NULL, out_fd, NULL, len, SPLICE_F_MOVE);这里有个非常漂亮的回归管道又出现了。splice内部复用的正是管道的缓冲区机制——它先让你把一段fd的数据移动到管道再把管道的数据移动到另一段fd。移动的是页面引用不是页面内容所以CPU不参与复制。这正好呼应第一章管道不只是进程间通信工具内核还把它的缓冲区机制拿来当作通用零拷贝的传送带。splice比sendfile通用得多但代价是使用复杂。而且不是所有文件系统都支持splice语义我在实际项目里遇到过网络文件系统上报EINVAL的情况所以任何生产代码里使用了splice都必须准备一条read/write的fallback路径。如果说sendfile是专用快车道splice就是通用货运动脉功能强大但需要用的人对边界条件有充分认知。3.4 零拷贝的边界别把零理解成绝对零围绕零拷贝有个很常见的误解零拷贝等于完全不复制数据。严格说只有CPU拷贝才被消除了DMA搬运依然存在——网卡从内存取数据、磁盘从介质读数据这些物理层面的移动省不掉。更准确的叫法其实是CPU零拷贝它省下的是CPU时间和内存带宽不是数据没动。另外小数据量场景下零拷贝的收益并不突出。一次sendfile虽然只剩一次系统调用但任何系统调用的固定开销都不小如果一个连接只发几十字节调用开销占比很大。零拷贝在大块数据持续转发的场景里才真正拉开差距。反过来如果用户态必须修改数据、必须计算校验和、必须做协议封装那数据天然要经过用户态mmap也好、sendfile也好、splice也好都用不上老老实实read/write就行。记住判断原则先确认这段数据要不要被用户态加工再决定走哪条路。4. 从select到io_uring多路复用掩盖下的异步I/O终局零拷贝解决了不必要的数据搬运但I/O还有另一笔隐藏成本进程反复陷入内核等待。一个服务端连接通常极多而大部分连接在绝大多数时刻是空闲的。早期服务器用阻塞I/O一个进程只能处理一个连接这是c10k问题的根源。select出现后进程可以同时等一堆fd但select有两个硬伤默认的FD_SETSIZE只有1024连接一多就不够用每次调用都要内核全量扫描用户传来的fd集合连接越多线性开销越大。epoll补上了这两个洞。它在内核里维护一棵红黑树存放你注册的fd每个fd上挂回调某个fd就绪时内核把它加进就绪链表。应用程序只需要用epoll_wait取就绪事件复杂度从O(n)降到O(k)k是真正就绪的连接数。事件驱动模型从此成为Linux服务端的主流Nginx、Redis、Netty底层全都是这套机制。但如果你把用epoll写出高性能服务当成终点你会忽略一个事实epoll解决的是通知问题不是读写问题。它告诉你某个fd能读了、能写了但真正的read/write还是要进程自己发起还是要在系统调用里等待数据从内核复制到用户态。打个比方epoll相当于前台叫号通知你轮到你了但办理业务还是得亲自去窗口排队。在高IOPS场景下每次操作的系统调用开销依然可观而且调用发起后如果数据没就绪依然可能阻塞。异步I/O的终局是把提交请求和等待完成彻底解耦。io_uring就是Linux 5.1开始引入的完整解法。它建立了两个用户态和内核共享的环形队列SQSubmission Queue和CQCompletion Queue。你要读文件就把读文件这个请求扔进SQ然后立即返回继续干别的事内核处理完把结果写进CQ你下次来CQ收割就好了。配合SQPOLL模式内核线程甚至会自动轮询SQ连发起请求的系统调用都可以省掉实现真正意义上的全异步。io_uring还允许注册文件和缓冲区。注册文件相当于提前把文件描述符转换成内核内部索引注册缓冲区相当于提前固定一块用户内存的物理映射。这减少了每次I/O都做文件查找、内存映射的开销。而在零拷贝这条线上io_uring更进一步IORING_OP_SEND_ZC从Linux 5.19起可以提交一次零拷贝发送请求让用户态缓冲区直接被网卡DMA读取不需要先复制进内核的socket buffer。它和sendfile的区别是sendfile的源头是文件而SEND_ZC的源头是用户态内存它和普通send的区别是数据不用先考贝进内核。放置到整个演进史里看io_uring把异步化和零拷贝合并成了同一个框架前面几代原语积累的智慧在这里汇合。但io_uring不是银弹。它要求内核版本较新运行在审计、容器隔离、部分虚拟化环境中时可能触发兼容性问题或额外开销调试和性能剖析也远比select/epoll复杂。我的建议是如果你的服务已经用epoll加sendfile把I/O路径优化得足够好且IOPS没有成为瓶颈不必为了追逐新技术强行迁移io_uring。它属于高IOPS、高并发、长尾敏感的代理层和存储层属于那些单机需要扛几十万甚至上百万IOPS的场景。技术选型要看场景需求而不是看谁的声量大。5. 选型决策与踩坑实录生产环境里该怎么用这些原语5.1 一张选型表对应的场景把前面讲的整条演进史浓缩成一张决策表生产环境里我会这样选场景推荐原语理由进程内轻量通信、事件通知管道 / socketpair内核缓冲区天然带背压简单可靠用户态需要解析、加工、聚合数据read write 或带buffer的库函数数据必然要进用户空间省不掉的路径大文件随机读、多进程共享只读数据mmap按页加载省掉显式read适合当内存访问静态文件下载、文件→socket转发sendfileCPU零拷贝、一次系统调用收益最大socket→socket、通用fd间转发splice泛化sendfile适合代理类转发路径高IOPS、异步化、需要内核线程轮询io_uring异步提交/收割注册文件与buffer可进一步降开销选型时我会额外看一个因素数据量。单次传输几十KB以上的场景零拷贝收益明显每次只传几十字节的场景sendfile和splice的固定开销可能比read/write省下的还要多直接用read/write反而干净。5.2 我踩过的几个坑sendfile的offset不会自动更新。这是一个非常隐蔽的坑。sendfile的第三个参数如果是指针内核会更新它指向的值如果传NULL文件偏移量也会前进。但如果你自己维护offset做分片发送每次调用后必须重新读取或手动增加offset否则第二次发送会从同一个位置出发客户端收到的全是重复内容。我见过线上文件下载服务出现文件末尾多出一截的告警排查半天发现就是offset维护问题。mmap文件被截断会直接SIGBUS。文件映射到内存后如果另一个进程把文件截短再访问已经超出新文件大小的映射区域内核直接对这个进程发SIGBUS。在信号处理函数里做善后非常麻烦而且容易留下脏数据。如果映射的文件有被并发修改的可能尽量用pread代替或者做信号兜底并重新映射。splice在部分文件系统上直接返回EINVAL。这个问题在NFS、某些FUSE文件系统上比较常见。凡是用了splice的数据通路必须有EINVAL的降级分支回到read/write。我习惯在代码启动时先做一次自检用一个临时文件试一次splice如果返回EINVAL就设置全局flag运行时走fallback路径。零拷贝和落盘保证天然冲突。日志系统、数据库WAL这类场景核心需求是写成功数据已持久化。零拷贝擅长让数据更快地从A流到B它不负责把数据平安放到磁盘上。你用它优化日志路径表面上看吞吐上去了一旦机器断电缓冲区里那批数据全部蒸发。所以持久化场景老老实实write加fsync零拷贝留给那些数据丢了也没关系、临时转发的场景。io_uring的异步完成通知在用户态收割时也有顺序问题需要小心。多个请求并发提交完成顺序和提交顺序不一定一致如果业务对顺序敏感必须在用户态做序列号排序。另外io_uring的IORING_OP_SEND_ZC虽然使用了零拷贝发送但发送完成的语义和普通send不同socket buffer释放通知时机更晚缓冲区复用必须等CQ里对应的完成事件到达否则会覆盖还没发出去的数据。5.3 给团队做演示用的一个小实验如果你需要向团队讲清楚为什么转发路径要换零拷贝与其贴文档不如跑一个直接的小实验。准备一个2GB的文件写一个本地回环测试程序客户端从服务端循环读取文件分别用read write、sendfile、splice三种方式发送统计吞吐和CPU占用。# 简化后的对比命令思路 # readwrite 路径 文件发送时间与CPU time ./send_with_rw /path/to/2gb.bin # sendfile 路径 time ./send_with_sendfile /path/to/2gb.bin # 用 perf stat 观察上下文切换数量 perf stat -e context-switches ./send_with_sendfile /path/to/2gb.bin实测下来sendfile通常比readwrite快50%到200%CPU占用下降更明显perf stat里的context-switches数量能直观看出系统调用少了多少。真实数值因机器、内核、网卡而异关键是让团队看到同样的业务逻辑底层原语不同CPU成本差一大截。我在实际排查服务端性能问题时见过太多次代码层面已经优化到极限、但CPU还是降不下来的场景。最后定位到I/O路径发现只是缺了一次原语的正确选择。Linux I/O演进史这条线本质上就是一段消除不必要开销的历史先消除不必要的数据搬运再消除不必要的等待最后用异步把两者统一。希望这篇梳理能帮你把散落的系统调用串成一张完整的地图。下次再写转发逻辑时先停一下问自己三个问题数据需要进用户态吗这件事还能不能少一次系统调用这个等待是不是必须的想清楚这三个问题你的选型大概率不会错。
返回列表