[linux] io_uring 完全指南:从 ring buffer 到零系统调用 I/O

背景

Linux 的异步 I/O 一直是个尴尬的话题。

老的 POSIX AIO(aio_read/aio_write)在用户态用线程池模拟,内核态 AIO(io_submit)只支持 O_DIRECT 文件,连 socket 都做不了。epoll 虽然解决了网络 I/O 的 C10K 问题,但对文件 I/O 无能为力——epoll 对普通文件 fd 永远返回 ready,于是 Node.js 的 libuv 只能开线程池来处理文件读写。

2019 年,Jens Axboe(当时在 Facebook)向内核提交了一套全新的异步 I/O 接口,命名为 io_uring。它用两个共享 ring buffer 实现了内核-用户态零拷贝通信,支持文件、网络、甚至 accept() 等操作,并且提供 SQPOLL 模式让应用可以完全免去系统调用。Linux 5.1 合入主线,之后每个版本都在扩充 opcode。

io_uring 的核心设计:两个 ring buffer

io_uring 的命名来自它的核心数据结构——两个 ring buffer(环形缓冲区):

  • Submission Queue (SQ):用户态写入 I/O 请求(SQE)
  • Completion Queue (CQ):内核写入 I/O 完成结果(CQE)

两个队列通过 mmap() 映射到用户态和内核态共享的内存区域。这意味着:大部分情况下,用户态和内核态不需要拷贝数据,也不需要系统调用来传递结构体

数据结构层面

SQE(Submission Queue Entry)描述一个 I/O 操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct io_uring_sqe {
    __u8    opcode;     /* IORING_OP_READV, IORING_OP_WRITEV ... */
    __u8    flags;      /* IOSQE_IO_LINK 等链式标记 */
    __s32   fd;         /* 操作目标 fd */
    __u64   off;        /* 文件偏移 */
    void    *addr;      /* buffer 或 iovec 数组指针 */
    __u32   len;        /* buffer 大小或 iovec 数量 */
    __u64   user_data;  /* 用户自定义标记,关联 CQE */
    __u16   buf_index;  /* fixed buffer 索引 */
};

CQE(Completion Queue Event)返回结果:

1
2
3
4
5
struct io_uring_cqe {
    __u64  user_data;   /* 原样返回 SQE 中设置的 user_data */
    __s32  res;         /* 结果码(类似 read/write 返回值) */
    __u32  flags;
};

工作流程

1
2
3
4
5
6
7
用户态                        内核态
   |                            |
   |— 填充 SQE (写 SQ ring)     |
   |— io_uring_enter() ————————→|— 消费 SQ ring
   |                            |— 执行 I/O 操作
   |— 读取 CQE (读 CQ ring) ←——|— 填充 CQ ring
   |                            |

关键优势:多个 I/O 请求只需要一次系统调用。传统的 read() 一次调用一个,epoll 也是等事件然后逐个调用。io_uring 可以在 SQ 里批量提交 256 个请求,一次 io_uring_enter() 全部下发。

SQPOLL 模式:零系统调用 I/O

如果应用对延迟有极致要求,io_uring 提供 IORING_SETUP_SQPOLL 标志。启动后内核创建一个内核线程(sqpoll),持续轮询 SQ ring 是否有新的 SQE。应用只需写 SQ → 内核线程自动消费 → 写 CQ。应用全程不需要调用 io_uring_enter()

在 Spectre/Meltdown 修复之后,系统调用的开销显著增加(因为页表隔离和 TLB 刷新)。SQPOLL 模式完全消除了这个成本。

实战:用 liburing 写一个零系统调用读文件

原始 io_uring 系统调用接口很底层:需要 io_uring_setup() + 3 次 mmap() 映射不同区域 + 手动管理 ring buffer 的 head/tail 指针。liburing 封装了这些细节。

安装

1
2
git clone https://github.com/axboe/liburing
cd liburing && ./configure && make && sudo make install

代码:io_uring 版的文件读取器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <liburing.h>

#define QUEUE_DEPTH 1
#define BLOCK_SZ    4096

off_t get_file_size(int fd) {
    struct stat st;
    if (fstat(fd, &st) < 0) return -1;
    return st.st_size;
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <file>\n", argv[0]);
        return 1;
    }

    /* 1. 初始化 io_uring —— 一个 ring 搞定 */
    struct io_uring ring;
    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

    int fd = open(argv[1], O_RDONLY);
    off_t file_sz = get_file_size(fd);

    char *buf = malloc(file_sz);

    /* 2. 获取一个 SQE,准备 read 操作 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, file_sz, 0);

    /* 3. 提交到内核 */
    io_uring_submit(&ring);

    /* 4. 等待完成,读取 CQE */
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);

    if (cqe->res < 0) {
        fprintf(stderr, "read failed: %s\n", strerror(-cqe->res));
    } else {
        write(STDOUT_FILENO, buf, cqe->res);
    }

    /* 5. 标记 CQE 已消费 */
    io_uring_cqe_seen(&ring, cqe);

    io_uring_queue_exit(&ring);
    free(buf);
    close(fd);
    return 0;
}

