一、为什么需要 io_uring?传统异步 I/O 的痛点

Linux 异步 I/O 长期以来是一个"众人期待却未曾真正解决"的难题。在 io_uring 出现之前,开发者面临以下困境:

1. AIO 的局限性:Linux 原生 AIO(libaio)仅支持 O_DIRECT 模式的文件 I/O,无法用于缓冲 I/O,且网络 I/O 完全不支持。其语义在设计上就有诸多限制。

2. epoll 不是真正的异步 I/O:epoll 本质上是"就绪通知"机制,告诉你 fd 可读了,真正的 read/write 调用仍然会阻塞(或至少是同步的)。在高并发场景下,系统调用的开销不可忽视。

3. 系统调用开销:每次 read/write 都需要用户态↔内核态切换,在高 IOPS 场景下,系统调用本身成为瓶颈。Intel 的研究表明,单核每秒能处理的系统调用数量是有上限的,频繁的 syscall 消耗大量 CPU 周期。

io_uring 由 Jens Axboe(Linux 内核块设备层维护者)于 2019 年提出,目标是用统一、高效、可扩展的接口解决所有类型的异步 I/O 问题。自 Linux 5.1 引入以来,已成为 PostgreSQL、Redis、Nginx、Envoy、Netty 等核心基础设施的关键依赖。

二、io_uring 核心架构:共享内存环形队列

io_uring 的设计精髓在于用户态与内核态通过共享内存环形队列通信,在特定模式下可以完全避免系统调用。

2.1 双队列结构

io_uring 维护两个环形队列(Ring Buffer):

  • Submission Queue(SQ):用户态写入 I/O 请求(SQE),内核态消费
  • Completion Queue(CQ):内核态写入完成事件(CQE),用户态消费

这两个队列通过io_uring_setup()系统调用创建时映射到用户态内存,因此用户态提交请求时直接写入共享内存,无需 syscall(在 SQPOLL 模式下)。

2.2 关键数据结构

io_uring 的核心由以下几个部分组成:

  • io_uring:代表一个 io_uring 实例
  • io_uring_sqe(Submission Queue Entry):提交的 I/O 请求
  • io_uring_cqe(Completion Queue Entry):完成的 I/O 结果
  • io_uring_params:配置参数,包含队列大小、特性标志等

2.3 执行流程概览

一次完整的 io_uring I/O 请求经历以下步骤:

  1. 用户态获取空白 SQE(io_uring_get_sqe())
  2. 填充 SQE(操作码、fd、缓冲区地址、长度等)
  3. 写入 SQ 尾指针,将请求入队
  4. 内核从 SQ 取出 SQE,执行 I/O 操作
  5. 操作完成后将结果写入 CQ 对应 CQE
  6. 用户态从 CQ 读取 CQE,处理结果

在 SQPOLL 模式下,内核轮询线程持续消费 SQ,整个过程中用户态完全不需要 io_uring_enter() 系统调用。

三、io_uring 三种工作模式详解

3.1 中断驱动模式(默认)

最基础的运行模式:

  • 用户态通过 io_uring_enter() 系统调用通知内核处理 SQ 中的请求
  • 内核处理完成后,通过 CQ 返回结果
  • 优点:CPU 消耗最低,适合 I/O 频率不高的场景
  • 缺点:每次提交仍需一次 io_uring_enter() syscall

3.2 轮询模式(IORING_SETUP_IOPOLL)

对存储设备启用内核侧轮询:

  • 内核使用轮询方式检测 I/O 完成,而非中断驱动
  • 延迟更低,特别适合 NVMe 等高性能存储设备
  • 要求 fd 以 O_DIRECT 模式打开
  • CPU 消耗较高,但可以获得接近裸设备的性能

3.3 内核轮询模式(IORING_SETUP_SQPOLL)

