4. 第一个 io_uring 程序:初始化、提交、等待、处理与清理

本章将带领你编写第一个完整的 io_uring 程序。通过一个具体的文件读取示例,你将掌握 io_uring 编程的五个核心步骤:初始化、提交请求、等待完成、处理结果和清理资源。这是所有 io_uring 应用的基础骨架。

学习目标:完成本章后,你将能够独立编写一个基于 io_uring 的同步文件读取程序,并理解其内部状态流转。

4.1 程序整体流程概览

一个典型的 io_uring 程序遵循“提交-等待-收割”的循环模式。下面的 SVG 图展示了本次示例程序的完整生命周期。

io_uring 程序生命周期(文件读取示例) 1. 初始化 io_uring io_uring_queue_init() 2. 准备 SQE io_uring_get_sqe() 3. 提交请求 io_uring_submit() 4. 等待完成 io_uring_wait_cqe() 5. 处理 CQE 检查 res 字段 6. 清理资源 io_uring_queue_exit() 内核异步执行,用户态可执行其他任务

4.2 核心数据结构与 API

在编写代码之前,需要先了解 io_uring 库提供的几个核心数据结构和 API 函数。它们都定义在 <liburing.h> 头文件中。

类别 名称 描述
数据结构 struct io_uring io_uring 实例的主结构体,包含 SQ 和 CQ 的指针及参数
数据结构 struct io_uring_sqe Submission Queue Entry,描述一个具体的 I/O 操作
数据结构 struct io_uring_cqe Completion Queue Entry,包含 I/O 操作的结果
初始化 io_uring_queue_init() 初始化 io_uring 实例,分配 SQ 和 CQ 内存
获取 SQE io_uring_get_sqe() 从 SQ 中获取一个空闲的 SQE 条目
准备读操作 io_uring_prep_read() 填充 SQE 字段,准备一个 read 操作
提交请求 io_uring_submit() 将准备好的 SQE 提交给内核
等待完成 io_uring_wait_cqe() 阻塞等待一个 CQE 完成
标记处理 io_uring_cqe_seen() 通知内核该 CQE 已被消费
清理 io_uring_queue_exit() 释放 io_uring 实例占用的资源
注意:io_uring_get_sqe() 返回的 SQE 指针是 SQ 环上的直接引用,不需要手动分配内存。但如果 SQ 已满,该函数会返回 NULL,必须检查返回值。

4.3 完整代码实现

下面的程序打开当前目录下的 testfile.txt,读取前 4096 字节到缓冲区,然后打印读取的内容。这是 io_uring 编程的“Hello World”。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <liburing.h>

#define BUF_SIZE 4096
#define FILE_NAME "testfile.txt"

int main(void) {
    struct io_uring ring;
    struct io_uring_sqe *sqe;
    struct io_uring_cqe *cqe;
    char buf[BUF_SIZE];
    int fd, ret;

    /* 1. 打开文件 */
    fd = open(FILE_NAME, O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    /* 2. 初始化 io_uring,队列深度为 32 */
    ret = io_uring_queue_init(32, &ring, 0);
    if (ret < 0) {
        fprintf(stderr, "io_uring_queue_init failed: %d\n", ret);
        close(fd);
        return 1;
    }

    /* 3. 获取一个空闲的 SQE */
    sqe = io_uring_get_sqe(&ring);
    if (!sqe) {
        fprintf(stderr, "io_uring_get_sqe failed\n");
        io_uring_queue_exit(&ring);
        close(fd);
        return 1;
    }

    /* 4. 准备 read 操作:从 fd 读取 BUF_SIZE 字节到 buf */
    io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0);

    /* 5. 提交 SQE 到内核 */
    ret = io_uring_submit(&ring);
    if (ret < 0) {
        fprintf(stderr, "io_uring_submit failed: %d\n", ret);
        io_uring_queue_exit(&ring);
        close(fd);
        return 1;
    }

    /* 6. 等待完成事件 */
    ret = io_uring_wait_cqe(&ring, &cqe);
    if (ret < 0) {
        fprintf(stderr, "io_uring_wait_cqe failed: %d\n", ret);
        io_uring_queue_exit(&ring);
        close(fd);
        return 1;
    }

    /* 7. 处理结果:cqe->res 包含实际读取的字节数或负错误码 */
    if (cqe->res < 0) {
        fprintf(stderr, "read failed: %d\n", cqe->res);
    } else {
        printf("Read %d bytes:\n%.*s\n", cqe->res, cqe->res, buf);
    }

    /* 8. 标记 CQE 已处理 */
    io_uring_cqe_seen(&ring, cqe);

    /* 9. 清理资源 */
    io_uring_queue_exit(&ring);
    close(fd);

    return 0;
}

