io_uring深度实战:Linux内核异步IO革命与高性能存储引擎

一、为什么我们需要 io_uring?

在 Linux 内核的 IO 演进历程中,从最初的 read()/write() 阻塞系统调用,到 POSIX AIO 的半路折戟,再到 epoll 在网络 IO 领域的称王,Linux 程序员一直在寻求一种统一的、高性能的异步 IO 方案。io_uring 的出现,彻底解决了这个困扰 Linux 社区多年的问题。

io_uring 由 Jens Axboe(Linux 块设备层维护者)于 2019 年首次提交,并在 Linux 5.1 中合入主线。它的设计目标非常明确:

  • 零系统调用开销:用户态和内核态通过共享内存环通信
  • 统一接口:磁盘 IO、网络 IO、甚至 fork/exec 都纳入同一套框架
  • 真正的异步:不是 epoll 那样的"就绪通知",而是真正的异步提交-完成模型
  • 可扩展性:支持数百万 IO/秒,无锁数据结构保证高并发

二、io_uring 核心架构

2.1 三大核心数据结构

io_uring 的架构精髓在于三个通过共享内存(mmap)实现的内核-用户态通信通道:

┌─────────────────────────────────────────────────────┐
│                    io_uring 架构                      │
│                                                       │
│  ┌─────────────────┐   ┌─────────────────┐           │
│  │  Submission Queue │   │ Completion Queue │           │
│  │   (SQ)  - 环形    │   │  (CQ) - 环形     │           │
│  │   用户态 → 内核态   │   │  内核态 → 用户态   │           │
│  └────────┬────────┘   └────────┬────────┘           │
│           │                     │                     │
│           ▼                     ▼                     │
│  ┌─────────────────────────────────────┐            │
│  │     Submission Queue Entries (SQE)   │            │
│  │  - 每个 SQE 代表一个 IO 操作请求      │            │
│  │  - 用户态写 SQ tail, 内核态读 SQ head │            │
│  └─────────────────────────────────────┘            │
│  ┌─────────────────────────────────────┐            │
│  │    Completion Queue Entries (CQE)    │            │
│  │  - 每个 CQE 代表一个 IO 操作完成      │            │
│  │  - 内核态写 CQ tail, 用户态读 CQ head │            │
│  └─────────────────────────────────────┘            │
└─────────────────────────────────────────────────────┘

SQE(Submission Queue Entry)关键字段:

字段说明
opcode操作类型(IORING_OP_READ/WRITE/SEND/RECV等)
fd目标文件描述符
addr数据缓冲区地址或索引(固定缓冲模式下)
len数据长度
off文件偏移量
user_data用户自定义标识,原样回传至 CQE

CQE(Completion Queue Entry)关键字段:

字段说明
user_data匹配 SQE 的 user_data
res操作结果(类似返回值,负数为错误码)
flags标志位(如 IOSQE_IO_LINK 链接标记)

2.2 零拷贝的无锁通信

SQ 和 CQ 都是单一生产者-单一消费者(SPSC)的环形缓冲区:

  • SQ:仅用户态生产(写 SQE),仅内核消费(读 SQE)
  • CQ:仅内核生产(写 CQE),仅用户态消费(读 CQE)

这意味着在无竞态场景下,io_uring 的提交和完成操作不需要任何系统调用——只需要通过 memory barrier 同步环形指针。这在每秒百万 IO 的场景下,节省的系统调用开销是巨大的。

三、从入门到精通:io_uring 编程实战

3.1 初始化 io_uring

#include <liburing.h>

struct io_uring ring;
// 初始化:队列深度 1024,默认参数
int ret = io_uring_queue_init(1024, &ring, 0);
if (ret < 0) {
    fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
    return 1;
}

3.2 提交一个读请求(基础流程)

// 1. 从 SQ 获取一个空闲的 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // SQ 满了,先提交已有请求再重试
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

// 2. 填充 SQE
char buf[4096];
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
io_uring_sqe_set_data(sqe, (void*)my_context); // 设置用户标识

// 3. 提交到内核(可选:延迟批量提交)
io_uring_submit(&ring);

