引言:I/O 性能瓶颈的终极解答
在高性能应用场景中,I/O 操作一直是系统性能的主要瓶颈。Donald Knuth 曾说:"过早优化是万恶之源。"然而,当应用真正面临每秒数万乃至数十万的 I/O 操作需求时,传统的同步 I/O 模型(read/write)和异步 I/O(AIO)已无法满足性能要求。由 Linux 内核维护者 Jens Axboe 开发的 io_uring,以一种全新的异步 I/O 架构(从 Linux 5.1 引入),正在颠覆 Linux I/O 编程范式。官方基准测试显示,io_uring 在随机读场景下性能可达原生 Linux AIO 的 2 倍以上,并支持轮询模式(polling)实现零系统调用的 I/O 操作。本文将深入 io_uring 的核心原理,并通过实际编程案例帮助读者掌握这一革命性的技术。
一、I/O 模型演进
要理解 io_uring 的价值,首先需要回顾 Linux I/O 模型的发展历程:
1.1 同步阻塞 I/O
最初的 read/write 系统调用是最简单的 I/O 模型,但在高并发场景下会导致线程阻塞,CPU 利用率低,难以满足高性能需求。对于 I/O 密集型应用,线程模型会造成巨大的上下文切换开销。
1.2 Linux AIO (libaio)
Linux 原生异步 I/O(libaio)仅支持直接 I/O(O_DIRECT),不能使用页缓存缓冲 I/O。此外,它不支持 sockets、需要严格的内存对齐、且提交和收割需要系统调用,在实际应用中处处受限。
1.3 io_uring 的诞生
io_uring 由 Jens Axboe(Linux 块设备层维护者)开发,通过提交/完成队列(SQ/CQ)实现真正的零拷贝、零系统调用 I/O 操作,支持所有类型的文件描述符(磁盘、网络、管道、eventfd 等),并且支持缓冲 I/O,真正做到了通用异步 I/O。
二、io_uring 核心架构
io_uring 的设计围绕三个核心数据结构展开:
2.1 提交队列与完成队列
+-------------+ +--------------+ +-------------+
| 用户空间 | | 共享内存 | | 内核空间 |
| | | | | |
| SQ Ring <--+-----+- 提交队列 | | |
| SQEs[] --+-----+- 提交项数组 |-----+ io_uring |
| | | | | 实例 |
| CQ Ring <--+-----+- 完成队列 |<----+ |
| CQEs[] <--+-----+- 完成项数组 | | |
+-------------+ +--------------+ +-------------+
- SQ(Submission Queue,提交队列):用户通过它将 I/O 请求提交给内核
- CQ(Completion Queue,完成队列):内核通过它将 I/O 结果返回给用户
- SQEs:提交项数组,描述待执行的 I/O 操作(64 字节)
- CQEs:完成项数组,显示 I/O 操作结果(16 字节)
两个环形缓冲区通过 mmap 共享内存映射,用户空间可以无系统调用地读取内核完成的结果,这是 io_uring 高性能的基础。
2.2 初始化接口
#include <liburing.h>
struct io_uring ring;
// 初始化 io_uring,队列深度 128,默认标志位
int ret = io_uring_queue_init(128, &ring, 0);
// I/O 完成后清理资源
io_uring_queue_exit(&ring);
第三个参数 flags 可传入 IORING_SETUP_IOPOLL(轮询模式,需要 O_DIRECT)、IORING_SETUP_SQPOLL(内核轮询提交队列模式,可进一步减少系统调用)、IORING_SETUP_SQ_AFF(绑定线程到特定 CPU)等标志来进一步提升性能。
2.3 核心工作流
1. io_uring_get_sqe() - 从 SQ Ring 获取一个空闲 SQE
2. io_uring_prep_read/write() - 向 SQE 填充 I/O 操作参数
3. io_uring_submit() - 将请求刷入内核
4. io_uring_wait_cqe() - 等待完成事件
5. io_uring_cqe_seen() - 标记该 CQE 已处理
在高性能 I/O 密集场景下,可以批量获取 SQE、批量提交、批量收割 CQE,将系统调用次数降到最低。
三、编程实战:从基础到高级
io_uring 提供了 liburing 库简化编程。下面从基础逐步深入。
3.1 最简单的异步写示例
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
void write_example(const char *filename) {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
io_uring_queue_init(128, &ring, 0);
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
const char *data = "Hello, io_uring!\n";
size_t len = strlen(data);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, fd, data, len, 0);
io_uring_sqe_set_data(sqe, (void*)"write_user_data");
int submitted = io_uring_submit(&ring);
printf("Submitted %d request(s)\n", submitted);
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret == 0) {
printf("write result: %d (expected %d bytes)\n", cqe->res, (int)len);
io_uring_cqe_seen(&ring, cqe);
}
close(fd);
io_uring_queue_exit(&ring);
}
3.2 批量提交请求
io_uring 的核心优势在于可以一次性提交多个请求,大幅减少系统调用次数。下面的例子同时发起多个读请求,但只调用一次 io_uring_submit()。
void batch_io(const char *filename, int num_requests) {
struct io_uring ring;
io_uring_queue_init(1024, &ring, 0);
int fd = open(filename, O_RDWR | O_CREAT | O_DIRECT, 0644);
size_t file_size = 1024 * 1024 * 10; // 10MB
ftruncate(fd, file_size);
size_t block_size = 4096;
char *buf = aligned_alloc(4096, block_size);
// 同时提交多个读请求 — 只需一次 syscall
for (int i = 0; i < num_requests; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, block_size, i * block_size);
io_uring_sqe_set_data(sqe, (void*)(uintptr_t)i);
}
io_uring_submit(&ring);
// 批量收割结果
for (int i = 0; i < num_requests; i++) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
int idx = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
printf("read[%d]: %d bytes\n", idx, cqe->res);
io_uring_cqe_seen(&ring, cqe);
}
close(fd); free(buf);
io_uring_queue_exit(&ring);
}
3.3 链接操作(IOSQE_IO_LINK):按顺序串行执行
通过 IOSQE_IO_LINK 标志可以将多个操作链式链接,内核保证按提交顺序依次执行。如果链中某个操作失败,后续所有链接操作会被标记为 ECANCELED。
void chained_copy(const char *srcname, const char *dstname) {
struct io_uring ring;
io_uring_queue_init(16, &ring, 0);
int fd_src = open(srcname, O_RDONLY | O_DIRECT);
int fd_dst = open(dstname, O_WRONLY | O_CREAT | O_TRUNC | O_DIRECT, 0644);
char buf[4096] __attribute__((aligned(4096)));
struct io_uring_sqe *sqe;
// 步骤1: 读文件
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd_src, buf, sizeof(buf), 0);
sqe->flags |= IOSQE_IO_LINK;
// 步骤2: 等待数据落盘 (fsync)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_fsync(sqe, fd_src, 0);
sqe->flags |= IOSQE_IO_LINK;
// 步骤3: 写目标文件
sqe = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe, fd_dst, buf, sizeof(buf), 0);
io_uring_submit(&ring);
// 收割 3 个 CQE
for (int i = 0; i < 3; i++) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("step[%d]: res=%d\n", i, cqe->res);
io_uring_cqe_seen(&ring, cqe);
}
close(fd_src); close(fd_dst);
io_uring_queue_exit(&ring);
}
3.4 固定缓冲区与预注册文件
对于高频 I/O 操作,每次映射/解除映射缓冲区会带来 TLB 刷新等开销。io_uring 支持缓冲区和文件描述符的预注册:
// 预注册文件集合
int fds[] = { fd1, fd2, fd3 };
io_uring_register_files(&ring, fds, 3);
// 之后提交 I/O 时使用 IOSQE_FIXED_FILE 标志,使用索引代替 fd
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = 0; // 使用 fds[0]
// 预注册缓冲区
struct iovec iov = { .iov_base = buf, .iov_len = 4096 };
io_uring_register_buffers(&ring, &iov, 1);
// 之后内核会提前建立内存映射和文件引用,避免路径查找开销
预注册后,内核会提前建立内存映射和文件引用计数,每次 I/O 时直接使用,避免了引用计数、路径查找、TLB 无效化等开销。
四、高级特性与极致性能
4.1 SQPOLL 模式(提交队列轮询)
在 SQPOLL 模式下,io_uring 会启动一个内核线程,它主动轮询提交队列(SQ Ring)。用户只需将新请求放入 SQ Ring 并更新 tail 指针,内核线程会"看"到这些请求并直接在内核态执行,无需调用 io_uring_enter() 系统调用。
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2ms 后内核线程休眠退出
io_uring_queue_init_params(128, &ring, ¶ms);
// 用户只需:
// 1. 写入 SQEs
// 2. 写 barrier + 更新 SQ tail (smp_store_release)
// 3. 无需 syscall!内核线程自动处理新请求
4.2 IOPOLL 模式(完成轮询)
对于 NVMe 等高速设备,中断模式的延迟依然过高。IOPOLL 模式通过 CPU 轮询硬件完成队列来消除中断上下文切换开销。
struct io_uring_params params = {0};
params.flags = IORING_SETUP_IOPOLL;
io_uring_queue_init_params(128, &ring, ¶ms);
// 适用于 O_DIRECT 打开的 NVMe 设备文件
IOPOLL 通常与 SQPOLL 组合使用,实现完全零系统调用的 I/O 路径——也即"io_uring 极速模式"。
4.3 预读缓冲区自动选择
io_uring 支持预注册一组缓冲区(io_uring_register_buffers + IOSQE_BUFFER_SELECT),内核在执行读操作时自动从中选择一个空闲缓冲区存放数据。
#define BUF_BGID 1
#define BUF_COUNT 16
struct iovec bufs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++)
bufs[i] = (struct iovec){ .iov_base = aligned_alloc(4096, 4096), .iov_len = 4096 };
io_uring_register_buffers(&ring, bufs, BUF_COUNT);
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, NULL, 4096, 0);
sqe->buf_group = BUF_BGID;
sqe->flags |= IOSQE_BUFFER_SELECT;
io_uring_submit(&ring);
// CQE 中 flags 字段包含选中的缓冲区索引
int buf_idx = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
五、性能基准测试
以下是 io_uring 在不同场景下与 libaio 的对比(基于 NVMe SSD,使用 fio 工具测试):
| 测试场景 | libaio (O_DIRECT) | io_uring (默认) | io_uring (SQPOLL+IOPOLL) |
|---|---|---|---|
| 随机读 4KB IOPS | ~800,000 | ~1,200,000 | ~1,600,000 |
| 随机读 4KB 延迟 | ~8μs | ~5μs | ~3μs |
| 顺序读 64KB 带宽 | ~3.2 GB/s | ~3.5 GB/s | ~3.6 GB/s |
| 系统调用次数/I/O | 2 (submit + wait) | ~0.5 (摊销) | ~0 (SQPOLL) |
| CPU 开销 | 中等 | 低 | 低(占用轮询核) |
数据表明:在随机 IOPS 场景下,io_uring 默认模式比 libaio 提升约 50%;开启 SQPOLL+IOPOLL 组合后,提升接近 100%。
六、io_uring 在生产环境中的应用
业界主流项目已纷纷采用 io_uring 作为高性能 I/O 实现方案:
1. Redis 6.2+:从 6.2 版本开始支持 io_uring,用于网络 I/O 的异步处理和日志 AOF 写入。在高并发场景下(100K+ QPS),吞吐提升可达 40%,延迟降低 30%以上。
2. RocksDB:作为 LSM-Tree 数据库引擎,使用 io_uring 后写入吞吐量提升 20-30%,压缩和刷盘操作的 CPU 使用率也显著下降。
3. QEMU/KVM:虚拟机磁盘 I/O 使用 io_uring 后端,通过 IOPOLL 模式显著降低虚拟化 I/O 延迟。与 virtio-blk 配合使用,VM 磁盘性能接近裸机水平。
4. NGINX:通过第三方模块使用 io_uring 实现异步文件读取和日志写入。在处理高并发静态文件服务连接时,I/O 性能提升可达 2 倍。
5. SPDK:存储性能开发工具包使用 io_uring(作为可选引擎)简化了用户态 NVMe 驱动的实现。
6. 更多采用者:Node.js(libuv 已实验性支持)、.NET(已内置支持)、Go(通过 raw syscall 或 x/uring 库),以及各大云厂商的存储服务后端。
七、陷阱与注意事项
虽然 io_uring 性能强大,但在实际使用中有几个需要注意的点:
内核版本要求:io_uring 基础功能需要 Linux 5.1+,但生产建议 Linux 5.10+(稳定)。高级特性如 IOPOLL、改进的 SQPOLL 需要 5.19+,建议使用 6.x 内核以获得最佳体验。
SQPOLL 线程权限:SQPOLL 内核线程需要 CAP_SYS_ADMIN 权限。在容器环境中使用时需要特别配置,Docker 20.10+ 默认开启 io_uring 支持但可能需要 seccomp=unconfined。
信号处理:io_uring_wait_cqe() 等阻塞接口可能被信号中断(EINTR),必须检查返回值。在 Go、Rust 等语言中更需要注意信号竞争问题。
链接操作失败传播:在 IOSQE_IO_LINK 链中,如果前一个操作失败,后续所有链接操作将被标记为 ECANCELED,这是正常设计而非 bug。
非线程安全的共享:SQPOLL 模式下多个线程共享 SQ 时,需要使用锁保护 io_uring_get_sqe() 和 SQ tail 的更新。建议使用 IORING_SETUP_ATTACH_WQ 标志共享工作队列。
资源限制:默认情况下单个 io_uring 实例的 CQ 大小受 RLIMIT_MEMLOCK 和 /proc/sys/kernel/io_uring_max_entries 限制,需要在 /etc/security/limits.conf 中调整 memlock。
八、io_uring 与 eBPF 的协同
io_uring 与 eBPF 是 Linux 内核两大革命性技术(分别由 Jens Axboe 和 Alexei Starovoitov 主导)。它们可以协同工作:
- 使用 io_uring 监控 eBPF 程序产生的实时数据流(通过 eventfd 通知机制)
- 利用 io_uring 实现 eBPF maps 数据的高效持久化通路
- io_uring 提交时使用 BPF 程序进行 I/O 策略控制(cgroup I/O 限流)
- io_uring 与 XDP 协同构建高性能数据包处理链路
这组技术组合极大增强了 Linux 系统的可观测性和 I/O 性能优化能力。
九、总结与展望
io_uring 从根本上改变了 Linux I/O 编程模型,它通过共享环形缓冲区架构实现了用户空间与内核的高效协作,将系统调用频率从"每个 I/O 一次"降低到"几乎为零"。对于高性能存储、网络服务、数据库引擎等场景,io_uring 已成为事实上的标准方案。
随着 Linux 内核对 io_uring 的持续优化(Linux 6.5+ 进一步完善了 IOPOLL NVMe 轮询性能),以及 Rust 生态中 tokio-uring、gliocre、Go 生态中 x/uring 等高质量封装的成熟,io_uring 的应用门槛正在迅速降低。
对于希望提升 I/O 性能的工程师和架构师来说,掌握 io_uring 已经不再是可选项,而是必备技能。

发表评论 取消回复