io_uring 最强大的模式:

  • 内核创建一个专门的轮询线程(io-wq),持续扫描 SQ
  • 用户态提交 SQE 后完全不需要系统调用
  • 对于极高 IOPS 场景(如数据库、NVMe 存储),性能提升显著
  • 设置 sq_thread_idle 参数可控制空闲时线程超时(避免空转)
  • 注意:需要确保在未完成请求时内核线程保持活跃

四、liburing 编程实战

4.1 环境准备

liburing 是 io_uring 的官方封装库,提供友好的 C/C++ API:

# Ubuntu / Debian
sudo apt install liburing-dev

# 编译时链接
gcc -o my_app my_app.c -luring

# 检查内核版本(需要 >= 5.1,推荐 >= 5.11)
uname -r

4.2 初始化 io_uring

#include <liburing.h>

struct io_uring ring;
struct io_uring_params params;
memset(&params, 0, sizeof(params));

// 配置 SQPOLL 模式
params.flags |= IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2 秒后休眠

int ret = io_uring_queue_init_params(256, &ring, &params);
if (ret < 0) {
    fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
    return 1;
}

// 检查支持的特性
if (!(params.features & IORING_FEAT_NODROP)) {
    fprintf(stderr, "NODROP not supported!\n");
    return 1;
}

4.3 发起读请求

void submit_read(struct io_uring *ring, int fd, void *buf, off_t offset, size_t len) {
    // 获取一个空闲 SQE
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    if (!sqe) {
        fprintf(stderr, "SQ ring is full\n");
        return;
    }

    // 填充读请求
    io_uring_prep_read(sqe, fd, buf, len, offset);
    sqe->user_data = (uint64_t)buf; // 用户标记,方便识别

    // 提交(SQPOLL 模式下此调用可能为空操作)
    io_uring_submit(ring);
}

4.4 收割完成事件

void reap_completions(struct io_uring *ring) {
    struct io_uring_cqe *cqe;
    unsigned head;
    unsigned count = 0;

    // 批量读取 CQE(无锁,因为 CQ 是单生产者单消费者)
    io_uring_for_each_cqe(ring, head, cqe) {
        void *buf = (void *)cqe->user_data;
        
        if (cqe->res < 0) {
            fprintf(stderr, "I/O error: %s\n", strerror(-cqe->res));
        } else {
            printf("Read %d bytes from %p\n", cqe->res, buf);
        }

        count++;
    }

    // 批量推进 CQ 头指针(一次性释放所有已消费的 CQE)
    if (count > 0) {
        io_uring_cq_advance(ring, count);
    }
}

五、高级特性全景

5.1 固定文件(IORING_REGISTER_FILES)

预先注册一组文件描述符到内核,之后 I/O 请求中可以用索引替代 fd,避免每次的 fd 查找开销:

int files[] = {fd1, fd2, fd3};
io_uring_register_files(ring, files, 3);
// 之后 sqe->flags |= IOSQE_FIXED_FILE,sqe->fd 填索引 0/1/2

5.2 固定缓冲区(IORING_REGISTER_BUFFERS)

预先将用户态缓冲区映射到内核空间,读操作时内核直接传输数据,避免每次 pin/unpin 用户页面:

struct iovec iov[3];
posix_memalign((void*)&iov[0].iov_base, 4096, 4096);
iov[0].iov_len = 4096;

io_uring_register_buffers(ring, iov, 3);
// 发送读请求时指定 sqe->buf_group = 0;
// sqe->flags |= IOSQE_BUFFER_SELECT;

5.3 链式请求(IOSQE_IO_LINK)

将多个请求链接成一个顺序链,前一个完成后才执行下一个:

struct io_uring_sqe *sqe;

// 第一步:读取文件
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, read_buf, len, 0);
sqe->flags |= IOSQE_IO_LINK;
sqe->user_data = OP_READ;

// 第二步:写入另一个文件
sqe = io_uring_get_sqe(ring);
io_uring_prep_write(sqe, out_fd, read_buf, len, 0);
sqe->user_data = OP_WRITE;

io_uring_submit(ring);

5.4 请求取消与超时

