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, ¶ms);
// mmap SQ 和 CQ 到用户态...
// 此后提交 IO 时,只需写 SQ tail + memory barrier
// 无需任何系统调用来触发内核处理!
性能对比(NVMe SSD,4KB 随机读 QD=128):
| 模式 | IOPS | 平均延迟(μs) | 每秒系统调用 |
|---|---|---|---|
| 同步 pread/pwrite | ~200K | ~640 | 200K |
| io_uring 默认模式 | ~900K | ~140 | 900K (submit) |
| io_uring SQPOLL | ~1.8M | ~70 | 0 (仅 reap CQ) |
| io_uring SQPOLL + 固定缓冲 | ~2.5M | ~50 | 0 |
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 竞争对手对比
| 特性 | epoll | POSIX AIO | io_uring |
|---|---|---|---|
| 磁盘 IO 支持 | ❌ | ❌(管道模拟) | ✅ 原生高效 |
| 网络 IO 支持 | ✅ | ❌ | ✅ |
| 零系统调用提交 | ❌ | ❌ | ✅ (SQPOLL) |
| 批量提交 | ❌ | ❌(limited io_submit) | ✅ |
| 固定缓冲区/文件 | ❌ | ❌ | ✅ |
| 操作链(linked SQEs) | ❌ | ❌ | ✅ |
| 超时与取消 | epoll_wait timeout | ❌ | ✅ 内置 |
| 内核版本要求 | 2.6 | 5.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 的唯一接口。" 这一天正在加速到来。

发表评论 取消回复