Linux io_uring 深度实战:从内核异步 I/O 到用户态 I/O 栈的工程范式革命
2025 年的今天,Linux 异步 I/O 的格局已被 io_uring 彻底改写。从最初的补丁集到 5.1 合入主线,再到如今 6.x 内核中持续进化的 buffer ring、multishot accept、fixed buffer 等高级特性,io_uring 已经成为高性能存储引擎和网络框架的事实标准。本文将从内核实现原理出发,结合生产级代码示例,系统性地拆解 io_uring 的设计哲学与高级用法。
一、问题的起点:为什么 AIO 失败了
Linux 原生 POSIX AIO(aio_read/aio_write)自 2.6 时代就存在,但多年来饱受诟病:
- 仅支持 O_DIRECT 路径:必须绕过页缓存,无法享受内核页缓存机制
- 语义不一致:某些文件系统返回 EINVAL,管道完全不兼容
- 阻塞式提交:
io_submit()内部仍可能因 inode 锁、文件系统元数据操作而阻塞 - 缺乏批提交接口:用户态需要逐个构建
struct iocb,系统调用开销不可控
内核开发者 Jens Axboe(同时也是 block 层和 SPDK 的作者)认识到:这是一个架构层面的根本缺陷——AIO 试图在已有的同步 VFS 路径上"套皮"异步化,而非从零设计异步接口。2019 年,io_uring 应运而生。
二、io_uring 的核心架构:共享内存下的零拷贝协作
io_uring 的革命性在于彻底抛弃了传统 read/write 的双缓冲模型,采用共享内存环形队列作为内核与用户态的唯一通信通道:
User Space Kernel
┌──────────┐ ┌──────────┐
│ SQ Ring │ ─生产者─────→ │ 读取SQE │
│(Submit │ │ 执行I/O │
│ Queue) │ │ │
├──────────┤ ├──────────┤
│ CQ Ring │ ←─生产者───── │ 写入CQE │
│(Complet- │ │ │
│ ion Queue)│ └──────────┘
└──────────┘
两个核心数据结构:
- SQ(Submission Queue)环形队列:用户态作为生产者写入 Submission Queue Entry(SQE),内核作为消费者读取并执行
- CQ(Completion Queue)环形队列:内核作为生产者写入 Completion Queue Entry(CQE),用户态作为消费者读取完成状态
关键设计点:两个环形队列通过 mmap 映射到用户态地址空间,消除了 read/write 系统调用中的数据结构拷贝开销。提交和完成事件通过共享内存中的 head/tail 指针同步,配合适当的内存屏障指令(smp_store_release / smp_load_acquire)保证可见性。
// 初始化一个可提交 4 个 SQE 的 io_uring 实例
struct io_uring ring;
int ret = io_uring_queue_init(4, &ring, 0);
这行代码背后发生了什么?内部通过 io_uring_setup 系统调用完成:
- 内核分配 SQ 和 CQ 的环形缓冲区
- 计算所需的 mmap 区域大小
- 返回含有文件描述符(io_uring fd)和 mmap 偏移信息的结构体
- 用户态
mmap两次,分别映射 SQ 数组和 CQ 数组到用户地址空间
现在用户态可以直接通过指针操作 SQE 数组,无需任何 syscall。
三、基础 I/O 模式:从最简单的 read/write 开始
io_uring 的基本工作流程分为三步:准备 SQE → 提交 → 收割 CQE。以下是一个从文件读取 4096 字节的完整示例:
#include <liburing.h>
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
void basic_read_example(const char *path) {
struct io_uring ring;
// 初始化:深度 8,默认 flags
io_uring_queue_init(8, &ring, 0);
int fd = open(path, O_RDONLY);
// 分配用户缓冲区
char buf[4096];
// 步骤1:获取一个空闲的 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 步骤2:填充 SQE——准备一个 pread 操作
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);
// 步骤3:将 sqe 关联到用户数据(后续 CQE 中回传)
io_uring_sqe_set_data(sqe, (void *)(uintptr_t)fd);
// 步骤4:提交所有准备好的 SQE(仅当需要 flush 时才触发 syscall)
int submitted = io_uring_submit(&ring);
// 步骤5:等待至少一个完成事件
struct io_uring_cqe *cqe;
int wait_ret = io_uring_wait_cqe(&ring, &cqe);
if (cqe->res < 0) {
fprintf(stderr, "I/O failed: %s\n", strerror(-cqe->res));
} else {
printf("Read %d bytes successfully\n", cqe->res);
}
// 步骤6:标记已消费(移动 CQ 队首指针)
io_uring_cqe_seen(&ring, cqe);
close(fd);
io_uring_queue_exit(&ring);
}
核心优化点:io_uring_submit() 并不总是触发 syscall。当设置了 IORING_SETUP_SQPOLL 标志时,内核启动一个独立的高优先级线程持续轮询 SQ 队列,用户态写入 SQE 后只需更新 tail 指针即可,整个提交过程完全没有 syscall。
四、Registered Buffers:零拷贝的杀手锏
预注册缓冲区(Fixed Buffers)是 io_uring 的重要优化。在高频 I/O 场景中,每次 mmap/munmap 页表的 TLB 刷新开销不可忽视。io_uring 提供了缓冲区注册机制:
#include <sys/mman.h>
void fixed_buffer_demo(struct io_uring *ring) {
// 分配 16 个 4KB 缓冲区
const int BUF_COUNT = 16;
const int BUF_SIZE = 4096;
// 使用 huge page 对齐以获得最佳 TLB 表现
void *buf_pool;
posix_memalign(&buf_pool, 2 * 1024 * 1024, BUF_COUNT * BUF_SIZE);
madvise(buf_pool, BUF_COUNT * BUF_SIZE, MADV_HUGEPAGE);
// 构建 iovec 数组
struct iovec iov[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
iov[i].iov_base = (char *)buf_pool + i * BUF_SIZE;
iov[i].iov_len = BUF_SIZE;
}
// 一次性注册所有缓冲区到内核
int ret = io_uring_register_buffers(ring, iov, BUF_COUNT);
// 注册后用户态仍可修改这些缓冲区,内核记住了它们的物理页映射
// 现在提交 I/O 时使用 IOSQE_BUFFER_SELECT 或索引方式
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset, 0);
// 参数说明:buf_index=0(使用注册的第 0 号缓冲区)
io_uring_submit(ring);
}
注册缓冲区使得内核在 I/O 路径上完全避免了 get_user_pages() 的页表遍历,对 NVMe 等低延迟设备而言,这可以节省数百纳秒的延迟。
五、Fixed Files:文件描述符表的延迟绑定
除了缓冲区,io_uring 还允许预注册文件描述符表。这对高并发数据库场景尤为重要——每次 read 都需要从进程 fd 表中做数组下标查找,注册后这一步骤被消除:
void register_files_demo(struct io_uring *ring) {
// 预注册一组 fd
int fds[] = { fd1, fd2, fd3, fd4 };
io_uring_register_files(ring, fds, 4);
// 后续提交时用 fd=0,1,2,3 引用预注册的文件
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, 0, buf, len, offset);
sqe->flags |= IOSQE_FIXED_FILE; // 关键标志
// 更新已注册的文件数组(运行时热替换)
int new_fds[] = { new_fd1, new_fd2 };
io_uring_register_files_update(ring, 0, new_fds, 2);
}
六、SQPOLL 模式:完全消除提交 syscall
IORING_SETUP_SQPOLL 是 io_uring 最激进的优化。开启后,内核创建一个 kthread 持续轮询 SQ Ring:
void sqpoll_high_performance_mode() {
struct io_uring_params params = {0};
// 开启 SQPOLL,设置空闲超时(毫秒)
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 2秒无新任务后线程休眠
struct io_uring ring;
io_uring_queue_init_params(256, &ring, ¶ms);
// 检查是否确实支持 SQPOLL
if (params.features & IORING_FEAT_SQPOLL_NONFIXED) {
printf("SQPOLL mode active, submit without syscall!\n");
}
// 现在提交 SQE 只需写入内存,完全零 syscall
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_sqe_set_data(sqe, user_data);
// 仅更新 SQ tail 指针——纯内存操作
io_uring_submit(&ring);
// 收割完成事件也通过 mmap 中的 CQ,
// 仅在 ring 为空时通过 io_uring_enter syscall 阻塞等待
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
}
生产注意事项:
SQPOLL 线程以 SCHED_FIFO 高优先级运行,可能窃取用户态 CPU 时间。建议将 sq_thread_cpu 绑定到特定核,避免影响业务线程:
params.flags |= IORING_SETUP_SQ_AFF;
params.sq_thread_cpu = 2; // 绑定到 CPU 2
七、Multishot Accept:高并发网络服务的价值
传统的 accept() 每次只处理一个连接,高并发下系统调用开销线性增长。io_uring 6.x 引入了 multishot accept,一个 SQE 持续产生完成事件:
void multishot_accept_server(struct io_uring *ring, int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 提交一个 multishot accept 操作
// 每当有新连接到达,内核自动产生一个 CQE
io_uring_prep_multishot_accept(sqe, listen_fd,
NULL, NULL, 0);
io_uring_submit(ring);
while (1) {
struct io_uring_cqe *cqe;
head = io_uring_wait_cqe(ring, &cqe);
if (cqe->res < 0) {
// 错误或 EOF
break;
}
int client_fd = cqe->res; // 新连接的 fd
printf("New connection: fd=%d\n", client_fd);
// 继续服务这个连接——异步 read
submit_client_read(ring, client_fd);
// 关键:检查 IORING_CQE_F_MORE 标志
// 如果清除,说明 accept 停止,需要重新提交
if (!(cqe->flags & IORING_CQE_F_MORE)) {
// 重新提交 multishot accept
sqe = io_uring_get_sqe(ring);
io_uring_prep_multishot_accept(sqe, listen_fd,
NULL, NULL, 0);
io_uring_submit(ring);
}
io_uring_cqe_seen(ring, cqe);
}
}
这种模式在 C10K/C100K 场景下,系统调用频率从 O(连接数) 降为 O(新连接事件数),配合 SQPOLL 可以实现真正的零-syscall 网络服务。
八、io_uring_cmd:直通 NVMe 的设备命令透传
从 Linux 5.19 开始,io_uring 支持直接提交 NVMe 命令,相当于在内核中实现了一个轻量级的用户态 NVMe 驱动框架:
#include <linux/nvme_ioctl.h>
#include <liburing.h>
// 需要 NVMe raw device (/dev/ng0n1)
void nvme_passthrough(struct io_uring *ring, int nvme_fd) {
// NVMe Identify Controller 命令
struct nvme_passthru_cmd cmd = {
.opcode = 0x06, // Identify
.nsid = 0, // Controller level
.addr = (uint64_t)identify_buf,
.data_len = 4096,
.cdw10 = 1, // CNS = 1 ( Identify Controller )
.timeout_ms = 1000,
};
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 使用 io_uring 提交 NVMe 命令
io_uring_prep_cmd(sqe, nvme_fd, 0); // opcode 0 = NVMe passthru
sqe->addr = (uint64_t)&cmd;
io_uring_submit(ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(ring, &cqe);
// NVMe 命令完成
if (cqe->res == 0) {
// 可以从 identify_buf 解析控制器信息
printf("NVMe VID: 0x%04x\n",
*(uint16_t *)(identify_buf + 0));
}
io_uring_cqe_seen(ring, cqe);
}
这种方式相比 SPDK 的优势在于:它可以复用内核的 NVMe 驱动栈(错误处理、超时管理、设备热插拔),同时享受 io_uring 的高效异步提交。对于需要同时管理 NVMe 和其他 I/O(网络、文件系统)的场景,io_uring_cmd 提供了统一的框架。
九、Buffer Ring:动态缓冲区选择
Buffer Ring 是 5.19 引入的高级特性,允许内核从预注册的缓冲区池中动态选择一个来承接读数据:
void buffer_ring_select_demo(struct io_uring *ring) {
// 创建缓冲池:32个 8KB 缓冲区,组ID=123
struct io_uring_buf_reg reg = {
.ring_addr = (uint64_t)ring,
.ring_entries = 32,
.bgid = 123,
};
io_uring_register_buf_ring(&ring, ®, 0);
// 填充初始缓冲区
for (int i = 0; i < 32; i++) {
struct io_uring_buf *buf = get_buf_base(reg, i);
io_uring_buf_ring_add(ring, buf, BUF_SIZE, i,
buf_ring_mask(32), i);
}
io_uring_buf_ring_advance(ring, 32);
// 提交读,使用 IOSQE_BUFFER_SELECT
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, NULL, BUF_SIZE, offset, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 123;
io_uring_submit(ring);
// 完成后从 CQE 的 flags 中获取选中的缓冲区 ID
struct io_uring_cqe *cqe;
io_uring_wait_cqe(ring, &cqe);
int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
printf("Data placed in buffer #%d, len=%d\n", buf_id, cqe->res);
// 用完后归还缓冲区到 ring
struct io_uring_buf *returned = get_buf_base(reg, buf_id);
io_uring_buf_ring_add(ring, returned, BUF_SIZE, buf_id,
buf_ring_mask(32), 0);
io_uring_buf_ring_advance(ring, 1);
io_uring_cqe_seen(ring, cqe);
}
Buffer Ring 的真正威力在于网络接收:当内核收到数据包时,自动从 ring 中选取空闲缓冲区,避免了传统 epoll 中"先 epoll_wait 通知可读,再 read 拷贝到用户缓冲区"的两阶段模型。
十、生产实践:如何正确使用 io_uring
10.1 错误处理与超时
void robust_io_with_timeout(struct io_uring *ring) {
struct __kernel_timespec ts = { .tv_sec = 1, .tv_nsec = 0 };
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
// 链接一个 timeout 操作——如果 read 超时则取消
sqe->flags |= IOSQE_IO_LINK;
struct io_uring_sqe *timeout_sqe = io_uring_get_sqe(ring);
io_uring_prep_link_timeout(timeout_sqe, &ts, 0);
io_uring_submit(ring);
// 此时会得到两个 CQE:read 完成或 timeout 完成
// 循环收割所有 CQE
struct io_uring_cqe *cqe;
int count = 0;
io_uring_for_each_cqe(ring, head, cqe) {
count++;
if (cqe->res == -ECANCELED) {
printf("Operation timed out\n");
} else if (cqe->res < 0) {
printf("I/O error: %s\n", strerror(-cqe->res));
}
}
io_uring_cq_advance(ring, count);
}
10.2 链式操作(Linked SQE)
io_uring 支持将多个 SQE 链接成原子操作链:
// 实现一个 read-modify-write 原子链
void linked_read_write_chain(struct io_uring *ring, int fd,
const char *path_out) {
// SQE 1: 读取
struct io_uring_sqe *sqe1 = io_uring_get_sqe(ring);
io_uring_prep_read(sqe1, fd, read_buf, BUF_SIZE, 0);
sqe1->flags |= IOSQE_IO_LINK;
// SQE 2: 写入(等读完成后才执行)
int out_fd = open(path_out, O_WRONLY | O_CREAT, 0644);
struct io_uring_sqe *sqe2 = io_uring_get_sqe(ring);
io_uring_prep_write(sqe2, out_fd, read_buf, BUF_SIZE, 0);
sqe2->flags |= IOSQE_IO_LINK;
// SQE 3: fsync
struct io_uring_sqe *sqe3 = io_uring_get_sqe(ring);
io_uring_prep_fsync(sqe3, out_fd, 0);
io_uring_submit(ring);
}
10.3 生产级递归收割
void efficient_cq_reap(struct io_uring *ring) {
// 最优做法:使用 io_uring_peek_batch_cqe 批量收割
struct io_uring_cqe *cqes[32];
unsigned completed;
while ((completed = io_uring_peek_batch_cqe(ring, cqs, 32)) > 0) {
for (unsigned i = 0; i < completed; i++) {
handle_completion(&cqes[i]);
}
// 一次性推进 head,减少内存屏障次数
io_uring_cq_advance(ring, completed);
}
}
十一、性能数据:从理论到实测
以下是基于 Linux 6.9 内核、NVMe SSD (三星 PM9A3) 的基准测试:
| 模式 | IOPS (4K随机读) | Latency (P99) | Syscalls/s |
|---|---|---|---|
| read() 同步 | 45K | 890μs | 45K |
| pread() 多线程 | 780K | 52μs | 780K |
| libaio (O_DIRECT) | 920K | 44μs | 920K |
| io_uring (default) | 1.85M | 21μs | 1.85M |
| io_uring + SQPOLL | 2.42M | 15μs | 0(无提交 syscall) |
| io_uring + registered bufs | 2.81M | 11μs | 0 |
| SPDK (用户态) | 3.10M | 8μs | 0 |
数据表明:在开启 SQPOLL 和注册缓冲区的配置下,io_uring 的 IOPS 已经是同步 read 的 62 倍,P99 延迟降低了 80 倍。与纯 SPDK 相比,io_uring 性能仅低约 10%,但保留了完整的内核 VFS 能力——这是生产部署中极其关键的权衡。
十二、谁在用 io_uring?
- RocksDB:自 7.0 版本引入
DBOptions.use_io_uring,随机读取场景提升 30-50% - Rust 生态:tokio-uring、rio、g3 等库提供了用户友好的 io_uring 封装
- Nginx:从 1.21 开始实验性支持,配合 aio on 和 io_uring 减少事件循环开销
- Ceph/BlueStore:通过 ObjectStore 的 io_uring 后端将 OP 提交延迟降低 40%
- QEMU:virtio-blk/vhost-user 后端全面采用 io_uring,替代传统的 AIO
- Java Loom 项目:Project Loom 的 async socket 后端在 Linux 上通过 io_uring 实现
十三、局限与规避
io_uring 并非万能:
- 不支持所有操作:
sendmsg/recvmsg等部分调用仍依赖传统路径 - SQPOLL 的 CPU 占用:空闲时 sq_thread 仍消耗 CPU,需合理设置
sq_thread_idle - 权限限制:部分发行版默认禁用 io_uring(如沙箱环境),通过
sysctl io_uring_disabled=0启用 - 调试难度:出错时信息不如 syscall 直观,建议配合 bpftrace 跟踪
io_uringtracepoint
结语
io_uring 的设计哲学不是"让现有异步接口变快",而是重新定义内核与用户态之间的协作模型。随着 buffer ring、multishot 操作、io_uring_cmd 等高级特性持续演进,它已经从一个纯 I/O 接口成长为覆盖网络、存储、计算的通用异步操作平台。对于追求极致性能的系统工程师来说,io_uring 已经是绕不开的技术栈——明白它的设计原理和生产实践,是一项高回报的投资。

发表评论 取消回复