// 取消指定 user_data 的请求
sqe = io_uring_get_sqe(ring);
io_uring_prep_cancel(sqe, (void*)target_user_data, 0);

// 为请求添加超时
struct __kernel_timespec ts = {.tv_sec = 5, .tv_nsec = 0};
sqe = io_uring_get_sqe(ring);
io_uring_prep_link_timeout(sqe, &ts, 0);

5.5 Splice 零拷贝传输

// 直接将管道数据 splice 到文件(零用户态拷贝)
int pipefd[2];
pipe(pipefd);

sqe = io_uring_get_sqe(ring);
io_uring_prep_splice(sqe, fd, -1, pipefd[1], -1, 4096, SPLICE_F_MOVE);
sqe->flags |= IOSQE_IO_LINK;

sqe = io_uring_get_sqe(ring);
io_uring_prep_splice(sqe, pipefd[0], -1, out_fd, -1, 4096, SPLICE_F_MOVE);
io_uring_submit(ring);

六、io_uring vs epoll 网络编程范式对比

6.1 epoll 范式

epoll 仅管理"就绪状态",实际的 recv/send 仍需调用:

while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].events & EPOLLIN) {
            ssize_t r = recv(fd, buf, sizeof(buf), 0);
            process(buf, r);
        }
    }
}

6.2 io_uring 网络范式

io_uring 直接提交异步 recv/send 请求,内核处理后通过 CQE 返回结果:

void submit_recv(struct io_uring *ring, int fd, void *buf, size_t len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_recv(sqe, fd, buf, len, 0);
    sqe->user_data = (uint64_t)fd;
    io_uring_submit(ring);
    // 稍后通过 wait_cqe 获取结果
}

io_uring 网络编程的优势:请求批量化减少 syscall 次数;配合 SQPOLL 实现零系统调用网络 I/O;recv 完成后才产生事件,不存在 EAGAIN 问题;与磁盘 I/O 统一接口。

七、实战案例:构建高性能 Key-Value 存储后端

7.1 核心设计

使用 io_uring SQPOLL 模式批量提交写请求,内核线程异步处理 fsync,应用层继续接收新请求:

void kv_put(struct io_uring *ring, int fd, const void *data, size_t len, off_t offset) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    
    // 异步写
    io_uring_prep_write(sqe, fd, data, len, offset);
    sqe->user_data = generate_unique_id();
    sqe->flags |= IOSQE_IO_LINK;
    
    // 链接 fsync(写入完成后刷盘)
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_fsync(sqe, fd, IORING_FSYNC_DATASYNC);
    sqe->user_data = FSYNC_MAGIC;
    
    io_uring_submit(ring);
}

7.2 批量提交优化

void batch_submit(struct batch_ctx *ctx) {
    if (ctx->queued_count == 0) return;
    io_uring_submit(&ctx->ring);
    ctx->queued_count = 0;
}

while (running) {
    // 处理客户端请求
    if (queued_count >= 32 || time_since_last_submit() > 100) {
        batch_submit(&ctx);
    }
    reap_completions_nonblocking(ring);
}

7.3 性能对比

实验环境:Intel Xeon Gold 6338,NVMe SSD,CentOS 8.4,内核 5.15

方案P50 延迟 (μs)P99 延迟 (μs)吞吐量 (K QPS)CPU 使用率
同步 write + fsync453201815%
libaio281803522%
io_uring(默认)22955225%
io_uring(SQPOLL)83212845%
io_uring(SQPOLL + 固定缓冲区)51821050%

io_uring + SQPOLL + 固定缓冲区相比传统同步写方案,吞吐提升 11.7 倍,P99 延迟降低 94%。

八、生产环境部署与调优指南

8.1 内核版本要求

内核版本特性支持建议
5.1 - 5.10基础 io_uring仅开发测试
5.11 - 5.17SQPOLL 完善、Buffers 注册生产可用
5.18+所有高级特性就绪推荐生产部署
6.1+IORING_SETUP_DEFER_TASKRUN最佳选择

