io_uring:Linux 为什么需要另一套异步 I/O 机制

io_uring 是近几年 Linux 内核里最值得关注的 I/O 基础设施之一。它不是对 readwriteepoll 做一次小修小补,而是试图重新定义“用户态如何把 I/O 工作高效地交给内核”这件事。

如果只看表面,很多人会把 io_uring 理解成“更快的异步 I/O API”。这个说法不算错,但不够准确。io_uring 真正要解决的问题,并不只是某一个系统调用慢,而是 Linux 传统 I/O 模型在高并发、低延迟场景下,已经暴露出了一整套结构性问题。

这篇文章试着从工程视角回答几个核心问题:

  • 为什么会出现 io_uring
  • 它究竟是在解决什么问题
  • 它的核心原理是什么
  • 它适合什么场景,又不适合什么场景

一、为什么会出现 io_uring

要理解 io_uring,必须先看 Linux 之前的 I/O 路线为什么越来越吃力。

1. 传统同步 I/O 的问题

最经典的 I/O 调用方式就是:

read(fd, buf, size);
write(fd, buf, size);

在这里插入图片描述

它的问题非常直接:线程发起调用后,通常要等内核把事情做完,调用才返回。哪怕底层设备很快,这个模型仍然意味着:

  • 每次 I/O 都要进入一次内核
  • 每次调用都要有系统调用开销
  • 线程可能阻塞,调度器要参与上下文切换
  • 在大量并发连接下,线程模型会迅速膨胀

这套模式在低并发时代完全够用,但当服务端开始面对成千上万个连接、频繁的小包收发、海量磁盘请求时,开销会逐渐从“设备慢”变成“软件栈本身太重”。

2. 非阻塞 I/O + epoll 也不是终点

后来 Linux 逐步形成了另一条主流路线:non-blocking fd + epoll

它解决了一个非常重要的问题:线程不再需要为每个连接阻塞等待,应用可以通过事件通知来处理“哪个 fd 现在可读、哪个可写”。

这已经比“一连接一线程”先进很多,但它仍然有几个根本限制:

  • epoll 解决的是“事件通知”,不是“异步执行 I/O”
  • 应用仍然要在收到事件后自己调用 read/write
  • 一次完整处理通常仍然包含多次系统调用
  • 业务代码需要维护复杂的状态机
  • 小请求场景下,系统调用和状态切换成本依然明显

换句话说,epoll 提供的是 readiness model,也就是“告诉你现在差不多可以做了”;但它并没有把“做这件事”本身也异步化。

3. Linux AIO 历史上并不好用

Linux 不是没有做过异步 I/O。内核早就有过 libaio/Linux AIO。

但它一直没有成为通用应用层的主角,原因很现实:

  • 接口不够统一,使用体验差
  • 语义偏底层,编程复杂
  • 对文件 I/O 之外的支持不理想
  • 与网络 I/O、超时、轮询等场景缺乏统一抽象

很多开发者最终还是回到了 epoll + non-blocking + 线程池 这条路上。

这说明业界真正需要的,不是“某种局部的异步接口”,而是一套统一、可扩展、足够高性能的 I/O 提交与完成框架。

io_uring 就是在这个背景下出现的。


二、io_uring 试图解决什么问题

io_uring 的目标,不只是“让 I/O 更快”,而是让用户态和内核之间的 I/O 协作方式发生变化。

它主要解决四类问题。

1. 降低系统调用次数

传统模型里,一次 I/O 经常意味着:

  1. 应用等待事件
  2. 事件来了,发起系统调用
  3. 系统调用返回结果

高频场景下,系统调用本身就是成本。io_uring 把“提交请求”和“获取完成结果”改造成共享环形队列,尽量减少反复陷入内核的次数。

2. 降低上下文切换和调度开销

如果线程因为 I/O 阻塞,就会涉及调度;如果依赖线程池兜底异步,也会增加线程管理成本。

io_uring 的设计方向是:尽可能把更多工作变成“队列驱动”,而不是“线程睡眠再唤醒”。这对低延迟系统尤其重要。

