深入理解 io_uring:Linux 异步 I/O 的革命性接口与高性能实践
引言:为什么我们需要 io_uring?
在传统的 Linux 系统编程中,高性能 I/O 一直是开发者面临的重大挑战。从早期的 read/write 阻塞调用,到 select/poll 的多路复用,再到 epoll 的事件驱动模型,操作系统在 I/O 性能优化上持续演进。然而,这些机制主要解决的是"有多少个 I/O 事件就绪"的问题,而非"如何让 I/O 提交和完成的开销最小化"。
Linux 5.1(2019年)引入的 io_uring 正是为了解决这一根本性问题。它不仅提供了一个真正异步的 I/O 接口,更通过创新的共享内存环形缓冲区设计,将用户态与内核态之间的系统调用开销降至最低。
一、传统异步 I/O 的局限性
在 io_uring 出现之前,Linux 提供了 POSIX AIO(libaio)作为异步 I/O 解决方案。但 POSIX AIO 存在诸多先天不足:
1.1 仅支持 O_DIRECT I/O
POSIX AIO 仅对使用 O_DIRECT 标志打开的文件有效,这意味着它无法作用于普通缓存文件、管道、socket 等。O_DIRECT 绕过了内核页缓存,需要自行管理内存对齐(通常要求 512 字节对齐),增加了应用层复杂度。
1.2 每次 I/O 仍需两次系统调用
一次完整的 AIO 操作需要 io_submit() 提交 + io_getevents() 收割,即使用批处理模式,系统调用的开销在大规模并发场景下依然可观。
1.3 无法与网络 I/O 集成
POSIX AIO 在设计上只面向块设备,对 TCP/UDP socket 完全无能为力。这意味着在高并发网络服务器中,开发者在磁盘 I/O 和 socket I/O 上必须使用两套不同的异步框架。
二、io_uring 的核心架构
io_uring 的设计哲学可以概括为:"一个系统调用完成一切"。其核心是在用户态和内核态之间共享三块环形缓冲区(Ring Buffer),以此消除不必要的系统调用。
2.1 三个核心数据结构
io_uring 实例通过 io_uring_setup() 系统调用创建,建立后会在用户空间映射三块共享内存区域:
Submission Queue (SQ) — 提交队列
SQ 是一个生产者-消费者模式的环形缓冲区。用户态程序将 io_uring_sqe(Submission Queue Entry)写入 SQ 环,通知内核有新的 I/O 请求需要处理。SQ 拥有两个指针:
- tail 指针:用户态写入位置(仅用户态修改)
- head 指针:内核已消费位置(仅内核修改)
Completion Queue (CQ) — 完成队列
CQ 是内核用来通知用户态 I/O 操作完成的地方。每次 I/O 完成后,内核写入一个 io_uring_cqe(Completion Queue Entry)到 CQ 环。CQ 同样有两个指针:
- head 指针:内核已完成的位置(仅内核修改)
- tail 指针:用户态已收割位置(仅用户态修改)
Submission Queue Entries (SQE) 数组
SQE 数组是一个与 SQ 环分离的固定大小数组,SQ 环中存储的实际上是 SQE 数组中的索引值。这种间接设计使 SQ 可以持有对大量待处理 I/O 请求的引用。
2.2 工作模式
io_uring 提供多种工作模式,适应不同的性能与功能需求:
| 模式 | 标志 | 说明 |
|---|---|---|
| 中断驱动模式 | 默认 | I/O 完成后内核通过事件通知,CPU 开销最低,但延迟略高 |
| 内核线程轮询 | IORING_SETUP_SQPOLL | 内核线程主动轮询 SQ,进一步减少 syscall 开销,适合极低延迟场景 |
| 中断+轮询混合 | IORING_SETUP_SQPOLL + timeout | 轮询一段时间后自动切换为中断模式,兼顾低延迟与 CPU 效率 |
| 直通 I/O (Direct) | IORING_SETUP_IOPOLL | 对支持 polling 的块设备(如 NVMe SSD),绕过内核中断路径,直接轮询完成状态 |
三、liburing 编程接口详解
虽然可以直接使用 io_uring_setup、io_uring_enter、io_uring_register 三个系统调用操作 io_uring,但官方提供的 liburing 库封装了底层细节,是推荐的编程方式。
3.1 初始化与销毁
#include <liburing.h>
struct io_uring ring;
// 初始化:队列深度 128,默认参数
int ret = io_uring_queue_init(128, &ring, 0);
if (ret < 0) {
fprintf(stderr, "queue_init: %s\n", strerror(-ret));
return 1;
}
// ... 使用 ring ...
// 销毁
io_uring_queue_exit(&ring);
3.2 提交一个读请求
// 1. 获取一个 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. 准备一个 pread 操作
io_uring_prep_read(sqe, fd, buf, nbytes, offset);
io_uring_sqe_set_data(sqe, my_data_ptr); // 携带用户上下文
// 3. 提交给内核
io_uring_submit(&ring);
3.3 收割完成事件
struct io_uring_cqe *cqe;
// 等待至少一个完成事件(阻塞方式)
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "wait_cqe: %s\n", strerror(-ret));
return 1;
}
// 读取用户上下文
my_data_t *data = (my_data_t *)io_uring_cqe_get_data(cqe);
printf("read %d bytes\n", cqe->res);
// 标记该 CQE 已消费
io_uring_cqe_seen(&ring, cqe);
3.4 非阻塞检查模式
// 尝试收割完成事件,无就绪事件立即返回
int ret = io_uring_peek_cqe(&ring, &cqe);
if (ret == 0) {
// 有完成事件
process_completion(cqe);
io_uring_cqe_seen(&ring, cqe);
} else if (ret == -EAGAIN) {
// 暂无完成事件
do_other_work();
}
四、三大高级特性
4.1 注册文件与缓冲区(Fixed Files / Fixed Buffers)
默认情况下,每次 I/O 操作都需要通过文件描述符查找内核中的 file 结构,存在 RCU 读取开销。io_uring 允许预先注册一组文件描述符:
// 注册最多 256 个 fd
int fds[256];
// ... 打开所有文件,填充 fds ...
io_uring_register_files(&ring, fds, 256);
// 使用注册索引替代 fd(fd = 索引值,设置 IOSQE_FIXED_FILE)
io_uring_prep_read(sqe, index, buf, nbytes, offset);
sqe->flags |= IOSQE_FIXED_FILE;
类似地,io_uring_register_buffers() 可以预注册内存缓冲区,让内核直接映射用户内存,避免每次 I/O 的 get_user_pages() 调用。
4.2 链式 SQE(Linked SQEs)
io_uring 支持将多个 SQE 链接为一个执行链,链中操作按顺序前向推进。典型应用是"先读后写"管道:
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, src_fd, buf, sz, src_off);
sqe1->flags |= IOSQE_IO_LINK; // 链接标记
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, dst_fd, buf, sz, dst_off);
io_uring_submit(&ring); // 一次 submit 完成读+写
链式操作还支持条件跳转(IOSQE_IO_HARDLINK),前驱失败则跳过后续链,实现了一种高效的内核侧分支逻辑。
4.3 轮询模式下的零系统调用
启用 IORING_SETUP_SQPOLL 后,内核会创建一个专用线程持续扫描 SQ 环,发现新条目后直接执行。此时用户态甚至不需要调用 io_uring_enter(系统调用),只需写入 SQE 并更新 SQ tail 即可。CQ 环的收割同样无需系统调用(共享内存直接读取)。
在极高 I/O 压力场景下,这种设计可以真正做到"零 syscall",成为 SPDK、io_uring-based 存储引擎的基石。
五、实战:构建 io_uring echo server
下面展示一个基于 io_uring 的极简 TCP echo server 核心框架:
#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define QUEUE_DEPTH 256
#define BUF_SIZE 1024
struct conn_info {
int fd;
unsigned int read_flags;
};
void submit_accept(struct io_uring *ring, int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
struct conn_info *ci = malloc(sizeof(struct conn_info));
ci->fd = -1;
io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, ci);
io_uring_submit(ring);
}
void submit_read(struct io_uring *ring, int fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
struct conn_info *ci = malloc(sizeof(struct conn_info));
ci->fd = fd;
io_uring_prep_recv(sqe, fd, ci->buf, BUF_SIZE, 0);
io_uring_sqe_set_data(sqe, ci);
io_uring_submit(ring);
}
void event_loop(struct io_uring *ring, int listen_fd) {
submit_accept(ring, listen_fd);
while (1) {
struct io_uring_cqe *cqe;
io_uring_wait_cqe(ring, &cqe);
struct conn_info *ci = io_uring_cqe_get_data(cqe);
int res = cqe->res;
if (ci->fd == -1) {
// accept 完成,res 是新连接的 fd
int new_fd = res;
submit_accept(ring, listen_fd); // 继续 accept
submit_read(ring, new_fd); // 开始读新连接
} else if (res > 0) {
// recv 完成,echo 回去
submit_write(ring, ci->fd, res);
submit_read(ring, ci->fd); // 继续读
} else {
// 连接关闭
close(ci->fd);
}
free(ci);
io_uring_cqe_seen(ring, cqe);
}
}
注意:这段代码仅展示核心结构,实际部署需要错误处理、缓冲区管理、优雅退出等补充逻辑。
六、性能对比:io_uring vs epoll vs AIO
以下是在 NVMe SSD 上的 IOPS 基准测试数据参考(队列深度 128, 4KB 随机读):
| 方案 | IOPS | CPU 利用率 | syscall 次数/I/O |
|---|---|---|---|
| blocking read | ~120K | 100%(单核) | 2 (read + 返回) |
| POSIX AIO | ~180K | 75% | 2 (submit + reap) |
| epoll + non-blocking | ~200K | 85% | 1 (epoll_wait) |
| io_uring (默认) | ~280K | 65% | ~0.1 (批量submit) |
| io_uring (SQPOLL) | ~450K | 95%(轮询核) | ~0 (零 syscall) |
可以看出,io_uring 不仅在吞吐量上有显著优势,更重要的是通过批量提交和共享内存设计,将每次 I/O 相关的系统调用开销摊薄至极低水平。
七、生产环境使用建议
7.1 何时使用 io_uring
- 高并发文件 I/O:存储引擎、数据库 WAL、日志系统
- 低延迟网络代理:反向代理、负载均衡器、API 网关
- 与 epoll 混合使用:网络事件用 epoll 监听,数据流转用 io_uring 异步执行
7.2 注意事项
- 内核版本要求 ≥ 5.1(生产建议 ≥ 5.10 以获得完整特性支持)
- SQPOLL 模式会独占一个 CPU 核心用于内核线程轮询,需评估 CPU 预算
- 未注册的 Buffer 在每次 I/O 时会触发
get_user_pages()调用,热路径中应使用 Fixed Buffers - io_uring 操作并非全部 O_DIRECT 语义,默认走页缓存,对磁盘的 O_DIRECT I/O 需额外设置
7.3 主流生态项目
- Tokio-uring(Rust):基于 io_uring 的异步运行时
- Glommio(Rust):io_uring-native 异步框架
- PostgreSQL:从 16 开始实验性支持 io_uring WAL
- systemd:内置 journald 使用 io_uring 加速日志写入
- Nginx:从 1.25 起实验性支持 aio 指令使用 io_uring
结语
io_uring 代表了 Linux 内核在系统调用接口设计上的一次范式转换——从"显式请求/响应"模式走向"生产者-消费者共享内存"模式。它不仅大幅降低了高并发 I/O 场景下的系统开销,更统一了磁盘与网络异步操作的编程模型。
对于追求极致性能的系统开发者而言,io_uring 已经不是"可选项",而是必须掌握的核心基础设施。随着 Rust 生态(tokio-uring、glommio)的成熟和内核持续演进(如 ongoing 的 netpoll、stall detection 优化),io_uring 正在重塑高性能系统编程的未来图景。

发表评论 取消回复