8.2 关键参数调优

# /etc/sysctl.conf
fs.aio-max-nr = 1048576

# 内存锁定限制(注册缓冲区需要)
# /etc/security/limits.conf
your_user  soft  memlock  unlimited
your_user  hard  memlock  unlimited

8.3 常见陷阱

陷阱 1:SQPOLL 线程退出导致请求丢失

设置 sq_thread_idle 时,确保在线程休眠前所有请求已提交。否则需要再次 io_uring_submit() 唤醒内核线程。

陷阱 2:io_uring 与 libc read/write 混用同一 fd

某些场景下会导致数据不一致。建议:要么完全使用 io_uring 处理该 fd 的所有 I/O,要么完全不使用。

陷阱 3:固定缓冲区未对齐

O_DIRECT 要求缓冲区内存页对齐,使用 posix_memalign() 分配。

陷阱 4:队列溢出

如果 SQ 满了仍继续获取 SQE,io_uring_get_sqe() 返回 NULL,此时必须先提交并收割一批请求腾出空间。

九、io_uring 生态系统与未来展望

9.1 语言绑定

语言库/项目特点
Rusttokio-uring, glommio异步生态集成
Goliburing-go, gouringcgo 绑定
Pythonliburing (pypi)简洁封装
C++liburing++, iouring-asio现代 C++ 风格封装
Node.jsio_uring (npm)libuv 集成
Javaio_uring-jniJNI 绑定

9.2 生产应用

  • PostgreSQL 15+:支持 io_uring 后端,提升检查点写入和 WAL 性能
  • Redis:实验性引入 io_uring 加速 AOF 持久化
  • Nginx:通过模块支持 io_uring AIO,静态文件服务性能大幅提升
  • Envoy:使用 io_uring 处理文件系统操作,降低 proxy 延迟
  • Tokio (Rust):可选 io_uring 后端,替代 epoll 的异步调度器
  • MySQL/InnoDB:实验性支持,减少 fsync 延迟

9.3 内核演进方向

  • IORING_SETUP_DEFER_TASKRUN(6.1+):减少 CQE 处理延迟
  • io_uring 网络零拷贝(6.5+):零拷贝 sendmsg/recvmsg
  • blk-mq 深度集成:进一步优化块设备 I/O 路径
  • 自动 SQPOLL Idle 调优:自适应调整内核线程活跃时间
  • 越来越多的子系统迁移到 io_uring:文件系统、网络、甚至 GPU 驱动

9.4 io_uring 与 SPDK 的定位

io_uring 不是 SPDK 替代品,而是互补:SPDK 追求极致延迟(微秒级,完全用户态驱动),io_uring 追求生态兼容性和合理性能(数十微秒级)。通用负载用 io_uring,极端性能场景用 SPDK。

十、总结与学习路径

io_uring 是 Linux 异步 I/O 领域近 20 年来最重要的变革。它的核心价值不是某一两个 API 的创新,而是重新定义了用户态与内核态协作的方式:通过共享内存环形队列实现高效通信,通过 SQPOLL 和固定资源注册实现接近零开销的 I/O 提交。

学习路径建议:

  1. 入门阶段:阅读 Jens Axboe 的 io_uring 原始论文和邮件列表讨论,理解设计动机
  2. 实战阶段:使用 liburing 实现一个简单的异步文件复制工具,逐步加入 SQPOLL 和链接请求
  3. 进阶阶段:学习固定文件、固定缓冲区和请求取消,构建生产级应用
  4. 深度阶段:阅读内核源码(io_uring.c),理解 SQPOLL 线程调度、CQE 批量处理等细节
  5. 广度阶段:探索 Rust tokio-uring、网络 io_uring 等生态项目

推荐资源:

如果说 epoll 解决了 C10K 问题,那么 io_uring 正在解决 C10M 问题。无论从性能数据还是生态发展来看,掌握 io_uring 都将成为 Linux 系统程序员的必备技能。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部