Linux io_uring 深度解析:异步 I/O 革命与高性能网络实战
一、I/O 模型的演进:我们为什么需要 io_uring
在 Linux 高性能 I/O 的发展历程中,我们经历了几个关键阶段的演变。最初,阻塞式 I/O 是最自然的编程模型——线程发起 read/write 调用后休眠等待内核完成。然而,当连接数飙升至 C10K 甚至 C100K 时,线程调度开销和内存占用成为瓶颈。
于是 非阻塞 I/O + epoll 的组合应运而生。epoll 通过事件通知机制解决了"轮询浪费 CPU"的问题,成为近十年来高性能网络编程的事实标准(Nginx、Redis、Netty 均基于此)。但 epoll 本质上解决的只是"等待"的问题——真正的数据拷贝(read/write 系统调用)仍然是同步的。每次 read() 调用触发一次用户态/内核态切换,在高 IOPS 场景下,系统调用本身的开销成为了新的瓶颈。
io_uring 的出现彻底改变了这一格局。它由 Jens Axboe(Linux 块设备层维护者)于 2019 年提出,目标是让用户态与内核态之间的 I/O 通信"零系统调用"。核心思想是:在用户态和内核态之间共享一对环形缓冲区(ring buffer),用户将 I/O 请求写入提交队列(SQ),内核处理完成后将结果写入完成队列(CQ),整个过程(批量提交和收割完成)可以在没有系统调用的情况下完成。
自 5.1 版本合并入 Linux 主线以来,io_uring 快速演进:5.5 引入固定文件(IORING_REGISTER_FILES),5.6 引入高级特性(SQPOLL、IORING_SETUP_ATTACH_WQ),6.x 版本持续优化 io-wq worker 池和零拷贝支持。PostgreSQL 在 16 版本中将 io_uring 作为可选 I/O 后端,Rust 的 tokio-uring、Golang 的iouring-go 等语言绑定也日趋成熟。
二、io_uring 架构原理:双环形缓冲区
2.1 核心数据结构
io_uring 的核心是共享内存环形缓冲区,由三个关键组件构成:
┌──────────────────────────────────────────────────────┐
│ User Process │
│ │
│ ┌─────────────┐ ┌──────────────────┐ │
│ │ Submission │ │ Completion │ │
│ │ Queue │ │ Queue │ │
│ │ (SQ) │ │ (CQ) │ │
│ │ │ │ │ │
│ │ SQE[] 环形 │ │ CQE[] 环形 │ │
│ │ head → tail │ │ head → tail │ │
│ └─────────────┘ └──────────────────┘ │
│ ↓ io_uring_enter() ↑ │
└──────────┼─────────────────────────┼─────────────────┘
↓ ↑
┌──────────────────────────────────────────────────────┐
│ Kernel Space │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ io_uring 后端处理 │ │
│ │ ├─ 文件系统 I/O (read/write/preadv) │ │
│ │ ├─ 网络 I/O (send/recv/sendmsg) │ │
│ │ ├─ 轮询模式 IORING_SETUP_IOPOLL │ │
│ │ ├─ 固定缓冲区 Registered Buffers │ │
│ │ └─ 固定文件 Registered Files │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
- Submission Queue (SQ): 用户态向内核提交 I/O 请求(SQE, Submission Queue Entry)的环形缓冲区。只有用户态会写入 SQ,内核只读取。
- Completion Queue (CQ): 内核向用户态返回完成事件(CQE, Completion Queue Entry)的环形缓冲区。只有内核会写入 CQ,用户态只读取。
- Submission Queue Entries 数组 (SQE array): 每个 SQE 描述一个 I/O 操作(操作码 fd, buffer, offset, length 等)。
2.2 队列操作协议
生产者-消费者模型遵循严格的内存序约定:
用户态(生产者)操作 SQ:
1. 检查 SQ 是否有空位(sq_tail - sq_head < sq_entries)
2. 写入 SQE[sq_tail % sq_entries] = new_request
3. 内存屏障 + sq_tail++(原子操作)
4. 按需调用 io_uring_enter() 通知内核
消费者(内核)操作 SQ:
1. 读取 sq_tail(smp_load_acquire)
2. 依次处理 SQE[sq_head..sq_tail]
3. 内存屏障 + sq_head = sq_tail
内核(生产者)操作 CQ:
1. 检查 CQ 是否满
2. 写入 CQE[cq_tail % cq_entries] = completion
3. 内存屏障 + cq_tail++
用户态(消费者)操作 CQ:
1. 读取 cq_tail
2. 依次读取 CQE[cq_head..cq_tail]
3. cq_head = cq_tail
两个队列的更新完全通过 原子操作 + 内存屏障 完成,不需要系统调用。仅在需要唤醒内核 worker(SQ 线程休眠中)或要求内核阻塞等待至少 N 个完成事件时,才需要调用 io_uring_enter() 系统调用。
2.3 初始化与 mmap
// 1. 创建 io_uring 实例
struct io_uring ring;
int ret = io_uring_queue_init(QUEUE_DEPTH, &ring, 0);
// flags=0 表示默认模式(内核线程轮询 SQ)
// 2. 获取 SQE,填充请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, offset);
io_uring_sqe_set_data(sqe, my_request_ctx);
// SQE 的 user_data 字段可作为请求的上下文标识符
// 3. 提交(可选:批量提交时延迟)
io_uring_submit(&ring);
// 内部调用 io_uring_enter(ring_fd, to_submit=1, min_complete=0, ...)
// 4. 收割完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 或 io_uring_peek_cqe 非阻塞
// 或通过 io_uring_submit_and_wait(&ring, 1) 一步完成提交+等待
my_request_ctx = io_uring_cqe_get_data(cqe);
if (cqe->res < 0) { /* 处理错误 */ }
io_uring_cqe_seen(&ring, cqe); // 释放槽位
关键的优化点在于:如果使用了 IORING_SETUP_SQPOLL 标志启动 io_uring,内核会创建一个专用线程持续轮询 SQ 中的新条目(不调用 io_uring_submit 也能提交),此时几乎所有操作都是纯用户态完成的。
三、io_uring 高级特性
3.1 提交端轮询 (SQPOLL)
传统模式下,每次提交(io_uring_submit)都需要一次 io_uring_enter() 系统调用来唤醒内核处理 SQ。SQPOLL 模式下,内核启动一个专用线程(io-wq-sq)以忙等方式轮询 SQ tail 的更新:
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2ms 后休眠,节能
io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
代价: 该内核线程以 SCHED_FIFO 优先级忙等消耗 CPU(约 1 核的 5-15%)。对于高 IOPS 场景这笔开销远低于系统调用带来的延迟,但对于低频 I/O 不推荐使用。
进阶用法:IORING_SETUP_ATTACH_WQ 允许多个 io_uring 实例共享同一个 SQPOLL worker 线程,降低多线程场景下的 CPU 开销。
3.2 Registered Files (固定文件)
每次 I/O 操作的 fd 需要经过内核文件表查找,引入 RCU 读锁开销。通过 IORING_REGISTER_FILES 可以为用户态 fd 提前在内核注册一个固定索引(direct slot),后续 I/O 使用索引而非 fd,内核直接从固定数组获取 file 指针:
// 注册 fd 数组
int fds[] = {fd1, fd2, fd3};
io_uring_register_files(&ring, fds, 3);
// 使用固定索引提交 I/O
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
sqe->flags |= IOSQE_FIXED_FILE; // 使用索引而非 fd
sqe->fd = 0; // 指向 fds[0]
在数据库和 KV 存储中,文件集合通常不变,注册后可以消除约 10-15% 的 I/O 延迟。
3.3 Registered Buffers (固定缓冲区 / Provided Buffers)
传统的 read() 操作要求内核在 I/O 完成后将数据拷贝到用户态提供的缓冲区。对于延迟敏感的场景,内核需要先分配临时页面(page cache 或 %),完成后再映射到用户态(或通过 copy_to_user 拷贝)。
io_uring 通过 IORING_REGISTER_BUFFERS 支持"预注册缓冲区组":
struct iovec iov[ BUF_COUNT ];
for (int i = 0; i < BUF_COUNT; i++) {
posix_memalign(&iov[i].iov_base, 4096, BUF_SIZE);
iov[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iov, BUF_COUNT);
// I/O 时指定缓冲区组 ID 和索引
io_uring_prep_read_fixed(sqe, fd, NULL, BUF_SIZE, offset);
sqe->buf_index = select_buffer(i); // 组内索引
// 或使用 IORING_OP_PROVIDE_BUFFERS 实现内核驱动的缓冲池
3.4 链接请求与IORING_OP_LINK
通过 IOSQE_LINK 标志可以将多个 SQE 链接成原子链:前一个操作完成后才执行下一个,中间不返回用户态。这对于文件复制(lseek-read-write)、协议交互(ssl读-解密-处理)等场景可减少至少 50% 的上下文切换。
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, fd, buf1, len1, 0);
sqe1->flags |= IOSQE_IO_LINK;
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_write(sqe2, fd2, buf2, len2, 0);
sqe2->flags |= IOSQE_IO_LINK;
// sqe1 完成后自动触发 sqe2,中间不经过用户态
追加 IOSQE_IO_HARDLINK 可创建"硬链接":前置链中任何操作失败,后续操作立即被取消。
四、实战:用 io_uring 实现高性能文件服务器
下面展示一个基于 io_uring 的简单回显服务器(echo server),演示核心用法:
#include <liburing.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#define ENTRIES 4096
#define BUF_SIZE 8192
#define BACKLOG 4096
struct conn_info {
int fd;
unsigned type; // 0=accept, 1=read, 2=write
};
int main() {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
// 初始化 io_uring:SQPOLL 模式
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 1000;
if (io_uring_queue_init_params(ENTRIES, &ring, ¶ms) < 0) {
perror("io_uring_queue_init");
return 1;
}
// 创建 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(9999),
.sin_addr.s_addr = INADDR_ANY
};
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
listen(sockfd, BACKLOG);
// 注册固定缓冲区
struct iovec iov[ENTRIES];
for (int i = 0; i < ENTRIES; i++) {
iov[i].iov_base = aligned_alloc(4096, BUF_SIZE);
iov[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iov, ENTRIES);
// 提交初始 accept 请求
struct conn_info *ai = malloc(sizeof(struct conn_info));
ai->fd = sockfd; ai->type = 0;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, sockfd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, ai);
io_uring_submit(&ring);
printf("io_uring echo server on :%d\n", 9999);
// 事件循环
while (1) {
io_uring_wait_cqe(&ring, &cqe);
struct conn_info *info = io_uring_cqe_get_data(cqe);
int res = cqe->res;
io_uring_cqe_seen(&ring, cqe);
if (res < 0) {
fprintf(stderr, "io error: %s\n", strerror(-res));
close(info->fd);
free(info);
continue;
}
switch (info->type) {
case 0: // accept 完成
{
int client_fd = res;
// 为新 client 提交 read
struct conn_info *ri = malloc(sizeof(struct conn_info));
ri->fd = client_fd; ri->type = 1;
sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, client_fd,
iov[i = (client_fd % ENTRIES)].iov_base, BUF_SIZE, 0);
sqe->buf_index = i;
io_uring_sqe_set_data(sqe, ri);
// 继续 accept 新连接(复用原 conn_info)
sqe = io_uring_get_sqe(&ring);
io_uring_prep_accept(sqe, sockfd, NULL, NULL, 0);
io_uring_sqe_set_data(sqe, info);
io_uring_submit(&ring);
break;
}
case 1: // read 完成 → 提交 write
{
info->type = 2;
sqe = io_uring_get_sqe(&ring);
// 使用 write 回写相同缓冲区
io_uring_prep_write_fixed(sqe, info->fd,
__io_uring_cqe_addr... // 简化示意
res, 0);
io_uring_sqe_set_data(sqe, info);
io_uring_submit(&ring);
break;
}
case 2: // write 完成 → 重新 read 或关闭
if (res == 0) { close(info->fd); free(info); }
else { info->type = 1; /* 重新提交 read */ }
break;
}
}
io_uring_queue_exit(&ring);
return 0;
}
上面的代码结构展示了 io_uring 的核心范式:
- 事件驱动 + 状态机:每个连接维护
type字段标识当前状态,完成事件触发后转换状态。 - 零分配提交:
io_uring_get_sqe获取槽位后立即填充,无需等待。 - 批量操作:
send+recv可以在同一io_uring_submit中提交。 - 固定缓冲区:消除 read/write 之间的内存分配拷贝。
五、io_uring vs epoll:场景对比与选型
很多工程师会纠结"何时用 io_uring,何时继续用 epoll"。以下是系统性的对比:
| 维度 | epoll | io_uring |
|---|---|---|
| 解决问题 | I/O 多路复用(等待就绪) | I/O 提交+完成(全链路异步) |
| 调用开销 | epoll_wait + read/write = N+1 次 syscall | SQPOLL 下 0 次 syscall |
| 数据拷贝 | 始终需要 read/write 系统调用发起 | 可由内核直接完成(固定缓冲区) |
| NUMA 亲和 | 无特别优化 | SQPOLL 线程可绑定 CPU |
| 兼容性 | Linux 2.6+(全版本) | Linux 5.1+,主流发行版 5.10+ 推荐 |
| 内核开销 | O(1) 红黑树 + 就绪链表 | 额外内核线程(可选),SQ/CQ 管理 |
| 适用场景 | 高并发连接管理($C_n$级别连接) | 高 IOPS/高吞吐 I/O |
| 编程复杂度 | 中等(Reactor 模式成熟) | 高(需要状态机管理请求生命周期) |
结论:很多高性能系统采用 epoll + io_uring 混合模式——epoll 管理连接生命周期和 accept,io_uring 处理实际的 read/write 数据面。例如 PostgreSQL 16 使用 io_uring 的 IORING_SETUP_IOPOLL 模式加速堆页 I/O,而上层仍依赖传统的连接管理。
六、io_uring 最新进展与生态
6.1 安全强化 (Linux 6.x)
io_uring 曾被发现多个越权漏洞(CVE-2023-2598 等),根本原因是攻击者可通过精心构造的 SQE 绕过权限检查。内核社区在 6.1+ 版本中:
- 默认禁用非特权用户的 io_uring(
sysctl io_uring_disabled=2) - 引入
IORING_REGISTER_RING_FDS实现 ring fd 隔离 - 对
IORING_OP_MSG_RING等特权操作增加了 capability 校验
6.2 io_uring 与 Rust 生态
Rust 的异步生态为 io_uring 封装提供了天然优势:
- tokio-uring: 基于 tokio 运行时,为文件 I/O 提供 io_uring 后端(仅支持 fs,不支持 net)
- rio (io_uring): 独立 io_uring runtime,支持完整网络和文件 I/O
- Golang: iouring-go、eviopuring 等绑定
- C#: 社区 liburing-sharp 绑定
6.3 PostgreSQL 实践
PostgreSQL 16 引入了 io_method = io_uring 配置项,对以下场景有显著提升:
- 全表扫描(大堆页顺序 I/O):提升 20-40%
- 检查点写入:通过批量 io_uring_submit 减少 fsync 次数
- WAL 写入:结合
IOFLAG_USE_LINUX_AIO可达到亚毫秒延迟
6.4 轮询模式 (IOPOLL)
IORING_SETUP_IOPOLL 配合 NVMe 设备(set -xa /sys/block/nvme0n1/queue/io_poll=1)可以让 io_uring 在发起块设备 I/O 后不断轮询 CQ 等待完成,绕过整个 I/O 调度层和软中断。实测 NVMe 顺序读带宽可达内核 blk-mq 的 95%,而延迟降低 50% 以上。
七、性能调优要点
7.1 Queue Depth
io_uring_queue_init(entries) 中 entries 的典型值:
- 网络服务器: 256-1024(连接数 × 平均在途请求)
- KV 存储 / 数据库: 1024-4096(匹配 NVMe 队列深度)
- 顺序读: 64-256(避免 CQ 延迟过高)
注意:CQ 大小默认是 SQ 的 2 倍。如果 SQPOLL 线程延迟处理,CQ 可能会满(cq_tail 追上 cq_head 的"溢出"情况)。此时部分 CQE 会被丢弃并设置 IORING_CQE_F_BUFFER 标志。
7.2 批处理提交
即使使用了 SQPOLL,也应该尽量减少 io_uring_submit() 调用次数(每次调用最终仍可能触发一次轻量级 io_uring_enter 系统调用唤醒沉睡的 SQ 线程)。
推荐策略:在一个逻辑操作点(如处理完一批 CQE 后)批量提交所有新 SQE,然后只调用一次 io_uring_submit()。
7.3 工作线程亲和性
SQPOLL 内核线程的 CPU 亲和性可通过 /proc/<sqthread_pid>/task/<tid>/affinity 调整。在多 NUMA 节点系统中,将其绑定到与目标设备中断同一个 NUMA 节点可显著提升性能。
# 找到 SQPOLL 线程
ps -eo pid,comm | grep io_wq
taskset -pc 2-3 <sqthread_pid>
7.4 避免常见陷阱
- 不能使用
off_t大于 2GB 的值: 某些早期内核版本的 SQE offset 字段限为 32 位,需编译时启用_FILE_OFFSET_BITS=64。 - IOSQE_ASYNC 标志: 对非磁盘文件(如 socket)设置此标志会导致请求排队到 io-wq worker 池,增加延迟。仅在慢速操作(如打开稀疏文件)时使用。
- 共享 ring fd 的 fork: 多进程共享 io_uring 实例是允许的(
IORING_REGISTER_RING_FDS),但 SQPOLL 线程只有一个,提交端需要注意锁竞争。
八、总结
io_uring 是 Linux 近十年来最重要的 I/O 架构创新,它通过共享环形缓冲区将用户态与内核态的边界模糊化,实现了近乎零开销的 I/O 请求提交。对于追求极致性能的存储引擎、数据库、KV 存储、网络代理而言,io_uring 已经成为不可避免的基础设施。
但它也不是银弹——SQPOLL 的 CPU 占用、编程模型的高复杂度、以及内核版本要求(5.10+ 推荐),都意味着需要权衡收益与成本。在实践中,epoll 管理连接 + io_uring 处理数据面的混合架构是当前的最优解。
建议感兴趣的系统开发者从 liburing 的 io_uring-samples 入手,先跑通基础的 echo server 示例,再逐步引入 SQPOLL、固定缓冲区和链接请求,最终在自己的产品中获得可量化的性能提升。
参考资源:
- Jens Axboe. "Efficient IO with io_uring". https://kernel.dk/io_uring.pdf
- Linux 内核文档:
Documentation/io_uring.rst - liburing 源码: https://github.com/axboe/liburing
- PostgreSQL 16 Release Notes - io_uring support
- Lord of the io_uring (指南): https://unixism.net/loti/

发表评论 取消回复