Linux io_uring 高性能异步 IO 深度实战:革新 Linux 异步 IO 的完全体
1. 从传统异步 IO 到 io_uring
在 io_uring 出现之前,Linux 用户态与"真正的异步 IO"之间存在巨大鸿沟。当时的几种方案各有明显短板:
epoll + 线程池模型适合高并发网络服务,但对于同步文件 IO(磁盘读写)本质上仍然是阻塞的,不得不额外使用线程池来模拟异步效果,线程切换开销不可忽视。
POSIX AIO(aio_read/aio_write)底层用线程池包装同步调用实现"伪异步",不支持 buffered IO,也无法用于网络 socket,适用场景极其有限。
Linux native AIO(libaio)虽然真正走内核路径,但强制要求 O_DIRECT 标志导致 page cache 失效,每次请求仍需两次文件系统查找(getdents + open),且不支持网络 IO。
2019 年,Jens Axboe 在 Linux 5.1 引入 io_uring,从内核层面统一了解决异步 IO 的架构问题。它不仅仅是一组新 API,更是一套全新的内核-用户态通信范式。
2. io_uring 核心架构
2.1 双环形队列:用户态与内核共享内存
io_uring 最核心的设计是两个环形队列(Ring Buffer),它们通过 io_uring_setup() 系统调用创建,且 SQ(提交队列)和 CQ(完成队列)共享同一块内存映射区域:
SQ(Submission Queue):用户态向 SQ 写入 IO 请求描述符(SQE),内核从中读取并执行。
CQ(Completion Queue):内核将已完成请求的结果(CQE)写入 CQ,用户态从中读取。
两个队列的 head/tail 指针通过共享内存直接访问,无需任何系统调用即可读写。这是 io_uring 性能优势的根本来源——减少了 syscall 开销。
2.2 三种工作模式
默认模式:用户态通过 io_uring_enter() 系统调用通知内核检查 SQ 中的新请求。适合一般场景,开销可控。
内核轮询模式(SQPOLL):设置 IORING_SETUP_SQPOLL 标志后,内核启动一个专用线程(kio_uring)持续轮询 SQ。用户态写入 SQ 后无需 syscall 即可被内核拾取,进一步降低延迟。代价是该内核线程会持续消耗绑定的 CPU 核心。
IO 轮询模式(IOPOLL):设置 IORING_SETUP_IOPOLL 标志后,内核使用轮询而非中断方式完成 IO 操作。配合 NVMe SSD 等低延迟设备,可将单次 IO 延迟降至微秒级。
// 初始化轮询模式 io_uring
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL; // 内核线程轮询 SQ
params.sq_thread_cpu = 4; // 绑定 CPU 4
params.sq_thread_idle = 2000; // 空闲2秒后线程休眠
io_uring_queue_init_params(4096, &ring, ¶ms);
3. liburing 实战编程
3.1 初始化与清理
#include <liburing.h>
struct io_uring ring;
// 初始化:entries 大小自动向上取整到 2 的幂
int ret = io_uring_queue_init(4096, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring_queue_init failed: %s\n", strerror(-ret));
return -1;
}
// ... 使用后将 ring 销毁
io_uring_queue_exit(&ring);
3.2 提交 IO 请求
获取 SQE -> 填充操作参数 -> 提交给内核:
// 获取一个 SQE(非阻塞,若 SQ 满则返回 NULL)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) { /* SQ 满了,需要先提交部分请求再继续 */ }
// 封装一个 pread 操作
io_uring_prep_read(sqe, fd, buf, BUF_SIZE, offset);
// 设置用户数据,用于后续匹配完成事件
io_uring_sqe_set_data64(sqe, (uint64_t)my_ctx);
// 提交一个或多个 SQE 给内核(仅更新 tail 指针,不一定触发 syscall)
io_uring_submit(&ring);
// 或合并等待:提交并阻塞等待至少 min_complete 个完成事件
io_uring_submit_and_wait(&ring, 1);
3.3 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
unsigned completed = 0;
// 遍历 CQ 中所有已完成的 CQE
io_uring_for_each_cqe(&ring, head, cqe) {
my_ctx = (void*)cqe->user_data;
if (cqe->res < 0) {
fprintf(stderr, "IO failed: %s\n", strerror(-cqe->res));
} else {
printf("IO completed, transferred %d bytes\n", cqe->res);
}
completed++;
}
// 通知内核已消费 completed 个 CQE(更新 CQ head 指针)
io_uring_cq_advance(&ring, completed);
3.4 支持的 IO 操作类型
liburing 封装了几乎所有内核支持的 IO 操作:
基础文件 IO:
prep_read / prep_write / prep_readv / prep_writev / prep_read_fixed / prep_write_fixed网络 IO:
prep_recv / prep_send / prep_recvmsg / prep_sendmsg / prep_accept / prep_connect / prep_shutdown文件管理:
prep_openat / prep_close / prep_fadvise / prep_fallocate / prep_fsync / prep_renameat / prep_unlinkat同步控制:
prep_poll_add / prep_poll_remove / prep_cancel高级操作:
prep_splice / prep_tee / prep_madvise / prep_statx
4. 优化技巧与高级特性
4.1 注册固定缓冲区(Registered Buffers)
每次 IO 操作时内核需要 get_user_pages() 锁定用户内存。注册固定缓冲区后,预分配的内存在初始化时即被内核锁定和映射,后续 IO 不再需要页表遍历:
// 预分配 page-aligned 缓冲区
char pool[BUFS_PER_GROUP][BUF_SIZE] __attribute__((aligned(4096)));
// 批量注册到 io_uring(内核内部建立映射)
int ret = io_uring_register_buffers(&ring, pool, BUFS_PER_GROUP);
// 提交时使用固定缓冲区(buf_index 表示池中第几个缓冲区)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset, buf_index);
io_uring_sqe_set_flags(sqe, IOSQE_BUFFER_SELECT); // 对于 read,让内核自动选择 buffer
4.2 注册固定文件(Registered Files)
每次 IO 操作都需要从 fdtable 中查找 file 结构。注册文件数组后,内核可跳过 table 查找直接从数组索引获取:
int fds[] = {file_fd1, file_fd2, file_fd3};
io_uring_register_files(&ring, fds, 3);
// 提交时使用注册索引(fd=0 即为 fds[0]),并标记 IOSQE_FIXED_FILE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, size, offset);
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE);
4.3 链接操作(Linked SQEs)
通过 IOSQE_IO_LINK 标志将多个 SQE 串行链接,前一个完成后自动触发下一个。这避免了等待前一个完成事件后再提交的系统调用开销:
// 链式:write 数据 -> write 元数据 -> fsync 落盘
sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe1, fd, data, data_len, data_offset);
io_uring_sqe_set_flags(sqe1, IOSQE_IO_LINK);
sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, fd, meta, meta_len, meta_offset);
io_uring_sqe_set_flags(sqe2, IOSQE_IO_LINK);
sqe3 = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe3, fd, 0);
// 最后一个不需要 IOSQE_IO_LINK
io_uring_submit(&ring); // 一次性提交全部
4.4 _buffered select 机制
对于 read 操作,通过 IOSQE_BUFFER_SELECT 标志,让内核自动从已注册的 buffer pool 中选择一个空闲缓冲区:
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, NULL, BUF_SIZE, 0);
io_uring_sqe_set_flags(sqe, IOSQE_BUFFER_SELECT);
io_uring_submit(&ring);
// 完成的 CQE 中通过 flags 字段获取被选中的缓冲区索引
int buf_id = (cqe->flags >> IORING_CQE_BUFFER_SHIFT) & 0xFFFF;
if (cqe->flags & IORING_CQE_F_BUFFER) {
// 使用 pool[buf_id] 中的数据
}
5. 三个实战案例
5.1 案例一:KV 存储引擎的批量 IO
一个高性能 KV 引擎需要同时处理大量读 io_uring 的批量提交能力可以显著减少 syscall 次数:
typedef struct {
int op; // OP_READ or OP_WRITE
int fd;
void *buf;
size_t len;
off_t offset;
void *user_ctx;
} kv_request_t;
void kv_batch_submit(struct io_uring *ring, kv_request_t *reqs, int n) {
for (int i = 0; i < n; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
if (reqs[i].op == OP_READ)
io_uring_prep_read(sqe, reqs[i].fd, reqs[i].buf, reqs[i].len, reqs[i].offset);
else
io_uring_prep_write(sqe, reqs[i].fd, reqs[i].buf, reqs[i].len, reqs[i].offset);
io_uring_sqe_set_data(sqe, &reqs[i]);
}
io_uring_submit(ring); // 批量提交 n 个 IO 请求,仅 1 次 syscall(或 0 次 if SQPOLL)
}
5.2 案例二:多阶段 HTTP 请求处理
利用 IOSQE_IO_LINK 实现 accept -> recv -> parse -> send 的无缝衔接:
void http_submit_handler(struct io_uring *ring, int client_fd) {
// Stage 1: 接收请求
sqe[0] = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe[0], client_fd, req_buf, BUF_SIZE, 0);
io_uring_sqe_set_flags(sqe[0], IOSQE_IO_LINK);
// Stage 2: 发送响应(Stage 1 完成后立即执行,无需等待完成事件)
sqe[1] = io_uring_get_sqe(ring);
io_uring_prep_send(sqe[1], client_fd, resp_buf, resp_len, 0);
io_uring_submit(ring);
// 只需收割 1 个完成事件(send)
}
5.3 案例三:WAL(预写日志)的可靠持久化
数据库的 WAL 需要保证数据先于元数据落盘。用 link 指令确保 fsync 在数据写入完成后执行:
void wal_append(struct io_uring *ring, int wal_fd,
void *data, size_t len, off_t offset) {
// 1. 写入数据
sqe_data = io_uring_get_sqe(ring);
io_uring_prep_write(sqe_data, wal_fd, data, len, offset);
io_uring_sqe_set_flags(sqe_data, IOSQE_IO_LINK);
// 2. fsync 确保落盘(仅当 write 成功后执行)
sqe_sync = io_uring_get_sqe(ring);
io_uring_prep_fsync(sqe_sync, wal_fd, IORING_FSYNC_DATASYNC);
io_uring_submit(ring);
}
6. 性能对比数据
基于 NVMe SSD 4KB 随机 read 的实际测试数据(来源:Jens Axboe 基准测试):
io_uring + IOPOLL + SQPOLL + 注册缓冲:约 3.5 μs/op,可达 2.8M+ IOPS。这是当前 Linux 平台最高的裸 IO 性能,且仅需 1 个用户态线程。
io_uring 默认模式:约 6 μs/op,约 1.6M IOPS。相比传统方案仍有巨大优势。
epoll + 8 线程 + 同步 IO:约 25 μs/op,约 400K IOPS。多出 ~6 倍的延迟主要来自线程上下文切换和 syscall 开销。
libaio(原生 AIO):约 12 μs/op,极限约 800K IOPS,但强制 O_DIRECT 使得无法利用 page cache。
7. 安全考量
io_uring 的强大能力也带来了安全风险:
绕过 syscall 监控:由于 io_uring 可以直接在内核态处理 IO 的安全策略,传统基于 syscall 拦截的沙箱(seccomp)可能遗漏对 io_uring 执行的检查。
Firefox 的封杀:Mozilla 发现内核 io_uring 插件可能被用于绕过沙箱,因此 Firefox 完全禁用了 io_uring。而 Chromium 保留了 io_uring(用于 WebAssembly 加速),但对其行为做了严格限制。
- 权限收紧:Linux 5.12+ 引入了
io_uring_disabledsysctl,允许 root 控制是否允许非特权进程创建 io_uring 实例。
8. 最佳实践总结
合理选择 SQ 大小:建议 2 的幂次,如 1024 或 4096。过小会导致频繁提交,过大会浪费内存。
批量提交优于逐个提交:尽量一次性填充多个 SQE 后一次
io_uring_submit(),最大化 batching 效果。注册缓冲区应对高频 IO:如果同一批缓冲区会被反复读写,注册后省去的页表操作在 IOPS 极高时有可观收益。
SQPOLL 注意 CPU 占用:生产环境通常绑定专用核给 SQ 线程,使用
sq_thread_idle控制空闲时的休眠。内核版本选择:推荐 Linux 5.15+,该版本大幅改进了 io_uring 的稳定性和功能完整度(如完善的
IORING_OP_MSG_RING支持)。

发表评论 取消回复