io_uring 异步 I/O:从 SQ/CQ 环形队列到生产落地

深入 Linux io_uring 异步 I/O:SQ/CQ 共享环形队列机制、liburing 核心 API 与最小示例、注册文件与固定缓冲消除每次提交开销、IORING_SETUP_SQPOLL 内核轮询、与 libaio/POSIX AIO/epoll 的差异对比、数据库与存储引擎落地、观测调优与排错清单。

引言

传统的 read()/write() 是同步阻塞的:发起一次 I/O,线程就被挂起,直到数据从磁盘或网卡搬进用户缓冲区才返回。高并发场景下你只能靠「开更多线程」堆并发,而线程的上下文切换与栈内存开销很快成为瓶颈。io_uring 是 Linux 5.1 引入的异步 I/O 框架,它用一对内核与用户态共享的环形缓冲区(提交队列 SQ 与完成队列 CQ)替代「每次 I/O 一次系统调用」的老模型,把批量提交、无锁收割、可选的零拷贝与内核侧轮询组合起来,在 NVMe 与高速网络上把 IOPS 和尾延迟同时推高。

本文从「为什么需要」讲起,拆解 SQ/CQ 的内存布局与提交/收割流程,给出 liburing 的最小可用代码,再深入注册文件、固定缓冲与 SQPOLL 轮询模式,对比 libaio/POSIX AIO/epoll 的差异,最后落到数据库与存储引擎的生产实践与排错清单。

前置:IO 栈与块层基础见 Linux IO 栈与存储性能调优 ,其中第 5 节给出了 io_uring 的入门定位;底层观测手段见 eBPF 与可观测性 。

1. 为什么需要 io_uring:同步 I/O 的三重开销

一次同步 read() 的成本不只是「等数据」:它包含系统调用陷入、内核态上下文切换、以及为每次 I/O 建立/销毁内核对象(如 kiocb、iov 数组拷贝)的固定开销。当请求变小、IOPS 变高时,这些固定开销的占比迅速上升。

同步模型:   应用 → syscall → 内核执行 IO → 返回 → 应用 → syscall → ...
             (每次 IO 都要陷入一次内核,串行等待)

io_uring:   应用批量填 SQ → 一次 io_uring_enter → 内核异步执行
             内核写 CQ ← 应用无锁收割(可选择性陷入)

传统异步方案各有短板:

方案机制主要限制
POSIX AIO (glibc)用线程池「假装异步」实为多线程同步,开销大
libaio (kernel AIO)原生内核异步基本只支持 O_DIRECT,接口受限
epoll + 非阻塞事件驱动仅网络套接字,不适合文件 I/O
io_uring共享环形队列 + 内核异步需 Linux 5.1+,API 略复杂

心智:io_uring 的核心不是「更快的 IO」,而是「更低的每次 IO 固定成本」——它把「提交」和「收割」从逐次系统调用变成对共享内存的读写,因此 IOPS 越高、请求越小,收益越大。

上下文切换的真实成本

一次系统调用的直接成本约几百纳秒,但真正昂贵的是缓存与 TLB 污染和调度器介入。当每秒钟发生几十万次 I/O 时,成本被放大成数量级差异:

假设单次 sync read 固定开销 ≈ 1.5 μs(syscall + 切换 + 对象建立)
50 万 IOPS × 1.5 μs = 0.75 s CPU 时间/秒  → 单核几乎全耗在「调度」而非「干活」
io_uring 批量提交后固定开销摊薄到 ≈ 0.1 μs/IO 量级
指标同步 readio_uring(批量+注册)
每次 I/O 系统调用1≈ 0(批量摊薄)
内核对象建立每次新建一次注册复用
缓冲区 pin每次一次注册常驻
上下文切换每次阻塞仅收割时(可零)

三种「异步」语义的区分

不要混淆三个词:**阻塞(blocking)**指调用不返回直到完成;**非阻塞(non-blocking)**指调用立即返回、未就绪时给 EAGAIN;**异步(asynchronous)**指调用立刻返回、完成时另行通知。io_uring 是真正的异步——提交后你不必轮询,内核通过 CQ 通知完成。

