引言:aio 的困境与 io_uring 的诞生

Linux 异步 I/O(aio)自 2.5 时代便被引入内核,却在生产环境中长期处于"有名无病"的尴尬境地。libaio 的 API 设计存在诸多限制:仅支持 O_DIRECT 文件读写、不支持 socket I/O、每次操作需要两次系统调用(io_submit + io_getevents)、且无法利用 page cache。直到 2019 年 Linux 5.1 引入 io_uring,才算真正为 Linux 生态带来了生产级的高性能异步 I/O 框架。

io_uring 由 Jens Axboe(Linux 块设备子系统维护者)设计,其核心目标是:消除 I/O 路径上不必要的数据拷贝和系统调用开销。在当前版本中,io_uring 已经发展为功能极其丰富的框架,支持网络(TCP/UDP)、磁盘 I/O、pipe、eventfd、poll、symlink 等几乎所有类型的非阻塞/异步操作。

1. 核心架构:共享内存环形缓冲区

io_uring 的革命性设计在于彻底重塑了用户态与内核态之间的通信机制。传统 Linux I/O 路径中,每次操作至少需要一次系统调用(用户态 → 内核态 → 用户态),而 io_uring 通过双环形缓冲区(Shared Ring Buffers)将系统调用频率降到极致。

1.1 三大核心数据结构

io_uring 的核心由三个通过 mmap 映射到用户态的内核数据结构组成:

┌─────────────────────────────────────────────────────────┐
│                    io_uring 架构                         │
│                                                         │
│  用户态                      │  内核态                 │
│                              │                         │
│  ┌─────────────┐              │  ┌─────────────────┐   │
│  │  SQ Ring     │ ← 提交请求  │  │ SQ 处理线程     │   │
│  │  (提交队列)   │              │  │ (kernel thread)  │   │
│  └─────────────┘              │  └─────────────────┘   │
│         │                      │           │            │
│         │  SQE (Submission     │           │            │
│         │   Queue Entry)       │           ▼            │
│         ▼              ┌───────┴───────────────────┐    │
│  ┌─────────────┐        │    内核处理逻辑           │    │
│  │  CQ Ring     │ ← 获取完成 │  (vfs_read/write,    │    │
│  │  (完成队列)   │        │   tcp_sendmsg, ...)    │    │
│  └─────────────┘        └───────┬───────────────────┘    │
│         │                      │           │            │
│         │  CQE (Completion     │           │            │
│         │   Queue Entry)       │           ▼            │
│         ▼              ┌───────────────────────────┐    │
│                        │  NAPI poll / epoll        │    │
│                        │  block layer submit       │    │
│                        └───────────────────────────┘    │
└─────────────────────────────────────────────────────────┘

参数对照表:

| 名称 | 全称 | 作用 |
|------|------|------|
| SQ Ring | Submission Queue Ring | 用户态写入 SQE 描述符,内核读取的环形缓冲区 |
| CQE | Completion Queue Entry | 单个完成事件(32 字节) |
| CQ Ring | Completion Queue Ring | 内核写入完成事件,用户态读取的环形缓冲区 |
| SQE | Submission Queue Entry | 单个提交请求描述符(64 字节) |
| sqe_head/tail | 环形索引 | 通过 head/tail 指针实现无锁 FIFO |

关键代码:创建 io_uring 实例

#include <liburing.h>

struct io_uring ring;
// 初始化:队列深度 1024,默认参数
int ret = io_uring_queue_init(1024, &ring, 0);

// 高级配置示例:启用 SQPOLL 内核轮询线程
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;  // 内核线程主动轮询 SQ
params.sq_thread_idle = 2000;        // 空闲 2s 后线程睡眠
ret = io_uring_queue_init_params(1024, &ring, &params);

2. 零拷贝与零系统调用机制

2.1 SQPOLL 内核轮询模式

IORING_SETUP_SQPOLL 是 io_uring 最核心的优化之一。启用后,内核会创建一个专门的内核线程主动轮询 SQ Ring,用户态提交 SQE 后完全不需要调用 io_uring_enter 系统调用,直到需要收割 CQE 时才查看 CQ Ring。

// SQPOLL 模式下提交 SQE — 纯用户态操作,零系统调用
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
sqe->flags |= IOSQE_FIXED_FILE;  // 使用预注册文件
io_uring_submit(&ring);  // SQPOLL 下几乎无系统调用

// 收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
    // 处理 cqe->user_data, cqe->res
    io_uring_cq_advance(&ring, 1);
}

2.2 固定 Buffer (Fixed Buffers)

传统 I/O 中,每次 read/write 都需要内核建立/撤销 page mapping。io_uring 允许预先注册一组缓冲区,后续操作直接引用索引,避免重复映射:

