想象你去一家餐厅。老式餐厅里,你每点一道菜都要干等,直到服务员把菜端上来,你才能点下一道——这叫“同步阻塞 I/O”,厨师闲着你也饿着。后来餐厅改进了:你坐在吧台盯着信息屏,看到哪桌菜好了,还得亲自跑去窗口端——这叫“多路复用”(epoll),省了傻等,但端菜还得你自己跑。而 io_uring 带来的变化是:餐厅门口放了一个“点餐便签盒”和一个“出餐口”。你把想吃的十几道菜一次性写在便签上投进盒子(提交队列 SQ),厨房按便签批量做菜,做完全部放到出餐口(完成队列 CQ),你只需要走过去一次性端走所有菜。整个过程中你几乎不用一次次叫服务员(系统调用),也不用中途干等。更妙的是“零拷贝”:厨房发现你的桌子就在旁边,索性直接端上桌,连出餐口都不经过,省去了中间人反复搬运的体力活。这就是 io_uring 的世界——一次批量提交,异步批量收割,把“来回跑腿”变成“流水线作业”。
掌握 io_uring 原理,能帮助你在设计高并发文件系统、数据库、消息队列时理解 Linux 原生异步 IO 的底层机制,是进阶后端与存储方向工程师的核心竞争力。
目录
可视化演示
\u5c55\u793aio_uring\u73af\u5f62\u961f\u5217\u67b6\u6784(SQ/CQ\u53cc\u73af+kernel\u5904\u7406)\u3001IO\u5de5\u4f5c\u6d41\u7a0b(submit\u2192notify\u2192process\u2192complete+buffer\u6c60)\u3001\u4e0eepoll/AIO\u6027\u80fd\u5bf9\u6bd4(IOPS/\u5ef6\u8fdf/syscall/CPU)\uff0c\u5207\u6362\u6a21\u5f0f\u67e5\u770b\u3002
本节配有交互动效(进入视口后自动加载,约 3KB)。
io_uring\u53cc\u73af\u961f\u5217\u67b6\u6784 SQ\u63d0\u4ea4\u961f\u5217 \u2192 kernel\u5904\u7406 \u2192 CQ\u5b8c\u6210\u961f\u5217 \u96f6\u62f7\u8d1d+\u65e0syscall\u9ad8\u6027\u80fd SQE \u2192 kernel \u2192 CQE (0 syscall)
1. io_uring概述:异步IO的餐厅革命
餐厅的经营方式在几十年里经历了三代。第一代,顾客点一道菜、等一道菜、再点下一道,服务员和顾客都被“拴”在餐桌上——这是同步阻塞。第二代,顾客坐在吧台,盯着“菜品完成信息屏”,看到自己的菜好了,就起身去窗口端——这是事件通知,但端菜还得自己来。第三代,餐厅在吧台放了一个“便签盒”,顾客把想吃的菜一次性写满几张便签投进去,厨房按顺序批量做,做好全部放到“出餐口”,顾客只需等出餐口堆满后统一端走——这是异步批量式 IO。
在 Linux 中,“顾客”是应用程序,“厨房”是内核与磁盘设备,“服务员”是系统调用。长期以来,程序员面对的是前两代模式:read()/write() 同步阻塞,或 select/epoll 异步通知。epoll 虽然解决了“同时等很多菜”的问题,但“端菜”(真正的读写)还是要由应用程序调用 read()/write() 完成,依然要频繁进出内核。Linux 5.1 正式引入 io_uring,把第三代点餐模式变成了内核级原语。
io_uring 的核心思想是“批量提交、批量收割”。它创建两个用户态与内核态共享的环形队列:提交队列 SQ(submission queue)和完成队列 CQ(completion queue)。应用程序把 I/O 请求(叫 SQE)写入 SQ,更新队尾指针;内核异步消费 SQ 中的请求,执行真实的磁盘读写;完成后把结果(叫 CQE)写入 CQ,更新队尾;应用程序消费 CQ 获得结果。整个过程把多次系统调用压缩为“一次提交、一次等待、一次收割”。
所以 io_uring 就是那套“便签盒 + 出餐口”的系统:SQ 是便签盒,CQ 是出餐口,内核是厨房。你不需要一道道等菜,而是批量点、批量做、批量端,把一次次“跑腿”换成了更高效的整体吞吐。
- 分母 T_submit提交一个请求到队列的平均时间。io_uring 通过批量写入 SQ 来摊薄这部分成本。
- 等待时间 T_wait请求在内核中排队、等待设备响应的耗时。io_uring 的异步机制让多个请求的等待重叠,不阻塞提交。
- 完成时间 T_complete从设备返回结果到用户拿到 CQE 的时间。批量收割 CQ 让完成成本被多个请求分摊。
- 整体吞吐 η单位时间内能完成的 IO 请求数。所有时段越短,吞吐越高。
2. 提交队列SQ:点餐便签盒
便签盒之所以高效,是因为盒子的格子是固定的、可重复利用的。顾客往格子里投便签,厨房按格子顺序一张一张取走。只要还有空格,顾客就可以继续投;格子满了,顾客就得等一等。io_uring 的提交队列 SQ 正是这样一个“便签盒”:一个固定大小的环形队列,槽位由用户态和内核态共享。
SQ 中每个槽位存放一个 SQE(Submission Queue Entry),里面写着“读哪个文件、读多少、读到哪块内存”等操作指令。用户写完后,只需更新队尾指针 tail;内核空闲时从队头 head 开始消费,把请求取走执行。两个指针都在共享内存里,避免了传统模式下“每次请求都要用系统调用传递参数”的昂贵开销。
SQ 的批量提交,相当于顾客把十几张便签一口气塞进盒子,而不是每点一道菜就叫一次服务员。对于高并发场景,这种“一次塞满”的低成本优势极其明显。
- SQ_tail记录下一个可写槽位的指针,用户每投一张便签就加一。
- entries环形队列的容量,即便签盒总格数。
- 取模运算 mod让 tail 超过容量后自动绕回开头,盒子转着圈用。
- 槽位 slot最终实际写入的位置,内核也能用相同规则找到这张便签。
3. 完成队列CQ:出餐口
便签盒解决了“怎么把订单高效交给厨房”的问题,那“菜做好了怎么通知顾客”呢?io_uring 的回答是:别通知了,直接放到出餐口。完成队列 CQ 就是那个出餐口——内核每做完一个请求,就把一个 CQE(Completion Queue Entry)放到 CQ 的槽位上,里面记录着操作结果和返回字节数。
CQ 同样是环形队列,但读写方向相反:内核写数据,更新队尾 tail;用户读数据,更新队头 head。顾客不需要时时刻刻盯着厨房,只需要在合适的时机(比如批量任务都投完了)走到出餐口,把 CQ 里所有做好的菜一次性端走。传统异步编程中最麻烦的“等待通知”环节,在这里变成了简单的“查队列、批量取”。
CQ 让程序从“听到铃铛响就跑去窗口看”变成“定时去出餐口扫一圈,有就一把端走”。这正是 io_uring 能在高并发下显著减少 CPU 空转和上下文切换的原因。
- CQ_tail内核写入完成事件后递增的“已完成”指针。
- CQ_head用户消费完成事件后递增的“已取走”指针。
- nCQ 的容量。取模是为了让指针在环上循环。
- 判断式tail 不等于 head,说明出餐口有菜;等于则说明空无一物。
4. 系统调用流程:三个关键动作
用上便签盒和出餐口之后,还需要三个关键动作来支撑整套流程:买盒子、投递便签、验收菜品。对应到 io_uring,就是 io_uring_setup、io_uring_enter 和消费 CQ。
第一步,io_uring_setup 负责“买盒子”:创建一个 io_uring 实例,指定队列大小,内核返回一个文件描述符 fd,并把 SQ/CQ 映射到用户态共享内存。第二步,io_uring_enter 负责“投递与等待”:用户可以把若干已写入 SQ 的请求一次性提交给内核,也可以阻塞等待一批请求完成。第三步则是用户直接读 CQ 槽位、取走 CQE,并更新指针,整个过程不需要额外的系统调用。
理解这三个动作,就掌握了 io_uring 的骨架:setup 建队列,enter 批量投递,CQ 消费结果。在实际工程中,io_uring_enter 还可以结合 IORING_ENTER_GETEVENTS 等标志,实现“提交完顺便等结果”的组合拳,进一步压缩系统调用次数。
- fdio_uring 实例的文件描述符,相当于“哪个便签盒”。
- to_submit本次要提交给内核的 SQE 数量,对应“一次投几张便签”。
- min_complete最少要等多少个请求完成后才返回,对应“至少端走几道菜再走”。
- flags控制行为的标志位,比如是否阻塞等待。
- 返回值 result本次实际处理的完成数,相当于“出餐口里现有几道菜”。
5. 零拷贝原理:免收银台直送
传统餐厅里,菜从厨房到餐桌要走“传菜员 → 收银台 → 传菜员 → 餐桌”好几道手;每次转手都是一次体力消耗。传统 IO 也是如此:一次磁盘读 + 网络发送,数据往往要经历“磁盘 → 内核缓冲区 → 用户缓冲区 → 内核 Socket 缓冲区 → 网卡”的多次搬运,其中用户态与内核态之间的拷贝就有 4 次。
零拷贝的思路是“免收银台直送”:让数据尽量在内核内部或 DMA 的帮助下直达目的地,避免在用户态和内核态之间反复横跳。常见手段包括 mmap + write(减少一次拷贝)、sendfile(直接在内核转发)、splice(用管道搬运)。io_uring 进一步支持注册缓冲区(registered buffers),把内存提前固定并记录地址,省去每次 IO 前的地址映射。
零拷贝并非“零次拷贝”,而是指“零次用户态与内核态之间的多余拷贝”。真实世界里,数据从磁盘到网卡的 DMA 搬运依然存在,但 CPU 能从繁重的 memcpy 中解放出来——就像厨房直送虽然有厨师走动,但至少免去了收银台和传菜员的重复劳动。
- read + write 传统路径用户态与内核态之间来回拷贝 4 次,最慢。
- mmap + write共享映射省去一次内核到用户再回内核的拷贝,剩 2 次。
- splice 管道搬运内核内部完成传输,用户态全程不碰数据,仅剩 1 次。
- 递减趋势搬运次数越少,CPU 占用越低,吞吐越高。
6. 性能特性:批量摊薄的开销
为什么一次投 10 张便签比一张一张点菜快?因为每次叫服务员(系统调用)都有固定成本:陷入内核、保存上下文、调度返回。这个成本跟“点一道菜”还是“点十道菜”关系不大,却随调用次数线性累积。io_uring 把成本从“每次 IO 一次”变成“每批 IO 一次”,于是开销被完美摊薄。
实测中,io_uring 在大规模随机读写场景下,IOPS 可以达到传统 libaio 的数倍,CPU 占用率也显著下降。更重要的是尾延迟更稳定:因为请求是批量处理的,单次抖动的负面影响被队列吸收,突发流量不容易把延迟顶到天花板。
回到餐厅,一次投 20 张便签和一次投 2 张便签,对厨房来说差别不大,但对顾客省下的“叫服务员”的时间却差了 10 倍。这正是 io_uring 在高吞吐存储系统中的核心价值。
- 单次系统调用开销陷入内核与返回的固定成本。
- 批量请求数一次提交的 SQE 数量。
- 摊薄后的每请求开销总开销除以批量,批量越大越趋近于零。
- 近似关系实际还有设备调度等因素,但主体规律是反比下降。
7. 与传统IO对比:新旧餐厅之争
老餐厅(同步 IO)的问题在于“一人点菜全桌等”:调用 read() 时线程挂起,磁盘未返回前什么也做不了。新式餐厅(epoll)虽然允许同时盯很多桌,但菜单来了还得自己去端(再次 read()/write())。io_uring 则把“等菜单”和“端菜”都承包给了厨房,用户只负责投便签和端成品。
对比 epoll 和 io_uring 的关键差异:epoll 只通知“文件可读/可写”,具体读写仍由用户执行;io_uring 直接提交完整读写请求,并异步收到结果。epoll 每次事件循环往往处理大量文件描述符,面对每一个可读 fd 都要调用一次 read(),系统调用数为 O(n);io_uring 批量提交同样 n 个请求,可能只需少数几次 enter,系统调用数为 O(1)。
当然,io_uring 不是万能钥匙。对于低并发、延迟敏感的少量 IO,同步 IO 反而代码更简单;对于需要老内核兼容的场合,epoll 仍是稳妥选择。选型建议是:高并发、高吞吐、长生命周期服务优先 io_uring;胶水脚本或快速原型,优先简单可靠的老办法。
- epoll 的循环模式有多少个就绪 fd,就要做多少次系统调用。
- io_uring 的批量模式一次 enter 可提交和收割大量请求。
- 复杂度对比当 n 很大时,O(1) 的优势压倒性胜出。
8. 实践与展望:中央厨房时代
最后,让我们看看这套“便签盒 + 出餐口”的体系如何落地。Linux 官方推荐通过 liburing 库使用 io_uring,它封装了底层的 setup、enter 和队列管理,让开发者像写普通代码一样写异步 IO。典型模式分为五步:setup 创建实例 → prep 准备 SQE → submit 批量提交 → wait 等待完成 → reap 收割 CQE。
生态方面,Rust 的 tokio-uring、Go 的 go-uring 实验绑定、Ceph 和 RocksDB 的存储引擎集成,都在让 io_uring 走向生产环境。未来的内核版本还在持续优化:增加更多操作类型、提升设备轮询效率、改进与网络子系统的协同。可以预见,io_uring 将逐步成为 Linux 高性能 IO 的新基建。
从“同步等待”到“批量异步”,io_uring 把餐厅的运营智慧浓缩成了内核原语。今天的它像一座中央厨房:标准化的流程、批量化的生产、高效的直送链路。对工程师来说,理解这套设计,就是拿到了下一代高性能 IO 的钥匙。
- opcode操作类型,比如读、写、fsync、poll。
- fd目标文件描述符。
- rw_addr 与 rw_len数据缓冲区的地址和长度。
- offset文件中的读写偏移量。
- flags携带链接、固定缓冲区等扩展标志。
AI 应用场景
高性能文件服务 / 反向代理
阶段:数据接入与高速文件服务
解决的问题:传统 Nginx 基于 epoll,在高并发下每次读写仍需系统调用和上下文切换,CPU 大量消耗在“通知—处理”循环中。io_uring 能把请求读取和响应发送批量提交,由内核异步完成,显著降低 CPU 占用。
具体案例:某 CDN 边缘节点在 100Gbps 网络下使用 io_uring 改造静态文件发送路径,吞吐提升约 25%,CPU 占用下降 30%,支撑了更多并发连接。
数据库日志异步刷盘
阶段:存储引擎事务提交
解决的问题:数据库每次事务提交都要 fsync 刷盘,同步等待磁盘完成是典型的延迟瓶颈。io_uring 支持异步刷盘与组提交,让多个事务的日志合并为一次批量写。
具体案例:RocksDB 集成 io_uring 后,在高并发写场景下事务提交吞吐显著提升,tail latency 更稳定,磁盘利用率也更均匀。
AI训练数据异步预取
阶段:训练样本加载与预处理
解决的问题:AI 训练的数据加载通常用多进程 + 阻塞读,IO 抖动会拖慢 GPU 训练。用 io_uring 做异步预取,把样本批量读入内存队列,让训练和 IO 深度流水。
具体案例:某大模型训练平台用 io_uring 重写 DataLoader 的磁盘读取路径后,数据加载耗时降低 40%,GPU 利用率提升近 15%。
Python 演示
import timeimport random class IOUringSimulator: """用两个队列模拟 io_uring 的提交-完成流程""" def __init__(self, queue_size=8): self.sq = [None] * queue_size self.cq = [] self.sq_head = 0 self.sq_tail = 0 self.capacity = queue_size def submit(self, op, data): if (self.sq_tail + 1) % self.capacity == self.sq_head: raise BufferError("SQ 满,需要等待消费") slot = self.sq_tail % self.capacity self.sq[slot] = (op, data) self.sq_tail += 1 return slot def process(self, worker_fn): while self.sq_head < self.sq_tail: slot = self.sq_head % self.capacity op, data = self.sq[slot] result = worker_fn(op, data) self.cq.append((op, data, result)) self.sq[slot] = None self.sq_head += 1 def reap(self): done, self.cq = self.cq, [] return done def disk_read_mock(op, data): time.sleep(random.uniform(0.001, 0.005)) return "read:" + data def main(): ring = IOUringSimulator(8) for i in range(1, 7): ring.submit("read", "file_%d.bin" % i) print("提交后 SQ:", ring.sq) ring.process(disk_read_mock) print("内核处理后 CQ:", ring.reap()) if __name__ == "__main__": main()
self.sq = [None] * queue_size预分配提交队列的环形槽位,对应 SQ。if (self.sq_tail + 1) % self.capacity == self.sq_head:用取模判断队列是否已满,满则拒收新请求。slot = self.sq_tail % self.capacity通过队尾指针取模,算出实际写入的槽位。self.sq[slot] = (op, data)模拟用户把 SQE 写入共享队列。while self.sq_head < self.sq_tail:模拟内核消费所有已提交但未处理的请求。self.cq.append((op, data, result))每个请求执行完成后,把 CQE 放入完成队列。done, self.cq = self.cq, []用户一次性批量取走所有完成事件,类似 io_uring 的批量收割。
常见误区
误区听人说 io_uring 是“异步 epoll”,以为它只是把事件通知做得更快,换汤不换药。
正解epoll 只告诉你“这个 fd 可读可写了”,真正的读写操作还得你亲自调 read()/write();io_uring 则是把你把整个读写请求提交给内核,内核做完后把结果放回 CQ。它解决的“跑腿”问题比 epoll 深一层。
为何常见:两者都用于高并发 IO,名字里都有“异步”的影子,初学者容易把它们混为一谈。
误区既然叫“零拷贝”,那数据一定完全不需要搬动,CPU 一点力都不用出。
正解零拷贝指的是消除用户态与内核态之间的重复拷贝,磁盘到内存的 DMA 搬运依然存在,网卡收发也要走硬件。它让 CPU 从 memcpy 中解放,但数据的物理流动不可能凭空消失。
为何常见:“零”这个字太有迷惑性,加上营销话术渲染,容易让人望文生义。
误区只要把程序改成 io_uring,性能就一定翻倍。
正解io_uring 的优势依赖“批量”。如果请求零散、单次 IO 延迟极低,收益会被队列维护和复杂度成本抵消。低并发小 IO 场景下,同步 IO 反而更简单高效。
为何常见:人们容易把新技术的典型 benchmark 当成普适结论,忽略了场景前提。
误区既然队列是共享内存,是不是所有线程随便写就行?
正解共享内存的并发写入同样需要同步机制。多线程写 SQ 需要加锁或使用 per-thread 队列,多线程消费 CQ 也要用原子操作协调 head 指针。io_uring 简化的是“用户态与内核态”的同步,不是“用户态线程之间”的同步。
为何常见:共享内存 + 无系统调用的设计让人误以为天然线程安全,忽略了并发基础原则。
误区几年后 read/write 和 epoll 都会淘汰,io_uring 一统天下。
正解io_uring 需要较新的内核(5.1+)和配合的硬件/驱动,老系统、嵌入式设备、内核模块等场景仍以传统方案为主。生态迁移需要时间,短期是共存而非取代。
为何常见:技术社区的声浪容易让人高估新技术的落地速度,低估存量系统的惯性。
面试题
Q1. 在 io_uring 中,SQ 和 CQ 分别指什么?
- A.提交队列和完成队列← 正确答案
- B.写入队列和读出队列
- C.发送队列和接收队列
- D.共享队列和私有队列
答案:A。SQ 是 submission queue,负责承载用户提交的请(便签盒);CQ 是 completion queue,负责承载内核放回的完成结果(出餐口)。两者都是环形队列,不是简单意义的读写或收发。
Q2. io_uring 主要解决什么问题?
- A.减少系统调用与上下文切换← 正确答案
- B.减少磁盘的物理存储空间
- C.提升 CPU 的主频与缓存
- D.替代操作系统进程调度
答案:A。io_uring 通过批量提交和批量收割,把多次系统调用压缩成少数几次,从而减少上下文切换开销。它不改变硬件存储容量,也不提升 CPU 主频,更不负责进程调度。
Q3. 相比传统 read/write,io_uring 最突出的能力是:
- A.一次性完成所有数据加密
- B.批量提交并异步完成 IO← 正确答案
- C.消除所有设备的中断
- D.自动管理文件系统日志
答案:B。io_uring 允许用户把多个 SQE 一次性提交,内核异步执行,完成后写入 CQ。它不是加密模块,不能消除硬件中断,也不替代文件系统日志。
Q4. io_uring 的提交队列使用了哪种数据结构?
- A.双向链表
- B.环形队列← 正确答案
- C.平衡二叉树
- D.哈希表
答案:B。SQ 是用户态与内核态共享的环形队列,通过 head/tail 指针和取模运算实现复用。链表在内存布局和共享映射上不如环形队列高效,树和哈希表更不匹配队列语义。
Q5. io_uring_enter 系统调用的主要作用是:
- A.创建 io_uring 实例
- B.提交 SQE 并等待 CQE← 正确答案
- C.关闭文件描述符
- D.修改内核调度参数
答案:B。io_uring_enter 负责把 SQ 里的请求提交给内核,并可根据参数等待完成事件。创建实例由 io_uring_setup 完成,关闭 fd 是 close 的事,调度参数与它无关。
Q6. 用户态与内核态共享 SQ/CQ 队列依赖的技术是:
- A.共享内存映射 mmap← 正确答案
- B.网络套接字转发
- C.文件系统日志回放
- D.环境变量传递参数
答案:A。io_uring 通过 mmap 把内核的队列内存映射到用户态,让双方直接读写同一块内存,避免额外拷贝。套接字、日志回放、环境变量都无法实现这种低延迟共享。
Q7. 传统阻塞式 read+write 全流程会产生哪些数据搬运?
- A.用户态与内核态拷贝← 正确答案
- B.DMA 控制器搬运数据← 正确答案
- C.仅在用户态内复制
- D.无需任何实际物理拷贝
答案:AB。传统 IO 既有内核与用户缓冲区之间的 CPU 拷贝,也有磁盘到内存的 DMA 搬运。仅用户态复制或完全不拷贝都不符合实际——这正是零拷贝要优化的问题。
Q8. 下列哪些属于零拷贝的常用机制?
- A.sendfile 系统调用← 正确答案
- B.splice 系统调用← 正确答案
- C.mmap 内存映射← 正确答案
- D.fork 进程复制
答案:ABC。sendfile 和 splice 都在内核内部搬运数据,mmap 通过共享映射减少用户态拷贝,三者都是零拷贝的经典手段。fork 复制进程是进程管理,与数据传输优化无关。
Q9. 与 epoll 相比,io_uring 的优势包括:
- A.一次性处理完整 IO 操作← 正确答案
- B.减少每次 IO 的内核调用← 正确答案
- C.天然兼容所有旧内核
- D.免去所有硬件中断
答案:AB。io_uring 把 read/write 等操作本身交给内核,且支持批量提交,从而减少系统调用。它需要 5.1+ 内核,不能说“天然兼容所有旧内核”;硬件中断仍存在。
Q10. 提交队列已满时,通常满足哪个条件?
- A.head 与 tail 完全相等
- B.(tail+1) 模容量等于 head← 正确答案
- C.head 加一等于 tail
- D.tail 与 head 相差为零
答案:B。环形队列一般留一个空位区分“空”和“满”:当 (tail+1) % cap == head 时,表示再写就会覆盖未消费的请求,所以判定为满。head 等于 tail 表示队列为空,另外两个选项不符合常见实现。