4.4 关键步骤详解

4.4.1 初始化:io_uring_queue_init()

io_uring_queue_init(entries, ring, flags) 是 io_uring 的入口函数。它完成以下工作:

  • 通过 io_uring_setup() 系统调用与内核协商创建两个环形缓冲区(SQ 和 CQ)。
  • 将 SQ 和 CQ 的内存映射到用户空间,实现零拷贝提交和收割。
  • entries 参数指定 SQ 的深度(即最多同时提交多少个未完成的请求)。
  • flags 参数用于设置高级特性(如 IORING_SETUP_IOPOLL),本例中传 0 表示使用默认中断驱动模式。
性能提示:队列深度 entries 的选择影响吞吐量。对于简单的同步程序,32 或 64 已经足够;对于高并发服务器,通常设置为 1024 或更高。

4.4.2 准备 SQE:io_uring_get_sqe() 与 io_uring_prep_read()

这两个函数配合使用:

  1. io_uring_get_sqe() 从 SQ 环中取出下一个可用的 SQE 指针。如果 SQ 已满,返回 NULL。
  2. io_uring_prep_read(sqe, fd, buf, nbytes, offset) 是一个内联辅助函数,它填充 SQE 的字段:
    sqe->opcode = IORING_OP_READ;
    sqe->fd = fd;
    sqe->addr = (unsigned long)buf;
    sqe->len = nbytes;
    sqe->off = offset。

4.4.3 提交:io_uring_submit()

io_uring_submit() 将 SQ 环中所有新添加的 SQE(通过 SQ 尾指针标识)一次性提交给内核。返回值是实际提交的 SQE 数量。内核收到后,会异步地执行这些 I/O 操作。

4.4.4 等待与处理:io_uring_wait_cqe() 与 cqe->res

io_uring_wait_cqe(ring, &cqe_ptr) 是一个阻塞调用,直到至少有一个 CQE 到达。CQE 中的 res 字段是关键:

  • 如果 res >= 0,表示操作成功,值为实际传输的字节数(与 read() 系统调用的返回值语义一致)。
  • 如果 res < 0,表示操作失败,值为负的错误码(例如 -EIO、-ENOMEM)。

处理完 CQE 后,必须调用 io_uring_cqe_seen() 来更新 CQ 头指针,释放该 CQE 占用的槽位,以便内核可以写入新的完成事件。

4.5 编译与运行

编译时需要链接 liburing 库:

gcc -o io_uring_read io_uring_read.c -luring

创建一个测试文件并运行:

echo "Hello from io_uring!" > testfile.txt
./io_uring_read

预期输出:

Read 21 bytes:
Hello from io_uring!

4.6 常见错误与排查

错误现象 可能原因 解决方案
io_uring_queue_init 返回负值 内核版本过低(< 5.1)或 CONFIG_IO_URING 未开启 升级内核或检查内核配置
io_uring_get_sqe 返回 NULL SQ 队列已满,未及时提交或收割 增加队列深度或确保及时调用 io_uring_submit
cqe->res 为负值(如 -9) 文件描述符无效(-EBADF)或权限不足 检查 open() 是否成功
程序卡在 io_uring_wait_cqe 提交的请求未正确触发,或内核未处理 检查 io_uring_submit 返回值是否为 1

4.7 本章小结

通过本章的示例,你已经掌握了 io_uring 编程的完整骨架:

  • 初始化:io_uring_queue_init() 建立用户态与内核态的通信通道。
  • 准备:io_uring_get_sqe() + io_uring_prep_read() 描述 I/O 操作。
  • 提交:io_uring_submit() 将请求推入内核。
  • 等待:io_uring_wait_cqe() 阻塞直到操作完成。
  • 处理:检查 cqe->res 获取结果。
  • 清理:io_uring_cqe_seen() 和 io_uring_queue_exit() 释放资源。