记忆:epoll 是「就绪通知」模型(告诉你「可以读了」),io_uring 是「完成通知」模型(告诉你「已经读完了」),后者对文件 I/O 尤其重要,因为普通文件总是「就绪」的,epoll 对它无能为力。

2. SQ/CQ:共享环形队列的机制

io_uring 的全部魔法都在一块由内核与应用共同映射的内存里。它由两个环形队列组成:

                 ┌──────────── 共享内存(mmap)────────────┐
应用写 →  SQ ring │  sqe[0] sqe[1] sqe[2] ... sqe[N]        │
(提交队列)      │   tail(应用写) / head(内核读)             │
                 ├─────────────────────────────────────────┤
内核写 →  CQ ring │  cqe[0] cqe[1] ... cqe[M]                │
(完成队列)      │   head(应用读) / tail(内核写)             │
                 └─────────────────────────────────────────┘

关键点:

  • SQE(Submission Queue Entry)是定长 128 字节的提交描述符,内含 opcode、fd、缓冲区地址、长度、offset、user_data 等字段。
  • CQE(Completion Queue Entry)是定长 16 字节,只有结果码(res)、标志位与 user_data(原样回传,用来关联请求)。
  • 应用只写 SQ tail,内核只写 CQ tail,双方各自推进 head;由于读写指针分属不同方向,绝大多数操作无需锁。
  • 应用填完 SQE 后需写内存屏障(io_uring_sqring_wait / io_uring_submit 内部已处理),再通过 io_uring_enter() 通知内核「有新请求」。
提交路径:填 SQE → 推进 SQ tail → (可选)io_uring_enter 唤醒内核
收割路径:读 CQ head → 处理 CQE → 推进 CQ head(不陷入内核)

记忆:SQ 是「订单盒」,CQ 是「取件柜」。你往订单盒里塞单子、内核取走执行、完成后把回执放进取件柜;你不需要每次敲门问「好了没」,直接翻取件柜即可。

SQE 关键字段

字段含义常用取值
opcode操作类型IORING_OP_READ/WRITE/FSYNC/ACCEPT…
fd目标文件描述符或用注册索引(IOSQE_FIXED_FILE)
addr / len缓冲区地址与长度需按设备块大小对齐(O_DIRECT)
off文件偏移流式 I/O 可设 -1 表示追加
user_data用户自定义关联值常放指针或请求 ID,原样回传
flags行为标志IOSQE_IO_LINK/IOSQE_IO_DRAIN/IOSQE_FIXED_FILE
buf_index固定缓冲索引配 IORING_REGISTER_BUFFERS 使用

CQE 则极简,只有 user_data、res(结果或负 errno)和 flags。正因如此,关联「哪个请求完成了」完全靠你在 user_data 里塞的上下文——务必保证它在 I/O 完成前一直有效。

/* 把请求结构体指针塞进 user_data,完成时取回 */
struct req { int id; char *buf; };
struct req r = { .id = 42 };
io_uring_sqe_set_data(sqe, &r);
/* ... 收割时 ... */
struct req *done = io_uring_cqe_get_data(cqe);

铁律:user_data 指向的内存必须在 CQE 被 seen 之前保持有效。常见 bug 是把栈上的临时请求塞进 user_data 后函数就返回了。

3. liburing 最小可用示例

直接操作裸 ring 太繁琐,生产代码几乎都用 liburing(io_uring 的官方 C 封装库)。

# Debian/Ubuntu 安装开发库
sudo apt install liburing-dev
# RHEL 系
sudo dnf install liburing-devel
# 确认内核支持(5.1 起步,5.6+ 更完整)
uname -r

一个「读文件并打印」的最小例子:

#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    struct io_uring ring;
    /* 队列深度 8:表示最多 8 个在途请求 */
    io_uring_queue_init(8, &ring, 0);

    int fd = open("/etc/hostname", O_RDONLY);
    char buf[4096];

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
    io_uring_sqe_set_data(sqe, buf);      /* user_data 回传 */

    io_uring_submit(&ring);               /* 提交,必要时 io_uring_enter */

    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);       /* 等一个完成 */
    if (cqe->res < 0)
        fprintf(stderr, "read failed: %d\n", cqe->res);
    else
        fwrite(buf, 1, cqe->res, stdout);

    io_uring_cqe_seen(&ring, cqe);        /* 推进 CQ head */
    io_uring_queue_exit(&ring);
    close(fd);
    return 0;
}
gcc -O2 -o uread uread.c -luring
./uread