对比传统的同步读取,这段代码的关键区别是:io_uring_submit() 立即返回,不阻塞,你可以在这期间做别的计算。io_uring_wait_cqe() 才真正等待 I/O 完成。

编译

1
gcc -o iouring-cat iouring-cat.c -luring

进阶:用 io_uring 写一个完整的 HTTP 服务器

下面是一个基于 io_uring 的简化版 HTTP 服务器框架。它用 io_uring 同时处理 accept、readv 和 writev,全程只需要 3 个系统调用变体io_uring_queue_initio_uring_submitio_uring_wait_cqe

核心事件循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#define QUEUE_DEPTH 256

enum { EVENT_ACCEPT, EVENT_READ, EVENT_WRITE };

struct conn {
    int event_type;
    int client_fd;
    struct iovec iov[];
};

void server_loop(int server_fd) {
    struct io_uring ring;
    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

    /* 初始注册一个 accept 请求 */
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, server_fd, NULL, NULL, 0);
    io_uring_sqe_set_data(sqe, new_req(EVENT_ACCEPT, 0));
    io_uring_submit(&ring);

    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);
        struct conn *req = (struct conn *)cqe->user_data;

        switch (req->event_type) {
        case EVENT_ACCEPT: {
            int client_fd = cqe->res;  /* accept() 返回的客户端 fd */
            /* 再注册下一个 accept(持续监听)*/
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_accept(sqe, server_fd, NULL, NULL, 0);
            io_uring_sqe_set_data(sqe, new_req(EVENT_ACCEPT, 0));

            /* 注册读客户端请求 */
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_readv(sqe, client_fd, /* ... */);
            io_uring_sqe_set_data(sqe, new_req(EVENT_READ, client_fd));
            break;
        }
        case EVENT_READ: {
            /* 解析 HTTP 请求 → 打开文件 → 注册 writev 写回响应 */
            handle_http(req);
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_writev(sqe, req->client_fd, req->iov, n, 0);
            io_uring_sqe_set_data(sqe, req);
            break;
        }
        case EVENT_WRITE:
            /* 写完关闭连接 */
            close(req->client_fd);
            free(req);
            break;
        }

        io_uring_cqe_seen(&ring, cqe);
        io_uring_submit(&ring);  /* 批量提交所有新注册的请求 */
    }
}

这是单线程异步模型的极致形态——一个 io_uring_submit 提交所有类型的 I/O(accept、read、write),一个 io_uring_wait_cqe 等待任何完成。不需要 epoll、不需要线程池、不需要区分网络 I/O 和文件 I/O。

ZeroHTTPd(Shuveb Hussain 的开源项目)基于这个架构做了 benchmark:在单核 VM 上,io_uring 版本比 epoll + 线程池版本吞吐提升约 30-50%,延迟降低更明显,因为没有了线程切换和系统调用开销。

io_uring 生态现状

场景项目说明
数据库RocksDB6.15+ 开始集成 io_uring 做文件 I/O
存储SPDK / FIOSPDK 的 io_uring 引擎,FIO 原生支持 io_uring
网络代理Envoy社区有 io_uring 集成 PR
编程语言Rust (tokio-uring)tokio-uring 项目将 io_uring 引入 Rust 异步生态
文件系统XFS / Btrfs内核原生支持,io_uring 绕过 VFS 的某些路径

Rust 的 tokio-uring 值得一提——它把 io_uring 封装成 Rust 的 async/await 接口,实现了"真正零开销异步 I/O":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use tokio_uring::fs::File;

fn main() -> io::Result<()> {
    tokio_uring::start(async {
        let file = File::open("hello.txt").await?;
        let buf = vec![0u8; 4096];
        let (res, buf) = file.read_at(buf, 0).await;
        println!("read {} bytes", res?);
        Ok(())
    })
}

今日可执行动作

  1. 本地实验sudo apt install -y liburing-dev 然后编译上面的 io_uring cat 代码。用 strace -e io_uring_enter,io_uring_setup,io_uring_register ./iouring-cat 观察实际发生了几次系统调用。对比普通 catread() 的次数。

  2. 理解性能差异:运行 fio --engine=io_uring --rw=randread --bs=4k --size=1G --runtime=10 对比 --engine=psync,观察 IOPS 差距。

  3. 读源码:liburing 的 src/queue.c 只有 200 行。看 io_uring_submit 如何管理 SQ tail 指针、io_uring_wait_cqe 如何决定走 io_uring_enter 还是直接从 CQ ring 读取——这是理解"批处理 + 共享内存"设计的最佳入口。

参考

CC BY-NC 4.0
最后更新于 2026-05-24