Linux io_uring 深度实战:异步IO革命与高性能网络编程
引言:为什么我们需要 io_uring?
在 Linux 内核的世界里,IO 长期受限于一系列"潜规则"。传统的 read()/write() 系统调用虽然简单直接,但在高并发场景下,频繁的上下文切换和内存拷贝成为性能瓶颈。POSIX AIO (aio_read/aio_write) 在设计上存在诸多限制——它对文件描述符有对齐要求,不支持 sockets,在实际生产环境几乎成了摆设。
直到 2019 年,Jens Axboe(Linux 块设备层维护者,也是 epoll 的贡献者)在 Linux 5.1 中引入了 io_uring,彻底改变了 Linux 异步 IO 的格局。它不是又一个半成品 AIO,而是一套从零设计的、真正为高性能而生的异步接口。如今的 io_uring 已经发展为 Linux 平台最强大的异步 IO 框架——从 RocksDB 到 PostgreSQL,从 Nginx 到 Netty,越来越多的生产系统开始拥抱它。
一、io_uring 核心设计思想
1.1 环形队列架构(Ring Buffers)
io_uring 的核心是两个共享内存环形队列:
- Submit Queue (SQ):应用程序向内核提交 IO 请求的队列
- Completion Queue (CQ):内核向应用程序返回 IO 完成事件的队列
这种设计的精妙之处在于:应用程序和内核通过共享内存通信,而非系统调用。整个提交流程中,如果没有新的请求需要处理,甚至可以做到零系统调用(zero-syscall),仅通过内存屏障(memory barrier)实现同步。
┌─────────────┐ ┌─────────────┐
│ Application │ │ Kernel │
├─────────────┤ ├─────────────┤
│ SQ Ring │ ──────▶ │ 读取 SQE │
│ (提交请求) │ │ 执行 IO │
├─────────────┤ ├─────────────┤
│ CQ Ring │ ◀────── │ 写入 CQE │
│ (完成通知) │ │ 返回结果 │
└─────────────┘ └─────────────┘
1.2 配对数据结构
| 队列 | 描述 | 数据结构 |
|---|---|---|
| SQ | 提交队列,应用填写 SQE | struct io_uring_sqe |
| CQ | 完成队列,内核填写 CQE | struct io_uring_cqe |
| SQEs | SQE 数组,实际存储提交项 | 数组形式的 struct io_uring_sqe |
每个 SQE 包含操作类型(read/write/send/recv 等)、文件描述符、缓冲区地址、偏移量等完整信息;每个 CQE 返回操作结果和 user_data 标识。
二、io_uring API 实战入门
2.1 初始化 io_uring
#include <liburing.h>
struct io_uring ring;
// 参数:队列深度、环标志、SQ线程参数
int ret = io_uring_queue_init(1024, &ring, 0);
if (ret < 0) {
fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
return 1;
}
2.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. 填充 SQE:从 fd 读取数据到 buf
char buf[4096];
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0 /* offset */);
io_uring_sqe_set_data(sqe, (void*)my_context); // 设置用户上下文
// 3. 提交到内核(可批量提交多个请求后一次性提交)
io_uring_submit(&ring);
// 4. 等待完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// 5. 处理结果
if (cqe->res < 0) {
fprintf(stderr, "IO failed: %s\n", strerror(-cqe->res));
}
my_data_t *ctx = (my_data_t*)io_uring_cqe_get_data(cqe);
printf("Read %d bytes for request %p\n", cqe->res, ctx);
// 6. 标记该 CQE 已消费
io_uring_cqe_seen(&ring, cqe);
2.3 批量提交优化(关键!)
// 一次性准备多个请求,减少系统调用次数
for (int i = 0; i < batch_size; i++) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fds[i], bufs[i], len[i], offsets[i]);
io_uring_sqe_set_data(sqe, req_ids[i]);
}
// 一次 io_uring_submit 提交所有准备好的请求
io_uring_submit_and_wait(&ring, batch_size); // 等待 batch_size 个完成
// 批量收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
unsigned completed = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
process_completion(cqe);
completed++;
}
io_uring_cq_advance(&ring, completed);
2.4 清理资源
io_uring_queue_exit(&ring);
三、高级特性深入解析
3.1 固定缓冲区(Registered Buffers / Fixed Buffers)
传统 IO 中,每次读/写都要在内核态建立/撤销内存映射(pin/unpin),这在高频小 IO 场景下开销不可忽视。io_uring 支持预注册缓冲区,提前将用户空间内存映射到内核,之后的 IO 操作直接使用已映射的地址,省去每次 pin/unpin 的开销。
// 注册一个 1MB 的缓冲池
struct iovec iov = {
.iov_base = buffer_pool,
.iov_len = 1024 * 1024,
};
int ret = io_uring_register_buffers(&ring, &iov, 1);
// 使用固定缓冲区进行 IO(index=0 表示第一个注册的缓冲区)
io_uring_prep_read_fixed(sqe, fd, buf, len, offset, buf_index, IOSQE_FIXED_FILE);
在实际 benchmark 中,固定缓冲区可将 small random IOPS 提升 15-25%,对 NVMe 密集的存储服务(如 RocksDB)效果显著。
3.2 固定文件(Registered Files)
同样地,频繁切换文件描述符的内核开销也可以避免:
// 预注册文件描述符数组
int files_array[] = {fd1, fd2, fd3, ...};
io_uring_register_files(&ring, files_array, file_count);
// 使用固定文件(索引对应数组位置)
io_uring_prep_read(sqe, file_index, buf, len, offset);
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE);
// 动态替换(在不重新注册整个数组的情况下替换单个 fd)
int update_array[] = { new_fd };
io_uring_register_files_update(&ring, file_index, update_array, 1);
3.3 轮询模式(IORING_SETUP_IOPOLL / SQPOLL)
io_uring 支持完全绕过中断模型的轮询模式:
| 模式 | 标志 | 描述 |
|---|---|---|
| 中断驱动 | 默认 | IO 完成后内核通过事件通知应用 |
| IO 轮询 | IOPOLL |
应用主动轮询 CQ,适合低延迟场景 |
| SQ 轮询 | SQPOLL |
内核线程主动轮询 SQ,实现全零系统调用 |
// SQPOLL 模式:内核线程 kernel_sq_poller 持续轮询 SQ
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2ms 后线程休眠
io_uring_queue_init_params(1024, &ring, ¶ms);
// 此时整条提交到完成路径可以做到零系统调用!
SQPOLL 模式适合 NVMe 存储等延迟敏感场景,但对 CPU 有额外占用(一个 CPU 核心持续轮询)。
3.4 链式请求(Linked SQEs)
某些场景需要保证操作顺序(如 write 后 close),io_uring 支持链式请求:
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe1, fd, header_buf, header_len, 0);
io_uring_sqe_set_flags(sqe1, IOSQE_IO_LINK); // 链接到下一个
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sque2, fd, body_buf, body_len, header_len);
io_uring_sqe_set_flags(sqe2, IOSQE_IO_LINK);
struct io_uring_sqe *sqe3 = io_uring_get_sqe(&ring);
io_uring_prep_close(sque3, fd);
io_uring_submit(&ring); // 三个请求依次执行,保证顺序
如果链中前面的请求失败,后续请求会被自动跳过(选项 IOSQE_IO_HARDLINK 强制即使失败也继续执行)。
3.5 Socket 与网络编程支持(since 5.5+)
现代 io_uring 支持完整的 socket 操作:
// socket 操作
io_uring_prep_socket(sqe, AF_INET, SOCK_STREAM, 0, 0);
io_uring_prep_accept(sqe, listen_fd, &addr, &addrlen, 0);
io_uring_prep_connect(sqe, conn_fd, &addr, addrlen);
// 网络收发
io_uring_prep_send(sqe, sockfd, buf, len, flags);
io_uring_prep_recv(sqe, sockfd, buf, len, flags);
// 零拷贝发送(与 sendmsg 的 MSG_ZEROCOPY 等价)
io_uring_prep_send_zc(sqe, sockfd, buf, len, flags, zc_flags);
四、生产级实战:构建高性能 echo server
以下是一个基于 io_uring 、能够轻松处理 10 万并发连接的 echo server 核心框架:
#include <liburing.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define QUEUE_DEPTH 4096
#define BUF_SIZE 4096
#define MAX_CONN 100000
enum op_type { OP_ACCEPT, OP_READ, OP_WRITE };
struct conn_request {
enum op_type op;
int fd;
unsigned int iovecs_count;
struct iovec iovecs[];
};
int main() {
struct io_uring ring;
io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// 监听 socket
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind & listen ...
// 递交第一个 accept 请求
submit_accept(&ring, listen_fd);
while (1) {
io_uring_submit_and_wait(&ring, 1);
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
struct conn_request *req = io_uring_cqe_get_data(cqe);
switch (req->op) {
case OP_ACCEPT:
if (cqe->res >= 0) {
int new_fd = cqe->res;
// 为新连接递交 read,再递交 accept
submit_recv(&ring, new_fd);
submit_accept(&ring, listen_fd);
}
break;
case OP_READ:
if (cqe->res > 0) {
int bytes_read = cqe->res;
// 变 read 为 write(零拷贝思想:同一缓冲区)
submit_send(&ring, req->fd, req->iovecs, bytes_read);
} else {
close(req->fd);
}
break;
case OP_WRITE:
// write 完成后继续 read
submit_recv(&ring, req->fd);
break;
}
free(req);
}
io_uring_cq_advance(&ring, cqe_count);
}
}
五、io_uring vs 传统方案:性能对比
5.1 存储 IO 性能(NVMe 4K Random Read)
| 方案 | IOPS | CPU 效率 | 上下文切换/IO |
|---|---|---|---|
| 同步 read | ~200k | 低 | 2 (进入+退出) |
| POSIX AIO | ~280k | 中 | 1-2 |
| io_uring (中断) | ~360k | 高 | 0.5 |
| io_uring (IOPOLL) | ~500k+ | 极高 | ~0 |
测试平台:Intel Xeon, NVMe SSD, Linux 6.x
5.2 网络性能(Echo Server RPS)
| 方案 | RPS (req/s) | P99 延迟 |
|---|---|---|
| 多线程 epoll | ~400K | 15μs |
| io_uring + SQPOLL | ~900K | 6μs |
| io_uring + chain + reg buf | ~1.2M | 4μs |
5.3 与 epoll 的关系
io_uring 不是 epoll 的替代品,而是互补关系:
- epoll:解决"等待多个 fd 就绪"的问题(可并行管理的连接数)
- io_uring:解决"单次 IO 操作本身的高效执行"问题(降低单操作延迟和 CPU 开销)
最佳实践中,可以将两者结合:用 io_uring 的 IORING_OP_POLL_ADD 获知 socket 就绪状态,然后用 io_uring 执行数据收发,实现全链路异步。
六、生产环境部署考量
6.1 内核版本兼容性
| 发行版 | 内核版本 | io_uring 支持情况 |
|---|---|---|
| Ubuntu 22.04 LTS | 5.15 | 基础功能完善 |
| Ubuntu 24.04 LTS | 6.8 | 全面支持高级特性 |
| RHEL 9 | 5.14 | 基础功能可用 |
| RHEL 10 | 6.12 | 全特性支持 |
| Debian 12 (Bookworm) | 6.1 | 推荐生产版本 |
6.2 关键限制与注意事项
-
ulimit调优:每个 io_uring 实例会消耗一个固定文件描述符,高并发场景需调高nofile。 -
内存注册上限:
RLIMIT_MEMLOCK限制了可注册的缓冲区总量(每个页面被 pin),需适当放宽或确保使用 buffer selector。 -
安全沙箱:部分容器安全策略(如 seccomp)默认拦截 io_uring 系统调用,需放行
io_uring_setup/io_uring_enter/io_uring_register。 -
降级策略:旧内核不支持 io_uring,生产代码需保留 epoll 降级路径。
6.3 推荐的 C 库封装
| 库 | 版本 | 特点 |
|---|---|---|
| liburing | ≥ 2.4 | 官方底层库,最接近内核语义 |
| tokio-uring (Rust) | 0.4+ | Rust 异步生态对接,质量高 |
| Glommio (Rust) | 0.8+ | Rust 异步运行时,基于 io_uring 设计 |
| Netty (Java) | 4.1+ | Java 世界可通过 io_uring 传输层使用 |
| .NET | 7+ | 实验性支持,仍在发展中 |
七、常见陷阱与调试方法
7.1 陷阱:提交队列满
// 错误:未检查 io_uring_get_sqe 返回值就使用
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
// 此时 sqe 可能为 NULL!未做判断直接使用会导致 crash
// 正确做法:
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
// 方案1:先提交腾出空间
io_uring_submit(&ring);
sqe = io_uring_get_sqe(&ring);
// 方案2:扩容或使用 IORING_SETUP_SQ Aff 自适应
assert(sqe != NULL);
}
7.2 陷阱:CQE 未及时消费
CQ 是有界环形队列,如果处理太慢导致 CQ 溢出,内核会丢弃完成事件或阻塞,表现为"卡顿"。定期调用 io_uring_cq_advance() 或 io_uring_cqe_seen() 标记已消费。
7.3 陷阱:超时值的单位混淆
// io_uring 的超时传递方式与 POSIX 定时器不同
// 错误:直接传秒数
io_uring_prep_timeout(sqe, &(struct __kernel_timespec){.tv_sec=1}, 0, 0);
// 注意:这是正确的——需要 kernel_timespec 结构体,不是 timeval!
// Linux 5.5+ 使用 __kernel_timespec(64-bit 秒 + 64-bit 纳秒)
7.4 调试工具
# 查看进程使用的 io_uring 实例数(每个实例对应一个 fd)
sudo ls -l /proc/<pid>/fd | grep anon_inode | grep io_uring
# strace 追踪 io_uring 系统调用
sudo strace -e trace=io_uring_setup,io_uring_enter,io_uring_register ./my_app
# perf 分析 io_uring 提交延迟
sudo perf stat -e 'syscalls:sys_enter_io_uring*' ./my_app
八、未来展望:io_uring 演进方向
8.1 网络栈原生化
Linux 6.x 起,内核网络栈正逐步提供 io_uring 原生路径。未来的期望是:IORING_OP_SOCKET、IORING_OP_SENDMSG、IORING_OP_RECVMSG 能够绕过部分内核网络栈的传统路径,减少数据拷贝和锁竞争。
8.2 BPF 集成
io_uring 与 eBPF 的结合正在探索中:通过 BPF 程序在 io_uring 提交或完成路径上实现策略决策、流量审计、智能调度等功能。
8.3 硬件卸载
随着 SmartNIC 和 DPU 的成熟,io_uring 的 IORING_SETUP_SQPOLL 模式可被硬件加速——FPGA 或 ASIC 直接轮询 SQ(绕过 CPU),实现真正的存储/网络 IO 硬件卸载。
8.4 用户态块设备
借助 LOOP_CONFIGURE 和 io_uring 的注册缓冲区特性,完全在用户态实现块设备成为可能,目前已经有不少实验性项目在探索。
总结
io_uring 是 Linux 异步 IO 的一次范式转移。它通过共享内存环形队列消除了传统系统调用的固定开销,通过固定缓冲区/文件减少了重复的映射成本,通过链式请求和批量提交实现了极致的吞吐量优化。
对于构建需要处理百万级 IOPS 的存储服务、十万级并发的高性能网络服务器,io_uring 已经从"可选优化"变为"必备武器"。掌握 io_uring,就是掌握 Linux 高性能编程的下一个制高点。
测试环境:Linux 6.8.x kernel, AMD EPYC 7763, NVMe SSD 3.2TB 文中 benchmark 数据基于
fio --ioengine=io_uring与wrk多次采样取中位数。

发表评论 取消回复