批量提交才是 io_uring 的常态:一次 io_uring_submit 提交 N 个 SQE,一次 io_uring_wait_cqe/peek_cqe 收割多个 CQE,把系统调用次数从 O(N) 压到 O(1)。

4. 注册文件与固定缓冲:消除每次提交的固定开销

即便有了 ring,每次提交 SQE 内核仍要根据 fd 查文件表(fget/fput)、pin 住用户缓冲区(get_user_pages)。用 io_uring_register 预先注册,可以把这两项也省掉。

/* 注册一组 fd,之后 SQE 里用索引代替 fd */
int fds[2] = { fd_a, fd_b };
io_uring_register_files(&ring, fds, 2);
io_uring_prep_read(sqe, 0, buf, len, off);   /* 0 表示第 0 个注册 fd */
sqe->flags |= IOSQE_FIXED_FILE;

/* 注册固定缓冲,内核提前 pin 页并建立映射 */
io_uring_register_buffers(&ring, iovecs, nr);
sqe->buf_index = 0;                          /* 用第 0 块固定缓冲 */
注册项省掉的开销适用场景
注册文件 REGISTER_FILES每次 fget/fput 原子操作大量小 IO 到固定几个 fd
固定缓冲 REGISTER_BUFFERS每次 get_user_pages/pin高 IOPS、缓冲区复用
事件 fd REGISTER_EVENTFD轮询/阻塞切换与 epoll 集成
环形队列资源 REGISTER_RINGS每次 mmap 系统调用多进程共享同一 ring

心法:「注册」是 io_uring 性能曲线的第二级台阶。裸 ring 已经比同步快,注册文件 + 固定缓冲再砍掉一层固定成本,小请求场景提升尤其明显。

5. SQPOLL 与内核侧轮询

默认情况下,应用填完 SQE 要调用 io_uring_enter() 唤醒内核,这本身还是一次系统调用。IORING_SETUP_SQPOLL 让内核起一个专属内核线程持续轮询 SQ,应用只需写 tail,内核线程自动取走——提交路径彻底零系统调用。

struct io_uring_params p = {0};
p.flags = IORING_SETUP_SQPOLL;
p.sq_thread_idle = 2000;      /* 空闲 2 秒后内核线程休眠(毫秒) */
io_uring_queue_init_params(256, &ring, &p);

/* 应用只需检查 SQ 是否被内核「看到」,无需 enter */
if (io_uring_sq_ready(&ring))
    io_uring_sqring_wait(&ring);   /* SQPOLL 下的等待原语 */
# 运行后可见内核轮询线程 [iou-sqp-xxxx]
ps -eLf | grep iou-sqp
# 查看当前进程的 io_uring 实例
ls -l /proc/<pid>/fd | grep io_uring
模式提交系统调用CPU 占用适合
默认需要 enter低通用、低 IOPS
SQPOLL零(写 tail 即可)一个核持续忙极高 IOPS、专用核
SQPOLL + IOPOLL零核忙 + 设备轮询NVMe 极致低延迟

IORING_SETUP_IOPOLL 再进一步:让内核轮询设备完成队列而非等中断,省掉中断开销,但要求设备支持轮询(典型是 NVMe),且必须配 O_DIRECT。

铁律:SQPOLL 用「一个常驻 CPU 核」换「提交零 syscall」。只有在 IOPS 足够高、且能接受牺牲一个核时才有净收益;普通业务开 SQPOLL 反而浪费 CPU。

6. 与 libaio、POSIX AIO、epoll 的对比

维度同步 readPOSIX AIOlibaioio_uring
系统调用次数每次 I/O 1 次每次 1 次+线程池提交/收割各 1 次可批量,可零
缓冲 I/O 支持是是基本仅 O_DIRECT两者皆可
网络 I/O需 epoll否否是(send/recv/accept)
零拷贝/固定缓冲否否部分是
内核轮询否否否是(SQPOLL/IOPOLL)
复杂度低中中中高