// 4. 等待并收割完成事件(CQE)
struct io_uring_cqe *cqe;
ret = io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
    fprintf(stderr, "IO failed: %s\n", strerror(-cqe->res));
}
void *ctx = io_uring_cqe_get_data(cqe); // 获取用户标识
io_uring_cqe_seen(&ring, cqe); // 标记该 CQE 已消费

3.3 批量提交:减少系统调用次数

io_uring 真正的性能优势在于批量处理——攒一大批 SQE,只调用一次 io_uring_submit():

// 批量提交 64 个异步写操作
#define BATCH_SIZE 64
struct iovec iov[BATCH_SIZE];

for (int i = 0; i < BATCH_SIZE; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_writev(sqe, fd, &iov[i], 1, i * 4096);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)i);
}

// 一次性提交 64 个 IO,仅 1 次系统调用
int submitted = io_uring_submit(&ring);
printf("Submitted %d IO requests in one syscall\n", submitted);

四、高级特性:让 io_uring 起飞的核武器

4.1 内核轮询模式(IORING_SETUP_SQPOLL)

默认模式下,用户态需要调用 io_uring_enter() 系统调用来通知内核处理 SQ 中的新请求。IORING_SETUP_SQPOLL 模式让内核启动一个内核线程自动轮询 SQ,彻底消除提交时的系统调用:

struct io_uring_params params = {
    .sq_entries = 1024,
    .cq_entries = 1024,
    .flags = IORING_SETUP_SQPOLL,       // 开启内核轮询
    .sq_thread_idle = 2000,             // 空闲 2s 后内核线程睡眠(毫秒)
    .sq_thread_cpu = 2,                 // 绑定到 CPU 2
};

int ring_fd = io_uring_setup(1024, &params);
// mmap SQ 和 CQ 到用户态...

// 此后提交 IO 时,只需写 SQ tail + memory barrier
// 无需任何系统调用来触发内核处理!

性能对比(NVMe SSD,4KB 随机读 QD=128):

模式IOPS平均延迟(μs)每秒系统调用
同步 pread/pwrite~200K~640200K
io_uring 默认模式~900K~140900K (submit)
io_uring SQPOLL~1.8M~700 (仅 reap CQ)
io_uring SQPOLL + 固定缓冲~2.5M~500

4.2 固定缓冲区(Registered Buffers)

默认模式下,每次 IO 在发起时内核需要将用户态缓冲区 pin 住(get_user_pages()),完成后 unpin。这个固定/解除固定操作有显著开销。固定缓冲区通过预先将一组缓冲区注册到 io_uring,每次 IO 直接引用其索引,完全跳过 pin/unpin:

// 注册一组固定缓冲区
#define BUF_COUNT 1024
#define BUF_SIZE  4096
char pool[BUF_COUNT][BUF_SIZE];

struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
    iovecs[i] = (struct iovec){ pool[i], BUF_SIZE };
}

// 一次性注册所有缓冲区(仅 1 次系统调用)
ret = io_uring_register_buffers(&ring, iovecs, BUF_COUNT);

// 后续 IO 使用 IOSQE_BUFFER_SELECT 或 buf_index 引用预注册缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, pool[buf_idx], len, offset, buf_idx);

4.3 固定文件(Registered Files)

类似固定缓冲区,预先注册一组 fd,IO 时使用 IOSQE_FIXED_FILE 标志直接引用索引,避免每次 IO 的文件表查找(fget/fput):

// 注册 fd 表
int fds[] = { fd_data, fd_log, fd_index };
io_uring_register_files(&ring, fds, 3);

// IO 时使用 fd 索引而非实际 fd
sqe->fd = file_index;        // 0, 1, 2 对应注册的 fd
sqe->flags |= IOSQE_FIXED_FILE;

4.4 链接操作(IOSQE_IO_LINK)

通过 IOSQE_IO_LINK 标志,将多个 IO 操作串成操作链,保证前一个完成后才执行下一个,全部异步完成。典型的覆盖写(先 fsync 再写)场景:

uint8_t buf[4096];

// Step 1: 异步读取(链头)
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, buf, 4096, 0);
io_uring_sqe_set_data(sqe1, (void*)PHASE_READ);
sqe1->flags |= IOSQE_IO_LINK;  // 链接到下一个