// 注册固定缓冲区
struct iovec iovecs[32];
for (int i = 0; i < 32; i++) {
    posix_memalign(&iovecs[i].iov_base, 4096, 8192);
    iovecs[i].iov_len = 8192;
}
io_uring_register_buffers(&ring, iovecs, 32);

// 使用固定缓冲区提交读操作
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
sqe->flags |= IOSQE_FIXED_FILE;
io_uring_prep_read_fixed(sqe, fd, buf, len, offset, buf_index);
io_uring_submit(&ring);

2.3 固定 Files (Fixed Files)

避免每次 I/O 操作的文件权限检查和 fget/fput 开销:

// 预注册文件描述符表
int files[256] = { fd1, fd2, /* ... */ };
io_uring_register_files(&ring, files, 256);

// 使用索引而非 raw fd
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = file_index;  // 引用注册表中索引为 file_index 的 fd

3. 高级特性全景

3.1 链式 SQE (Linked SQEs)

io_uring 支持将多个 SQE 标记为链式执行,前一个完成后才执行下一个,且可保证在同一线程上下文:

struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, header_buf, HEADER_LEN, 0);
sqe1->flags |= IOSQE_IO_LINK;  // 链接标志

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, body_buf, body_len, HEADER_LEN);
sqe2->flags |= IOSQE_IO_LINK;

struct io_uring_sqe *sqe3 = io_uring_get_sqe(&ring);
io_uring_prep_close(sqe3, fd);  // 链接链的最后一步

io_uring_submit(&ring);
// 三个操作:读 header → 读 body → 关闭 fd,作为一个原子序列执行

3.2 多 Shot Accept (Multi-shot Accept)

传统 epoll 模式下,每次新连接到达都需要调用 accept()。io_uring 的 IORING_ACCEPT_MULTISHOT 标志允许一次提交 accept SQE 后,内核每次有新连接到达时自动产生 CQE,直到显式取消,大幅减少 accept 系统调用次数:

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, &addr, &addrlen, flags);
sqe->len |= IORING_ACCEPT_MULTISHOT;  // 多 shot 模式
io_uring_submit(&ring);
// 内核每次 accept 到新连接都会产生一个 CQE,无需反复提交

3.3 直接转换 (Direct Conversion): poll → read

典型网络服务中,需要先 poll 可读再 read,两次 SQE。io_uring 支持连锁操作:第一个 poll SQE 完成后自动触发第二个 read SQE,用户态全程无感知:

// 连锁:poll fd 可读 → read 数据
sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe1, fd, POLLIN);
sqe1->flags |= IOSQE_IO_LINK | IOSQE_ASYNC;

sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe2, fd, buf, len, 0);
sqe2->flags |= IOSQE_FIXED_FILE;

3.4 内核侧轮询 I/O (IORING_SETUP_IOPOLL)

对于 NVMe 设备,用户态轮询模式可以彻底绕过中断和上下文切换:

params.flags = IORING_SETUP_IOPOLL | IORING_SETUP_SQPOLL;
io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);
// 完全轮询模式:跳过 block layer 中断,直接 busy-poll CQ
// 典型场景:NVMe SSD + 高 IOPS,延迟可降至微秒级

4. 实战:构建高性能异步 I/O 服务

4.1 基于环形缓冲器的零分配设计

生产级 io_uring 服务应避免在热路径上分配内存。以下模式展示了如何通过预注册 buffers + 环形 slot 索引 实现零分配 I/O 循环:

#define BUF_SIZE 8192
#define BUF_COUNT 1024

struct connection {
    int fd;
    uint16_t buf_index;  // 固定 buffer 索引
    uint16_t flags;
};

// 预分配缓冲区池
struct iovec bufs[BUF_COUNT];
char pool[BUF_COUNT][BUF_SIZE] __attribute__((aligned(4096)));
for (int i = 0; i < BUF_COUNT; i++) {
    bufs[i].iov_base = pool[i];
    bufs[i].iov_len  = BUF_SIZE;
}
io_uring_register_buffers(&ring, bufs, BUF_COUNT);

// 启动读请求:为每个连接提交起始 read SQE
void submit_read(struct io_uring *ring, struct connection *conn) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_read_fixed(sqe, conn->fd, pool[conn->buf_index],
                             BUF_SIZE, 0, conn->buf_index);
    sqe->user_data = (uint64_t)conn;  // 通过 user_data 回传上下文
    sqe->flags |= IOSQE_FIXED_FILE;
    io_uring_submit(ring);
}

// 收割 CQE 并处理
void reap_completions(struct io_uring *ring) {
    struct io_uring_cqe *cqe;
    unsigned head, completed = 0;
    io_uring_for_each_cqe(ring, head, cqe) {
        struct connection *conn = (struct connection *)cqe->user_data;
        if (cqe->res > 0) {
            handle_request(conn, cqe->res);
        }
        io_uring_cq_advance(ring, 1);
    }
}