io_uring 的独特之处在于统一:同一个 ring 既能做文件 I/O,也能做网络收发(io_uring_prep_send/recv/accept/connect),甚至支持 splice、openat、statx、timeout、fsync 等上百种 opcode。这让「一个事件循环同时管磁盘和网络」成为可能。

记忆:epoll 解决的是「网络套接字多路复用」,io_uring 解决的是「所有 I/O 的异步化」。两者不冲突,io_uring 可注册 eventfd 与 epoll 协同。

7. 链式请求、多 shot 与事件循环

io_uring 不止能提交「孤立」的请求,它还能把请求串成依赖链、让一个 SQE 持续产出多个 CQE,这正是构建高性能事件循环的基础。

IOSQE_IO_LINK:把请求串成链

给 SQE 打上 IOSQE_IO_LINK,它就与下一个 SQE 形成「链接」——只有当前一个成功完成,下一个才会执行;若中途失败,后续整条链被取消(-ECANCELED)。

/* 顺序:read → write → fsync,任一失败则后续取消 */
io_uring_prep_read(sqe1, fd_in, buf, len, 0);
sqe1->flags |= IOSQE_IO_LINK;

io_uring_prep_write(sqe2, fd_out, buf, len, 0);
sqe2->flags |= IOSQE_IO_LINK;

io_uring_prep_fsync(sqe3, fd_out, 0);   /* 链尾,无 LINK */
标志语义
IOSQE_IO_LINK与下一个 SQE 硬链接,前一个成功才跑下一个
IOSQE_IO_HARDLINK类似 LINK,但前一个失败也继续(更强制)
IOSQE_IO_DRAIN本请求前,所有先前请求必须完成(屏障)
IOSQE_ASYNC强制走异步工作线程,即使本可内联完成

多 shot:一个 SQE,多个 CQE

IOSQE_BUFFER_SELECT + 多 shot opcode(如 IORING_OP_RECV_MULTISHOT、IORING_OP_ACCEPT 多 shot)让一次提交持续产生完成事件,省掉「每收一个包再提交一次」的往返。