// Step 2: 异步 fsync
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe2, fd, 0);
io_uring_sqe_set_data(sqe2, (void*)PHASE_FSYNC);
sqe2->flags |= IOSQE_IO_LINK;  // 链接到下一个

// Step 3: 异步写回(链尾)
struct io_uring_sqe *sqe3 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe3, fd, buf, 4096, 4096);
io_uring_sqe_set_data(sqe3, (void*)PHASE_WRITE);

// 提交整个链,3 个操作完全有序异步执行
io_uring_submit(&ring);

五、网络 + 存储统一:io_uring 的终极野心

io_uring 没有局限于磁盘 IO,它同样支持网络操作(IORING_OP_SEND、IORING_OP_RECV、IORING_OP_ACCEPT、IORING_OP_CONNECT 等),逐步替换 epoll + 非阻塞 IO 模式:

// 异步 accept 新连接
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct sockaddr_storage addr;
socklen_t addrlen = sizeof(addr);
io_uring_prep_accept(sqe, listen_fd, (struct sockaddr*)&addr, &addrlen, 0);
io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);

// 异步 recv 数据
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, recv_buf, sizeof(recv_buf), 0);
io_uring_sqe_set_data(sqe, (void*)OP_RECV);

// 异步 send 响应
sqe = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe, client_fd, resp_buf, resp_len, 0);
io_uring_sqe_set_data(sqe, (void*)OP_SEND);

io_uring_submit(&ring);

// 统一收割网络+磁盘的完成事件
int head, count = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
    uintptr_t op = (uintptr_t)io_uring_cqe_get_data(cqe);
    if (op == OP_READ && cqe->res > 0)
        // 磁盘读完成,准备 send response
    else if (op == OP_RECV && cqe->res > 0)
        // 网络数据到达,准备磁盘写
    count++;
}
io_uring_cq_advance(&ring, count);

这种统一事件循环的设计,让存储引擎可以同时处理:磁盘读/写、网络请求、定时器(IORING_OP_TIMEOUT),甚至进程 IO。

六、生产级实战:基于 io_uring 的 KV 存储引擎

6.1 整体架构

┌──────────────────────────────────────────────────────┐
│                 请求入口层                              │
│        (io_uring accept + recv 统一处理)               │
└────────────┬─────────────────────────────────────────┘
             │
             ▼
┌──────────────────────────────────────────────────────┐
│               WAL (Write-Ahead Log)                   │
│   io_uring append-only write → 固定缓冲 + O_DIRECT  │
│   批量 fsync(每 N 条操作或每 T 毫秒)                 │
└────────────┬─────────────────────────────────────────┘
             │
             ▼
┌──────────────────────────────────────────────────────┐
│          MemTable (无锁跳表)                           │
│    读写路径分离:WAL 写入由 io_uring 完成              │
└────────────┬─────────────────────────────────────────┘
             │
             ▼
┌──────────────────────────────────────────────────────┐
│       SSTable 层 (多级别 LSM-Tree)                    │
│   io_uring 异步读取 + MADVISEE 预读优化               │
│   Compaction 使用 io_uring 批量读写 + 链接操作          │
└──────────────────────────────────────────────────────┘

6.2 WAL 批量写入

struct wal_writer {
    struct io_uring     *ring;
    int                  wal_fd;
    struct mmap_region   log_mmap;
    uint64_t            *tail_ptr;
    uint32_t            *committed_lsn;
    uint32_t            *flushed_lsn;
    int                  pending_count;
    int                  batch_size;
};

int wal_append(struct wal_writer *w, const void *data, size_t len) {
    // 写入内存映射区域
    memcpy(w->log_mmap.addr + *w->tail_ptr, data, len);

    struct io_uring_sqe *sqe = io_uring_get_sqe(w->ring);
    io_uring_prep_write(sqe, w->wal_fd, data, len, *w->tail_ptr);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)(*w->tail_ptr + len));
    sqe->flags |= IOSQE_IO_LINK;

    struct io_uring_sqe *fsync_sqe = io_uring_get_sqe(w->ring);
    io_uring_prep_fsync(fsync_sqe, w->wal_fd, IORING_FSYNC_DATASYNC);

    w->pending_count++;
    *w->tail_ptr += len;

    if (w->pending_count >= w->batch_size) {
        io_uring_submit(w->ring);
        w->pending_count = 0;
    }
    return 0;
}

