尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Linux I/O演进:从管道到零拷贝与io_uring的底层逻辑

发布时间:2026/9/15 2:18:38

资讯中心
01
ARTICLE

Linux I/O演进:从管道到零拷贝与io_uring的底层逻辑

Linux I/O演进:从管道到零拷贝与io_uring的底层逻辑
我几年前调过一个静态文件下载服务QPS卡在几千上不去CPU却烧得很高。抓了一圈数据发现大部分时间都耗在read/write系统调用和数据拷贝上。那一刻我才真正理解Linux I/O从管道到零拷贝的这段演进史本质上就是服务端核心原语被不断重写的过程。这篇文章我想把这条线串起来适合正在做网关、存储、中间件或者单纯想搞懂底层I/O的朋友。很多人会用epoll、会用sendfile但说不清它们为什么高效更说不清为什么在这套体系后面还会冒出io_uring。如果你也有这种“知其然不知其所以然”的感觉我建议从头走一遍。我们不看教科书从一个真实服务端会遇到的问题开始一步步看数据是怎么在内核和用户态之间搬家的以及每一代I/O原语到底优化了什么。1. 先回答一个问题I/O原语到底在解决什么1.1 从“把数据搬进用户态”说起在一台Linux服务器上所有I/O最后都会落到几个系统调用上read、write、open、close、socket、accept、send、recv再往后是select、poll、epoll、sendfile、splice、io_uring。这些调用被抽象成文件描述符用户态程序通过文件描述符去读写文件、管道、socket。它们就是服务端I/O的核心原语。但“原语”这个词听起来很底层实际要解决的事情非常朴素把数据从磁盘或者网卡搬到进程内存里再把进程内存里的数据搬到磁盘或者网卡上。因为系统安全和进程隔离应用程序不能直接访问硬件所有I/O都要经过内核。内核拿了数据之后出于缓存和调度考虑不会马上扔给用户态而是会先放在自己的内存区域里也就是页缓存、socket缓冲区这些地方。用户态程序需要的时候再把数据“拷”出去。问题就在这里。数据每多拷一次CPU就要多忙一次每次用户态和内核态切换CPU就要多跑一次上下文切换。在高并发服务端当每秒有几十万次I/O请求时这些原本不起眼的开销会被无限放大。我当年调那个下载服务perf出来之后read和write加起来占了几乎60%的CPU时间不是业务逻辑复杂纯粹是搬数据搬得太狠了。1.2 每一次拷贝和上下文切换的成本估算我们先量化一下这些开销。一次用户态到内核态的上下文切换现代CPU上大约需要1到5微秒如果是系统调用密集成本会更高。这个数字看着小但要知道一次传统readwrite文件传输至少发生4次上下文切换read进入内核、read返回用户态、write进入内核、write返回用户态。在千兆带宽下如果每次请求只传几KB这个开销占比会非常难看。更贵的其实是数据拷贝。从磁盘到页缓存通常由DMA完成CPU不参与这是相对便宜的“搬运”。但从页缓存到用户态缓冲区这一步必须由CPU执行一次memcpy把数据从内核地址空间拷到用户地址空间。数据量越大CPU消耗越大。等到用户态把数据写给socket又要一次CPU拷贝把用户缓冲区数据搬到socket的发送缓冲区之后再靠DMA从socket缓冲区搬到网卡。所以一个传统文件下载流程数据会经历磁盘到页缓存DMA、页缓存到用户态CPU拷贝、用户态到socket缓冲区CPU拷贝、socket缓冲区到网卡DMA。两次CPU拷贝两次DMA拷贝如果算上TCP协议栈处理实际开销还会更多。这就是零拷贝技术要消除的部分前面两次CPU拷贝至少在文件传输场景可以绕过去。2. 管道匿名管道、命名管道背后的一对一搬运工2.1 一个经典命令背后的管道原语讲零拷贝之前必须先讲管道。因为管道是Linux历史上最成功、也最容易被忽视的I/O原语。你在命令行里随手敲一句cat access.log | grep error | wc -l这背后就是两个匿名管道。shell会创建管道然后把前一个进程的stdout接到管道写端把后一个进程的stdin接到管道读端。数据从cat进程出来进到内核里的一段缓冲区再被grep进程读走grep处理完再把结果通过另一个管道交给wc。管道本质上是内核提供的一块缓冲区读写双方只要操作文件描述符不需要知道对方进程的具体信息。它天然做到了流式处理左边还没跑完右边可以一边处理两边速度不一致时由缓冲区吸收压力。这个“缓冲区吸收压力”的思想后来被用到了socket缓冲区、Kafka的page cache等各种地方。服务端的消息队列、日志流水线本质上都是管道模型的放大版。2.2 管道缓冲区的容量与阻塞语义很多人不知道管道的容量不是无限大的。早期Linux的管道缓冲区只有4KB后来调整到16个页框也就是64KB左右。这个容量直接影响阻塞行为当管道满了写端继续write会被阻塞直到读者读走一部分当管道空了读端read会被阻塞直到有新数据写入。理解这点很重要。你在调优一个日志采集管道时如果写入速率长期高于消费速率即使你有管道最终也会被写满然后生产者被阻塞。这其实是背压backpressure的一种最原始实现不丢数据但让上游放慢速度。后来很多消息中间件里的“限流”“背压”设计思路和管道一模一样。另外管道还有一个原子性保证单个write写入的数据量如果小于PIPE_BUF内核保证这次写入不会和其他写者交错。Linux的PIPE_BUF是4096字节所以多个进程并发往同一个管道写的时候只要每次写小于等于4096字节就可以保证不互相穿插。做进程间通信的一些老程序会特意把消息切到4KB以内就是为了利用这个语义。2.3 管道与Socket同一套搬运逻辑管道和socket最像的地方在于它们都是全双工或半双工的字节流通道底层都是内核缓冲区都支持阻塞和非阻塞模式。区别只是管道面向本机进程间socket面向网络但操作系统给它们套了同一套文件描述符抽象。这个统一抽象特别重要。正因为管道、socket、普通文件在外层都表现为fd后来出现的splice才可以很自然地在管道和socket之间搬运数据而不需要区分对方是谁。可以说管道是理解所有内核I/O模型的钥匙。如果你能搞清楚read一个管道时发生了什么再去看epoll里的可读事件、事件循环里的回调、以及零拷贝里的splice都会顺畅很多。从管道开始往后看Linux I/O演进的逻辑其实很清晰阻塞不够用就上非阻塞轮询不够用就上事件通知显式拷贝太贵就想办法让数据少穿越用户态边界。所有改动都是围绕“减少等待、减少拷贝、减少上下文切换”这三个目标。3. 非阻塞、事件循环与多路复用I/O从“等”变成“轮询”再到“通知”3.1 阻塞读取为什么扛不住高并发管道和socket默认是阻塞的。阻塞意味着一个线程read一个fd时如果没有数据这个线程会睡在那里直到数据到来。这种方式在串行程序里很好理解但到高并发服务端就不行了。一个连接分配一个线程每个线程都阻塞在自己的连接上线程数量一多上下文切换直接吃掉所有CPU。我做过一个模拟测试2万并发连接的场景下即使用线程池线程切换开销也占了将近40%的核时。你不可能靠加线程无限扛下去。因此服务端一开始都会把fd设置成非阻塞。非阻塞read在有数据时返回数据没有数据时返回EAGAIN告诉应用“现在没货你可以干别的”。线程不用再睡死在一个连接上可以不断轮询所有连接的fd看看谁有数据。这就是I/O演进史的第一次转折从“等待”变成“检查”。3.2 select/poll到epoll的关键差异但“检查”也有问题。传统select系统调用让用户传入一组fd内核遍历这一组fd返回哪些可读可写。select有FD_SETSIZE限制通常只能监听1024个fd高并发下首先就出局。poll改用链表突破了数量限制但用户态每次调用poll都要把全部fd从用户态拷到内核态内核再全量扫描一遍复杂度是O(n)在多连接场景下依然很难看。epoll的出现算是彻底翻篇了。它把fd注册到内核里一棵红黑树上内核只维护有事件发生的fd列表。用户态调用epoll_wait时不需要再传递全部fd只需要拿结果内核也只把就绪fd拷给用户态。复杂度从O(n)降到了O(ready)。更重要的是epoll提供了边缘触发模式配合非阻塞fd可以做到事件发生时只通知一次应用层必须一次性把数据读完。这个模式的使用门槛更高但是性能上限更高很多高性能框架默认就用边缘触发非阻塞。3.3 服务端事件循环把read拆成阶段有了epoll之后服务端编程模型发生了根本变化。原来read通常是线性流程连接请求-读数据-处理-写回应。现在变成事件循环epoll_wait返回“可读事件”应用从事件里找到连接fd再去read读完之后处理处理完再注册“可写事件”等可写时再write。这个模型把I/O阶段和业务处理阶段完全解耦。read不再是一句单独的调用而是一个由事件驱动、多阶段触发的原语组合。Nginx、Redis、Netty、Node.js底层都是这套思路。后面要讲的零拷贝和io_uring很多设计都继承了这种思想请求可以批量提交处理结果可以异步返回。理解了事件循环再看io_uring里的环形队列你会觉得非常自然因为本质上它把“事件列表”变成了“内存队列”。4. 用户态与内核态的边界博弈mmap、直接I/O与Page Cache4.1 mmap到底省了什么epoll解决的是“等待”问题但数据搬运的拷贝浪费还在。前面提到传统read需要从页缓存拷到用户态缓冲区如果能绕过这次CPU拷贝文件读取会快很多。mmap就是干这个的。mmap把文件映射到进程地址空间用户态代码可以直接通过指针访问文件内容。内核把文件数据放进页缓存后进程读写这部分地址时实际上就是在读写页缓存省掉了显式的read拷贝。看起来完美但它不是免费的第一次访问映射的页面时会触发缺页中断内核要建立虚拟地址到物理页的映射使用过程中映射区还占用进程的虚拟地址空间文件被截断、扩展时如果处理不当还会出现SIGBUS。它适合大文件、随机读多的场景很多数据库和KV存储会用mmap管理数据文件。4.2 直接I/O不总是最优解页缓存虽然好但有它的问题它会缓存数据重复读效率高但如果应用已经知道自己的访问模式比如数据库有自己的buffer pool页缓存反而成了多余的一层还可能导致双重缓存的浪费。这时候可以直接用O_DIRECT标志打开文件让数据绕过页缓存直接在内核缓冲区和用户缓冲区之间传输。直接I/O省掉了页缓存维护开销但有个代价它要求内存缓冲区、文件偏移、传输长度都进行块对齐否则系统调用会报错同时它放弃了内核的预读机制频繁小写入的话性能可能比普通read还差。我见过不少项目直接I/O之后性能反而下降的例子原因就是读8字节配置也走直接I/O结果每读一次都要做磁盘I/O把内核页缓存这个高速层给废了。它适合顺序读写大文件或者像数据库这种自管缓存的应用不适合通用服务端接口。4.3 链路协调Page Cache到底让谁受益mmap和直接I/O的取舍本质上是“让谁来管理缓存”的问题。普通read页缓存适合绝大多数场景因为它对应用透明内核帮你做了缓存和预读。mmap适合需要频繁访问同一块文件数据的场景它让用户态直接映射到页缓存省一次拷贝。O_DIRECT则适合确定自己比内核更懂访问模式的应用。我之前在做一个配置中心的时候做过比较一个几百MB的配置数据读取频率很高用mmap映射后冷启动时会有明显缺页开销但跑起来之后访问延迟比read低不少反而是那份文件改成小文件几十KB以内mmap优势就没了普通read的页缓存命中已经足够快缺页中断的额外开销反而拖后腿。选哪种原语必须先测量你的访问模式不要想当然。5. 零拷贝家族sendfile、splice、vmsplice 的演进与适用场景5.1 sendfile文件到socket的直达快车服务器最常干的一件事就是把磁盘上的文件发给客户端。最简单的实现是read到用户态缓冲区再write到socket。前面说过这个流程有两次CPU拷贝。为了干掉它们Linux提供了sendfile系统调用ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它直接从文件描述符到socket描述符传输数据不需要用户态缓冲区。在支持scatter-gather的网卡上数据从磁盘到页缓存后可以由网卡控制器直接从页缓存DMA到网卡CPU一次拷贝都不用。即便不支持也只需要内核里做一次CPU拷贝远好过传统的两次。很多框架里所谓的“零拷贝”就是封装sendfile。Java里的FileChannel.transferTo、Netty里的FileRegion底层都是它。我调下载服务时把原来用BufferedOutputStream写文件改成FileChannel.transferTo同样的机器QPS提升非常明显CPU占用还降了。注意sendfile适合把整块文件发出去如果你要在数据中间插入一些动态生成的内容就要配合sendfile的header/trailer偏移处理或者用writev。5.2 splice连接两个文件描述符的“管道工”sendfile只解决从文件到socket的问题实际场景还有更复杂的一个socket收到的数据要立即转发给另一个socket比如反向代理或者从socket收到数据直接写到磁盘。这个时候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“移动”到另一个fd中间不经过用户态缓冲区。约束是fd_in和fd_out中至少有一个必须是管道。所以用splice做socket到socket转发不能直接两个socket对接而要先把一个socket的数据splice到管道再把管道里的数据splice到另一个socket。虽然多了一次管道搬运但管道这一层的传递只操作page引用不等同于CPU逐字节拷贝。Splice非常适合做数据代理和端口转发。我曾经给一个内网转发工具做过优化原来用readwrite转发CPU占用在千兆压力下到70%多改成spliceepoll之后CPU降到20%左右。它是一个被低估的服务端原语但使用时一定注意fd类型限制匹配置错误会直接报EINVAL。5.3 vmsplice用户态内存与管道的直接对接零拷贝家族里还有一个更激进的成员vmsplice。它允许把用户态内存页“粘”到管道上之后再用splice把管道里的数据写给socket。也就是说数据可以从用户态内存直接进入管道再被内核送到网卡全程不经过用户态到内核态的拷贝。这个机制很有诱惑力但风险也很明显。用户态内存可能被应用修改或释放内核在某个时刻还需要访问那个页面如果你用完之后立即改写内存就会产生数据错乱。内核本质上是把用户页固定住让硬件DMA访问这就要求vmsplice传入的内存页在完成传输前不能被移动。很多框架不用vmsplice就是因为它对内存生命周期管理太苛刻出了问题非常难排查。我个人建议除非你在写专门的用户态网络协议栈或者高性能共享内存框架否则不要轻易在生产环境用vmsplice。零拷贝学到最后你会发现最难的不是系统调用本身而是内存所有权和生命周期的问题。5.4 零拷贝不是万能药小包场景与协议头的坑零拷贝听起来很香但它不是银弹。它最擅长的是大文件、大块数据的传输。如果每个session只有几十字节的数据零拷贝的收益会被系统调用本身的固定开销稀释甚至因为内存固定、页管理的开销而变得更慢。我之前压测过一个IM网关消息体平均不到100字节把readwrite改成splice后性能几乎没有变化有的场景反而抖动更明显。另外很多协议在发送时需要在业务数据前面拼上头部文件内容可能是固定一部分头部又需要动态生成。你如果直接sendfile文件要想办法把头部和body组合。Linux的sendfile支持将数据从指定偏移发送但多个数据块的组合依然要借助writev合并系统调用或者在内核里用MSG_MORE这种flag优化不能想得太简单。TLS传输也是个大坑数据要经过用户态加密后再走内核socketsendfile这种直接让网卡从页缓存拉数据的机制是没法做TLS加密的除非用KTLS在内核里做。所以判断“能不能上零拷贝”一定要先看清楚数据链路里有没有需要用户态参与的处理环节。6. io_uring把系统调用也革掉的异步原语6.1 从read到io_uring系统调用变成了内存队列epoll已经让服务端可以管理海量连接sendfile也已经让部分场景接近零拷贝。但Linux 5.1引入的io_uring对I/O原语又做了一次彻底重构它把系统调用从“你调我”变成“你把请求丢到队列里内核自己取”。io_uring的核心是用户态与内核态共享两个ring buffer一个提交队列SQ一个完成队列CQ。应用在SQ里放入I/O请求比如read、write、accept、send、recv内核处理完成后把结果写入CQ。应用提交一批请求和收割一批结果时只需要一次io_uring_enter系统调用甚至如果之前已经submit过某些场景可以直接轮询CQ而完全不走系统调用。这种设计把I/O边界从“进程调用内核”变成了“进程和内核共享一块内存”。严格来讲io_uring不是传统意义上的零拷贝但它用同样的思想消灭了另一个开销——系统调用和上下文切换。这也是为什么它被称为异步I/O原语的集大成者。6.2 为什么io_uring更适合现代高并发epoll模型里内核只是通知“你可读了”真正的read和write还是要应用自己发起每个请求必然产生至少一次系统调用。io_uring把read/write本身也变成异步应用可以在一次批量提交里塞入成百上千个读写请求让内核一次性处理大幅度摊薄系统调用成本。io_uring还支持固定文件、固定缓冲区。所谓固定就是提前在内核里注册一批文件和缓冲区后续请求只需引用index不用每次重复get fd和内存映射信息。这一点在万级文件描述符和频繁I/O的场景下非常明显。它在存储领域已经大量落地像RocksDB、ClickHouse都有相关使用网络领域也开始出现在高性能代理和边缘网关里。我做小型数据同步工具时试过io_uring用liburing封装之后确实比epollreadv的写法性能高了30%左右。但注意这里的高是“系统调用开销”的高如果你的瓶颈在业务逻辑或者磁盘本身io_uring也救不了你。6.3 实际部署时要留意的兼容性io_uring虽好但建立在比较新的内核特性上。生产环境用的内核如果低于5.10我会犹豫要不要上。另外很多云服务器、容器运行时、安全软件会限制io_uring的使用因为它允许用户态直接和内核共享内存、注册文件这在安全审计上比较敏感。过去io_uring确实暴露过一些安全问题内核补丁要跟上。如果你准备在服务端引入我建议先做一个隔离的转发层而不是紧急替换核心业务链路。在压力测试环境验证至少一个星期再根据实际收益决定是否上生产。依赖库用liburing别自己直接拼ring buffer结构很容易踩内存屏障的坑。总的来说io_uring是当前Linux I/O演进的前沿但离在每一个服务端项目里无脑使用还有距离。7. 回到实际项目如何用这条演进史指导技术选型7.1 原始read/write什么时候依然是正确选择聊了这么多先进原语最后得泼一点冷水并不是所有项目都要追求零拷贝和io_uring。如果你的请求大小适中系统有足够内存做页缓存业务逻辑本身不简单那你直接read/write可能已经够用。一次普通I/O的CPU拷贝在千兆网络下并没有人们想象的那么恐怖真正麻烦的是反复系统调用和糟糕的缓冲区管理。之前我在一个ERP系统里优化一个导出功能改了半天零拷贝收益甚微最后把从头读写的缓冲区从4KB调到64KB同时减少无谓的close/open性能立刻上来了。I/O优化是一个系统问题不是某个“神奇系统调用”能单独解决的。原始read/write还有一个巨大优势通用、稳定、可读性高。对于维护性优先的团队这是很大考量。零拷贝和异步原语都要求开发者对内核行为有足够理解否则一个使用不当线上问题比性能多得多。7.2 一张决策表不同场景对应的I/O原语下面这张表是我在实际项目中总结出来的选型思路分享给你参考。它不是一个绝对标准但可以帮你少走弯路。场景推荐I/O原语原因静态文件下载/CDN回源sendfile / FileChannel.transferTo大文件顺序传输减少CPU拷贝收益明显反向代理/端口转发splice epoll连接间数据搬移不需要进用户态高并发网络网关大量小消息epoll 非阻塞read/write零拷贝对小包收益有限epoll更成熟数据库/存储引擎mmap 或 O_DIRECT取决于缓存策略核心是自管缓存与内核页缓存的分工高吞吐日志/消息管道批量read/write 页缓存顺序写场景页缓存非常友好极低延迟、新版本内核、大量异步IOio_uring批量提交减少系统调用适合新一代存储/网关注意表格里“高并发网络网关大量小消息”一栏我也见过有人用io_uring做得很好但那是建立在充分压测和对内核版本有控制权的前提上。不确定时优先选择社区经验更丰富的方案。7.3 一个真实案例TLS让我的零拷贝失效了最后讲一个我踩过的坑。有次给一个文件服务升级因为服务器只在内网我直接把读文件发送改成FileChannel.transferTo压测性能提升一倍多上线后确实很稳。后来运维要求外网访问必须加TLS整个链路需要用TLS终止并加密结果transferTo的表现大打折扣——因为TLS加密是在用户态完成的数据必须先进入用户态处理后再交给socket内核没法直接从页缓存里把加密后的数据拷给网卡。这个改动不是代码问题而是整个I/O模型和数据路径变了。那次经历让我彻底明白只有当数据链路上没有任何需要在用户态修改/加密的环节时零拷贝才能最大程度发挥作用。TLS、协议打包、业务粘包拆包只要有一环强制数据进入用户态所谓的零拷贝优势就会缩水甚至消失。所以后来的技术选型我都先画一张“数据路径图”标清楚哪些环节必须用户态参与再决定用什么原语。Linux I/O的演进本质上就是围绕数据路径做减法减少等待减少拷贝减少系统调用。管道让我们学会流式缓冲epoll让我们学会事件驱动mmap和直接I/O让我们审视缓存边界sendfile和splice帮我们跨过用户态io_uring则把整个I/O模型推进到了异步共享队列时代。理解这些原语背后的取舍远比背几个系统调用参数重要。下次再遇到性能问题先画数据路径再谈优化手段你会有比“上零拷贝”更清晰的答案。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。