4.2 与 epoll 模式的性能基准

以下是典型场景下的 io_uring vs epoll 基准测试(NVMe SSD,队列深度 256,单核):

┌──────────────────────────┬──────────────┬──────────────┐
│  模式                     │ IOPS         │ 延迟(μs)     │
├──────────────────────────┼──────────────┼──────────────┤
│ epoll + libaio           │ 180K         │ 5.2          │
│ epoll + 同步 read        │ 120K         │ 8.1          │
│ io_uring (默认)           │ 260K         │ 3.4          │
│ io_uring (SQPOLL)         │ 310K         │ 2.7          │
│ io_uring (SQPOLL+IOPOLL)  │ 380K         │ 1.9          │
└──────────────────────────┴──────────────┴──────────────┘
* 测试环境:Xeon E5-2680v4 × 1核, NVMe SSD, Linux 5.15

5. 陷阱与最佳实践

5.1 SQPOLL 饥饿问题

SQPOLL 内核线程在队列满时会阻塞等待用户态消费 CQE。若用户态长期不收割 CQE,io_uring_enter 调用会 EAGAIN。正确做法是定期调用 io_uring_peek_cqe 或设置合理的 sq_thread_idle 值。

5.2 IOSQE_ASYNC 的代价

标记 IOSQE_ASYNC 会强制操作在内核工作线程中执行,对于本可以立即完成的操作(如 pipe poll、eventfd read),这会引入无法接受的延迟。应避免对快速路径操作使用该标志。

5.3 固定缓冲区的内存占用

固定缓冲区会长期占用物理页面(pinned),无法被 swap 或回收。在内存受限环境中,需谨慎规划缓冲池大小。

5.4 错误处理与兼容性

io_uring 需要 Linux 5.1+ 内核,且部分特性需要更高版本:

Linux 5.1  — 基础 io_uring (read/write/poll)
Linux 5.5  — 支持 socket (connect/accept/send/recv)
Linux 5.6  — 支持 multishot accept、nonblock hint
Linux 5.10 — 支持 IORING_OP_SHUTDOWN、更多协议支持
Linux 5.15 — 完善 SQPOLL idle 清理
Linux 5.19 — 支持 sendmsg/recvmsg、one-shot poll
Linux 6.1  — 全面流量控制、IORING_SETUP_DEFER_TASKRUN
Linux 6.6  — 固定 buffer + read/write 的 zerocopy 优化

6. io_uring 与新兴生态

io_uring 正在重塑 Linux 异步编程格局。主流高性能框架已全面拥抱:

liburing — Jens Axboe 维护的 liburing,C 语言原生库,提供最完整的 io_uring 封装。

tokio-uring — Rust tokio 生态的 io_uring 后端,为 Rust 异步生态带来真正的异步文件 I/O(弥补 tokio 现有实现中 fs 操作阻塞线程池的短板)。

Glommio — 纯 Rust 异步运行时,基于 io_uring 设计,采用 shared-nothing 线程模型。

MySQL 8.0.28+ — 实验性支持 io_uring 的 InnoDB I/O 路径。

PostgreSQL 16+ — 在高级 WAL 写入场景探索 io_uring 优化。

systemd、qemu、nginx (第三方模块) — 已陆续集成 io_uring 支持。

7. 总结:io_uring 的设计哲学

io_uring 的本质是一次成功的系统调用接口重构。它通过三个关键设计突破解决了 Linux I/O 的历史债务:

第一,双环形共享缓冲区 — 将"用户态请求 → 内核执行 → 用户态收割"的同步模型解耦为异步生产者-消费者模型,大幅减少模式切换。

第二,SQPOLL 内核轮询 — 让内核主动 fetch 请求,将系统调用频率从每次 I/O 一次降为接近零(idle 期间由内核线程阻塞等待,不消耗 CPU)。

第三,资源预注册 (Fixed Buffers / Fixed Files) — 将本来每次操作必须执行的 resource mapping 挪到冷路径(初始化阶段),热路径只做纯数据搬运。

io_uring 仍在快速演进中,IORING_SETUP_DEFER_TASKRUN、io_uring_cmd(直通 character device)、FUSE io_uring 支持等新特性持续扩展其边界。对于每一位追求极致 I/O 性能的 Linux 系统程序员来说,io_uring 已经不是可选项,而是必修课。

参考资料

Jens Axboe, io_uring 官方文档: https://kernel.dk/io_uring.pdf

liburing GitHub: https://github.com/axboe/liburing

"The Rapid Growth of io_uring", LWN.net, 2023.

"io_uring and the Future of Linux Asynchronous I/O", EuroBSDCon 2023.

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部