/* 多 shot recv:一次提交,多次完成,直到显式取消 */
io_uring_prep_recv_multishot(sqe, sockfd, NULL, 0, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0;                 /* 从注册的 buffer group 0 取缓冲 */

/* 收割:每个 CQE 的 res 是本次收到字节数,flags 含 IORING_CQE_F_MORE 表示还有后续 */

典型事件循环骨架

loop:
  1. 有请求要发 → io_uring_get_sqe + prep_* + io_uring_submit
  2. 没有要发的   → io_uring_submit_and_wait(&ring, 1) 阻塞等一个完成
  3. 批量 peek CQE(io_uring_peek_batch_cqe)→ 逐个处理
  4. 处理完 → io_uring_cq_advance 一次性推进 head(比逐个 seen 更快)

心法:把「提交」与「收割」都批量化,是 io_uring 事件循环的性能关键。用 io_uring_peek_batch_cqe 一次拿一批 CQE、用 io_uring_cq_advance 一次推进,比逐个 wait_cqe/seen 减少大量内存屏障与函数调用。

8. 生产落地:数据库、存储引擎与网络服务

io_uring 已在多个关键系统落地,通常以「可选后端」形式提供:

# PostgreSQL:io_uring 相关(部分版本/补丁集)
# postgresql.conf 中 io_method 相关开关视版本而定
# RocksDB:编译期启用 io_uring
# nginx:--with-io_uring 编译开关
# ScyllaDB / 部分 KV 存储:原生 io_uring 后端

落地前要确认内核版本与权限:

# 检查内核是否支持 io_uring(探测 io_uring_setup 系统调用)
grep -w io_uring_setup /proc/kallsyms >/dev/null && echo supported
# 部分发行版限制非特权用户使用
sysctl kernel.io_uring_disabled
# 值:0=不限制 1=仅特权 2=完全禁用(较新内核引入)
系统使用方式备注
PostgreSQL数据文件 I/O 后端需特定版本/补丁
RocksDBio_uring 作为 FileSystem 后端编译开关控制
nginx事件循环 I/O 后端编译期 --with-io_uring
自研存储引擎直接调 liburing收益最大

容器与安全场景

io_uring 与容器、安全策略存在一些摩擦,落地前要特别确认:

# 1. seccomp:Docker 默认 profile 早期会拦截 io_uring_setup
#    新版 Docker/libseccomp 已放行,但自定义 seccomp 需显式允许
# 2. 内核开关(5.12+ 引入)
sysctl kernel.io_uring_disabled    # 0 允许 / 1 仅特权 / 2 禁用
# 3. cgroup:SQPOLL 内核线程会计入所属 cgroup 的 CPU 消耗
场景关注点建议
容器内应用seccomp 是否放行 io_uring_setup检查/放宽 seccomp profile
多租户主机io_uring_disabled=1 限制非特权用户按需设 1
安全加固基线io_uring 曾有历史 CVE及时跟进内核补丁
与 cgroup 配额SQPOLL 线程吃 CPU 配额预留核或关闭 SQPOLL

铁律:先确认瓶颈真的在「I/O 提交开销」再上 io_uring。如果 iostat 显示设备 util 已饱和、await 很高,那是设备本身慢,换 io_uring 也救不了;只有当设备不忙、CPU 却耗在 syscall/上下文切换上时,io_uring 才是对症的药。

9. 观测、调优与排错

io_uring 是「不可见」的——strace 默认只显示少数几个入口调用,看不到内部每个 SQE。可用以下手段观测:

# strace 追踪提交/收割入口
strace -e trace=io_uring_setup,io_uring_enter,io_uring_register -f ./app

# 用 bpftrace 统计各 opcode 的提交次数(需要内核支持 io_uring tracepoint)
bpftrace -e 'tracepoint:io_uring:io_uring_submit_sqe { @[args->opcode] = count(); }'

# 查看进程的 io_uring 实例与其参数
ls -l /proc/<pid>/fd | grep io_uring
cat /proc/<pid>/fdinfo/<ring_fd>     # 显示 SQ/CQ 指针、提交数等

常见问题与对策:

现象原因对策
-EINVAL内核版本过低/opcode 不支持升级内核或降级用 libaio
-EBADF用了未注册的 fd 却带 FIXED_FILE去掉该标志或先注册
-EAGAIN队列满或非阻塞资源未就绪加大 queue_depth、重试
吞吐不升反降SQPOLL 白耗 CPU关掉 SQPOLL 回默认模式
固定缓冲报错缓冲区未对齐按页/块对齐,或用非固定缓冲
# 对比压测:libaio vs io_uring 引擎
fio --name=t --rw=randread --bs=4k --iodepth=64 --ioengine=libaio --filename=/dev/nvme0n1 --direct=1 --runtime=30 --time_based
fio --name=t --rw=randread --bs=4k --iodepth=64 --ioengine=io_uring --filename=/dev/nvme0n1 --direct=1 --runtime=30 --time_based

铁律:排错先降队列深度与关轮询,回到最小可复现。很多「io_uring 不工作」其实是权限(io_uring_disabled)、内核版本或缓冲对齐问题。

10. 速查表

需求做法
初始化 ringio_uring_queue_init(depth, &ring, 0)
提交读请求io_uring_prep_read(sqe, fd, buf, len, off)
提交io_uring_submit(&ring)
等待完成io_uring_wait_cqe(&ring, &cqe)
收割完成io_uring_cqe_seen(&ring, cqe)
注册固定 fdio_uring_register_files(&ring, fds, n)
固定缓冲io_uring_register_buffers(&ring, iov, n)
零 syscall 提交IORING_SETUP_SQPOLL
设备轮询IORING_SETUP_IOPOLL(需 O_DIRECT)
观测提交bpftrace ... io_uring_submit_sqe
检查可用性sysctl kernel.io_uring_disabled

一句话记忆:io_uring 用一对共享环形队列(SQ/CQ)把 I/O 提交与收割从「逐次 syscall」变成「读写共享内存」;注册文件与固定缓冲砍掉 fget/pin 开销,SQPOLL 再砍掉提交 syscall,IOPOLL 连中断都省掉——IOPS 越高收益越大,但务必先确认瓶颈真的在提交开销而非设备本身。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「linux」更多文章

  1. PAM 认证与权限提升:模块栈、密码策略与 sudo 深入
  2. NFS 与 CIFS 网络文件系统:服务端配置、挂载调优与安全
  3. LVM 与 RAID 存储管理:卷管理、快照与软阵列运维