6.3 SSTable 异步读取

int sst_get(struct sstable *sst, const char *key, size_t key_len,
            struct io_uring *ring, void *result_cb) {
    if (!bloom_filter_may_contain(sst->bf, key, key_len))
        return KEY_NOT_FOUND;

    uint64_t block_offset = sst_index_lower_bound(sst, key, key_len);
    uint32_t block_size = sst_get_block_size(sst, block_offset);

    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    void *buf = buffer_pool_acquire(sst->buf_pool);

    io_uring_prep_read(sqe, sst->data_fd, buf, block_size, block_offset);
    io_uring_sqe_set_data(sqe, (void*)result_cb);

    if (block_offset + block_size < sst->file_size) {
        struct io_uring_sqe *prefetch = io_uring_get_sqe(ring);
        void *prefetch_buf = buffer_pool_acquire(sst->buf_pool);
        io_uring_prep_read(prefetch, sst->data_fd, prefetch_buf,
                          block_size, block_offset + block_size);
        io_uring_sqe_set_data(prefetch, (void*)PREFETCH_TAG);
    }

    io_uring_submit(ring);
    return IO_PENDING;
}

七、性能调优最佳实践

7.1 队列深度选择

  • NVMe SSD:QD=128~254
  • SATA SSD:QD=32~64
  • 机械硬盘:QD=4~8

7.2 O_DIRECT 与固定缓冲

使用 O_DIRECT 能绕过内核页缓存,减少一次内存拷贝。配合 posix_memalign() 对齐分配缓冲区,配合固定寄存器可消除内核 get_user_pages 开销。

void *buf;
posix_memalign(&buf, 512, buf_size);
io_uring_register_buffers(&ring, &(struct iovec){buf, buf_size}, 1);
io_uring_prep_read_fixed(sqe, fd, NULL, buf_size, offset, 0);

7.3 超时与取消

sqe_read->flags |= IOSQE_IO_LINK;
struct io_uring_sqe *timeout = io_uring_get_sqe(&ring);
struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };
io_uring_prep_link_timeout(timeout, &ts, 0);

7.4 多线程扩展

推荐 每核一个 io_uring(per-thread ring),通过 IORING_SETUP_ATTACH_WQ 共享内核工作线程池,避免共享 ring 的锁争用。

八、io_uring vs 竞争对手对比

特性epollPOSIX AIOio_uring
磁盘 IO 支持❌❌(管道模拟)✅ 原生高效
网络 IO 支持✅❌✅
零系统调用提交❌❌✅ (SQPOLL)
批量提交❌❌(limited io_submit)✅
固定缓冲区/文件❌❌✅
操作链(linked SQEs)❌❌✅
超时与取消epoll_wait timeout❌✅ 内置
内核版本要求2.65.1(aio no-fd)5.1+

九、未来展望

  • BPF 集成:在 io_uring 操作链中嵌入 BPF 请求,实现可编程 IO 流水线
  • 网络零拷贝:IORING_OP_SEND_ZC 已在 5.19+ 中支持,跳过内核拷贝
  • io_uring_cmd:新增设备直接命令接口,驱动通过 io_uring 接收命令
  • 用户态驱动:io_uring 用作用户态设备驱动命令通道(vfio-user)

十、总结

io_uring 不是"又一个异步 IO 库",它是 Linux IO 栈的范式转变。通过共享内存环形缓冲区、SQPOLL 内核轮询、固定缓冲区与文件、操作链,io_uring 让 Linux IO 首次达到了"无需系统调用即可完成批量 IO 提交与收割"的理想状态。

对于追求极致性能的存储引擎开发者来说,io_uring 已经是必备技术栈。不仅 RocksDB、ScyllaDB 等新兴数据库在积极拥抱 io_uring,连 SPDK 这样的用户态 IO 框架也开始与之融合。

正如 Jens Axboe 所说:"io_uring 的目标是成为 Linux 异步 IO 的唯一接口。" 这一天正在加速到来。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.357324s