引言
传统的 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 量级
| 指标 | 同步 read | io_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 的对比
| 维度 | 同步 read | POSIX AIO | libaio | io_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 后端 | 需特定版本/补丁 |
| RocksDB | io_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. 速查表
| 需求 | 做法 |
|---|---|
| 初始化 ring | io_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) |
| 注册固定 fd | io_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 IO 栈与存储性能调优 — VFS/页缓存/块层与 fio 压测
- eBPF 与可观测性 — bpftrace 追踪内核 I/O 路径
- Linux 内存管理 — 页缓存、脏页与 writeback 机制
- Docker 专题 — 容器中的 I/O 隔离与存储驱动
- 数据库专题 — 存储引擎与 I/O 后端选型
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。