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, &params);

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_disabled sysctl,允许 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 支持)。

参考资料

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部