3. 统一异步操作模型

io_uring 并不只想做“异步读写”。它试图把很多操作放进同一个提交-完成框架里,包括:

  • 文件读写
  • 网络收发
  • accept
  • connect
  • 超时控制
  • poll
  • 文件关闭、打开等辅助操作

这样一来,应用不必在 epoll、线程池、定时器、各种特定接口之间来回切换,而是可以围绕一种统一的 I/O 编排模型来写。

4. 给内核更多优化空间

一旦用户态不再只是“发一个系统调用,等一个返回值”,而是改成“批量提交一组请求,由内核统一处理”,内核就有更多机会做优化:

  • 批处理
  • 请求合并
  • 减少锁竞争
  • 少做不必要的唤醒
  • 更好地贴近设备队列模型

这也是 io_uring 名字里 uring 的含义所在:它借用了现代高性能设备常见的 ring buffer 思路。


三、io_uring 的核心原理

原理图

io_uring 的核心思想可以概括成一句话:

用户态和内核共享两组环形队列,一组用来提交请求,一组用来接收完成结果。

这和传统“每次做事都发一个系统调用”的模式有本质不同。

1. 两个环:SQ 和 CQ

io_uring 中最核心的结构是两组 ring:

  • SQ:Submission Queue,提交队列
  • CQ:Completion Queue,完成队列

应用把要做的事情放进 SQ,内核处理完成后把结果放进 CQ

它们通常通过 mmap 映射到用户态,因此用户态和内核可以围绕同一块共享内存协作,而不是每次都靠参数拷贝和系统调用返回值交流。

2. SQE 和 CQE

队列里的元素也有明确分工:

  • SQE:Submission Queue Entry,描述“你要我做什么”
  • CQE:Completion Queue Entry,描述“我做完了,结果是什么”

比如你要发一个异步读请求,就在 SQE 里填入:

  • 操作类型,例如 READV
  • 文件描述符
  • 缓冲区地址
  • 长度
  • 偏移
  • 用户自定义数据 user_data

内核处理完后,会在 CQE 里返回:

  • 结果码
  • 完成状态
  • 原样带回 user_data

这个 user_data 很关键。它让应用可以在大量并发请求完成时,快速知道“这个结果对应的是哪个业务上下文”。

3. 提交和完成是解耦的

传统同步 I/O 是“调用即执行,执行即返回”。

io_uring 则把整个过程拆成了两步:

  1. 提交请求
  2. 稍后收取完成事件

这个解耦带来的价值非常大:

  • 可以一次提交多个请求
  • 可以按批次收割结果
  • 可以把多个操作串起来
  • 可以减少调用边界上的来回切换

从架构角度看,它更像是内核提供了一个轻量级 I/O 工作队列,而不是一组孤立的系统调用。

4. 为什么它能更高效

io_uring 的效率并不来自某一个魔法优化,而是来自几个因素叠加:

  • 共享内存队列减少了数据交换成本
  • 批量提交减少了系统调用次数
  • 批量完成减少了事件处理成本
  • 队列化模型降低了调度和状态切换频率
  • 接口统一后,应用层可以少维护一堆分裂的 I/O 机制

在高 IOPS、高连接数、小包、低延迟这些场景里,这些细节叠加起来会非常可观。

5. io_uring 里最关键的数据结构

如果只是理解概念,知道 SQCQ 就够了;但如果要真正写代码,必须理解它背后的核心数据结构。

先说结论:io_uring 用户态最常接触的结构,主要有四类:

  • struct io_uring
  • struct io_uring_sqe
  • struct io_uring_cqe
  • struct io_uring_params

如果使用 liburing,你平时接触最多的也是这几组抽象。

struct io_uring

struct io_uring 不是内核 UAPI 里暴露给应用直接操作的原始布局,而是 liburing 在用户态封装出来的“ring 上下文对象”。

它的职责是把一整个 io_uring 实例组织起来,包括:

  • 提交队列状态
  • 完成队列状态
  • ring fd
  • ring 映射后的内存信息
  • 一些特性和标志位

从工程上看,你可以把它理解成:

“一个进程内可操作的 io_uring 句柄”

典型使用方式大致是:

struct io_uring ring;
io_uring_queue_init(256, &ring, 0);

初始化之后,后续的提交和收割完成事件,基本都围绕这个 ring 对象展开。

struct io_uring_sqe

SQE 是 Submission Queue Entry,也就是“提交描述符”。每一个 SQE 代表一项准备交给内核去做的工作。

可以把它理解成一张任务单,里面至少要说明:

  • 这是哪一种操作
  • 作用于哪个文件描述符
  • 缓冲区在哪里
  • 长度是多少
  • 偏移是多少
  • 附带哪个业务上下文

简化理解时,可以记住它的几个核心字段:

struct io_uring_sqe {
    __u8  opcode;
    __u8  flags;
    __u16 ioprio;
    __s32 fd;
    __u64 off;
    __u64 addr;
    __u32 len;
    union {
        __kernel_rwf_t rw_flags;
        __u32 poll_events;
        __u32 sync_range_flags;
        __u32 msg_flags;
        __u32 timeout_flags;
        __u32 accept_flags;
        __u32 cancel_flags;
        __u32 open_flags;
        __u32 statx_flags;
        __u32 fadvise_advice;
        __u32 splice_flags;
        __u32 rename_flags;
        __u32 unlink_flags;
        __u32 hardlink_flags;
    };
    __u64 user_data;
};

这里最值得重点理解的是几个字段:

  • opcode
    表示操作类型,例如读、写、收、发、超时、轮询等。
  • fd
    表示本次操作关联的文件描述符。
  • off
    表示文件偏移。对 socket 这类流式 fd,通常不强调这个字段。
  • addr
    指向用户缓冲区,或者某个参数结构。
  • len
    本次 I/O 长度,或者相关参数个数。
  • user_data
    这是应用侧最重要的字段之一。它是一个 64 位用户自定义值,内核不会解释它,只会在完成时原样带回。

实际开发里,user_data 经常被用来存放:

  • 请求 ID
  • 指针地址
  • 连接对象编号
  • 状态机上下文句柄

这样当 CQE 回来时,应用就能快速把结果路由回对应业务对象。

struct io_uring_cqe

CQE 是 Completion Queue Entry,也就是“完成描述符”。每一个 CQE 代表一项已经完成的请求结果。

它比 SQE 简单很多,核心就是“谁完成了,结果如何”:

struct io_uring_cqe {
    __u64 user_data;
    __s32  res;
    __u32  flags;
};

这三个字段非常关键:

  • user_data
    来自你提交时填入的 SQE.user_data
  • res
    操作结果。成功时通常是返回值,例如读了多少字节;失败时通常是负的错误码。
  • flags
    描述补充完成状态,例如是否有额外信息。

这里 res 的语义很像传统系统调用返回值,只不过它不是同步返回,而是在完成队列里异步返回。

struct io_uring_params

这个结构主要用于 ring 初始化阶段。应用在创建 io_uring 时,可以通过它告诉内核“我希望怎么建这个 ring”,同时内核也会通过它把 ring 的一些布局信息回填给用户态。

它的职责包括:

  • 指定创建标志
  • 获取 SQ/CQ ring 的偏移信息
  • 获取支持特性
  • 告知应用 mmap 布局

可以把它理解成:

“创建 io_uring 时的协商结构”

它不是每次 I/O 都要碰,但理解它有助于你明白:io_uring 之所以能高效运行,是因为创建阶段就已经把共享内存和 ring 布局约定好了。

SQ ring 和 CQ ring 的头尾指针

除了上面三个业务上最常见的结构,真正驱动 ring 运转的还有一组非常关键的共享元数据:

  • head
  • tail
  • ring_mask
  • ring_entries

它们分别存在于提交队列和完成队列中。

可以这样理解:

  • tail 往前推进,表示生产者放入了更多项
  • head 往前推进,表示消费者已经取走了更多项
  • ring_mask 常用于把递增下标映射回环形数组位置
  • ring_entries 表示 ring 的容量

一个最基本的 ring 下标计算通常类似这样:

index = tail & ring_mask;

这就是典型的环形缓冲区写法。只要 ring 容量是 2 的幂,就可以用按位与高效地完成取模。

为什么这些结构设计得这么“扁平”

从内核工程的角度看,io_uring 的这些结构有一个很明显的特点:字段直白、内存布局稳定、便于共享和批量处理。

原因很简单,这不是一个面向“优雅对象模型”的接口,而是一个面向高性能路径的数据平面接口。

它优先考虑的是:

  • 跨用户态和内核态的低成本协作
  • 尽量少拷贝
  • 尽量少解释
  • 尽量适合批处理

所以 io_uring 的数据结构看上去更像设备队列接口,而不像传统高级 API。

SQ线程

前面介绍了 io_uring怎么通过共享 提交队列 和 完成队列 来避免不必要的系统调用,但应用程序将 I/O 操作提交到 提交队列 后,内核什么时候从 提交队列 中获取要进行的 I/O 操作,并且发起 I/O 请求呢?

当用户使用 SQPOLL模式(指定了 IORING_SETUP_SQPOLL标志)创建 io_uring时,内核将会创建一个名为 io_uring-sq的内核线程(称为 SQ线程),此内核线程会不断从 提交队列 中读取 I/O 操作,并且发起 I/O 请求。

当 I/O 请求完成以后,SQ 线程将会把 I/O 操作的结果写入到 完成队列 中,应用程序就可以从 完成队列 中读取 I/O 操作的结果。

如下图所示:

SQ线程图
我们简单总结下 io_uring 的操作步骤:

  • 第一步:应用程序通过向 io_uring的 提交队列 提交 I/O 操作。
  • 第二步:SQ内核线程从 提交队列 中读取 I/O 操作。
  • 第三步:SQ内核线程发起 I/O 请求。
  • 第四步:I/O 请求完成后,SQ内核线程会将 I/O 请求的结果写入到 io_uring的 完成队列 中。
  • 第五步:应用程序可以通过从 完成队列 中读取到 I/O 操作的结果。

四、它为什么叫 io_uring

很多人第一次看到这个名字会觉得奇怪。其实它的命名非常直白:

  • io:I/O
  • uring:userspace ring buffer

也就是说,这套机制的核心就是“围绕用户态可见的环形队列来组织 I/O”。

这种设计思路并不是凭空出现的。现代存储和网络设备本来就大量依赖 queue/ring 模型。io_uring 某种程度上是把这种高性能队列化思想,进一步往通用 Linux I/O 抽象里推进。


五、io_uring 能解决什么实际问题

说完原理,再落回工程实践。

io_uring 适合解决的问题,通常都具有以下特征。

1. 高频 I/O 场景下的系统调用开销

如果你的应用每秒要处理大量读写请求,且请求粒度不大,那么系统调用成本、上下文切换成本、事件循环调度成本会非常显眼。

这时候 io_uring 的批处理和共享队列优势就很容易体现出来。

2. 文件 I/O 和网络 I/O 的统一异步化

传统服务端架构里,网络 I/O 往往由 epoll 管,磁盘 I/O 则可能依赖同步调用或线程池。两者模型不统一,代码很容易分裂。

io_uring 提供了一种更一致的方式,让文件和网络相关操作都能进入同一种提交-完成框架。

3. 减少线程池依赖

很多所谓的“异步”程序,本质上只是把阻塞调用塞进线程池。

这在功能上可行,但规模上往往不优雅:

  • 线程数需要控制
  • 队列需要调度
  • 栈内存会膨胀
  • 上下文切换会增多

io_uring 的一个重要价值,就是在部分场景下把“靠线程池模拟异步”替换成“真正由内核队列驱动的异步”。

4. 更灵活的 I/O 编排

io_uring 支持链式提交、超时、取消、轮询等能力,这意味着应用可以把多个 I/O 步骤编排成更紧凑的执行序列。

例如:

  • 先接收连接
  • 再读取请求
  • 再写回响应
  • 设置超时保护

这些动作可以被更系统化地组织,而不必全靠用户态状态机拼装。


六、它是不是 epoll 的替代品

这是一个很常见的问题。

严格来说,io_uring 不只是 epoll 的替代品。它覆盖的范围更大,因为它不是单独的事件通知机制,而是一套更完整的异步操作框架。

更准确的说法是:

  • epoll 解决的是“我该什么时候去做 I/O”
  • io_uring 解决的是“我怎样把 I/O 本身提交出去,并在完成后高效收回结果”

在某些网络场景里,io_uring 确实能承担过去 epoll + nonblocking read/write 的角色。

但工程上不能简单说“有了 io_uring 就不需要 epoll 了”。原因有两个:

  • 不同内核版本、不同操作类型、不同驱动路径上的成熟度并不完全一致
  • 某些应用已经围绕 epoll 建立了很稳定的系统,迁移成本未必划算

因此,一个更工程化的结论是:

io_uring 是 Linux 高性能异步 I/O 的新主线,但是否替代 epoll,要看你的业务模型、内核版本和收益预期。


七、它的代价和边界是什么

任何高性能机制都不是免费的,io_uring 也一样。

1. 编程复杂度更高

相比直接调用 read/writeio_uring 的编程模型明显更复杂。你要管理:

  • 提交队列
  • 完成队列
  • 缓冲区生命周期
  • 请求与结果对应关系
  • 错误处理和取消逻辑

如果业务本身并不高并发,这种复杂性可能得不偿失。

2. 不同场景收益差异很大

io_uring 并不是所有程序都能“无脑提速”。

如果应用瓶颈在:

  • CPU 计算
  • 上层协议处理
  • 数据库查询
  • 业务锁竞争

那么单纯换成 io_uring,收益可能非常有限。

3. 内核路径与生态成熟度要考虑

io_uring 很强,但它终究强依赖内核实现。线上落地时要看:

  • 你的内核版本是否足够新
  • 目标操作是否已经很好支持
  • 所用语言运行时或框架封装是否成熟
  • 运维团队是否能处理相关问题

高性能接口的价值,从来不只取决于 API 设计,也取决于它在你的生产环境里是否“稳定可控”。


八、从内核工程角度看,io_uring 真正重要的地方

如果站在资深内核工程师的视角看,io_uring 最重要的意义并不是“又多了一个 API”,而是它代表了 Linux I/O 设计的一次方向调整。

过去很多接口的思路是:

  • 用户态发起一次调用
  • 内核当场处理
  • 立即返回结果

io_uring 的思路是:

  • 用户态先把工作描述出来
  • 内核按队列消费这些工作
  • 完成后再把结果异步返回

这更接近现代高性能系统的基本形态:队列化、批处理、异步完成、尽量减少边界来回切换。

因此,io_uring 的价值不只是“优化了几种 I/O 操作”,而是给 Linux 提供了一种更适合未来高并发工作负载的通用交互模式。


九、总结

io_uring 出现的根本原因,是传统 Linux I/O 模型在高并发、低延迟场景下,越来越难同时满足性能、统一性和编程效率。

它要解决的问题包括:

  • 系统调用次数过多
  • 阻塞和上下文切换开销高
  • epoll 只能通知事件,不能统一异步执行 I/O
  • 线程池式异步成本高
  • 文件和网络 I/O 模型分裂

它的核心原理是:

  • 用共享内存中的两组 ring buffer
  • 让用户态提交 SQE
  • 让内核异步完成后返回 CQE
  • 用提交/完成解耦、批处理和队列化模型来提升效率

从结果看,io_uring 并不是“所有程序都该立刻迁移”的银弹,但在高性能 I/O 场景里,它已经成为 Linux 无法绕开的关键技术。

如果你只记住一句话,我建议记住这一句:

io_uring 的本质,不是把 read 变快,而是把 Linux I/O 从“调用驱动”推向“队列驱动”。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