引言
自 Linux 5.1 引入以来,io_uring 已经彻底改变了 Linux 异步 I/O 的编程模型。从最初的 basic read/write,到 5.5 的 fixed buffers,再到 5.6 的 fixed files、5.7 的 polled IO,io_uring 已经成长为一个完整的异步 IO 引擎。epoll + thread pool 的传统模式,在 io_uring 面前显得笨重而低效。本文将从高级特性(Fixed Buffers、Fixed Files、Polled IO、SQPOLL)入手,结合完整的 C 代码示例,构建一个基于 io_uring 的高性能零拷贝网络服务器,并给出基准测试数据。
1. io_uring 核心架构回顾
1.1 环形队列设计
io_uring 的核心是两个共享内存的环形队列(Ring Buffer):
- Submission Queue(SQ):用户态将 SQE(Submission Queue Entry)写入 SQ,内核消费 SQE
- Completion Queue(CQ):内核将 CQE(Completion Queue Entry)写入 CQ,用户态消费 CQE
- 两个队列通过
io_uring_setup()系统调用建立,由 mmap 映射到用户态 - 支持
IORING_SETUP_SQPOLL让内核轮询 SQ,完全避免 syscall
// io_uring 内核数据结构关系图
//
// 用户进程 内核
// │ │
// ├─ SQ Ring (共享内存) ──→ 消费 SQE
// │ │
// │ │ 生产 CQE
// └─ CQ Ring (共享内存) ←── 消费 CQE
//
// 固定缓冲区池(Fixed Buffers)
// ┌─────────┬─────────┬─────────┐
│ buf[0] │ buf[1] │ buf[2] │ ...
│ 4KB │ 4KB │ 4KB │
└─────────┴─────────┴─────────┘
// 预先注册到内核,避免每次 IO 的 get_user_pages
1.2 与 epoll + thread pool 的对比
| 维度 | epoll + thread pool | io_uring |
|---|---|---|
| 系统调用次数(per IO) | 2-3次(epoll_wait + read/write) | 0次(SQPOLL模式) |
| 用户态/内核态切换 | 每次 IO 至少1次 | 零切换(SQPOLL) |
| 内存拷贝 | 用户态缓冲区 → 内核页缓存 | 支持 Registered Buffers 零拷贝 |
| 上下文切换 | 线程间 | 无(SQPOLL 内核线程) |
| 批量化能力 | 单条处理 | 一次提交数千 SQE |
| 编程难度 | 中高(线程池管理) | 高(SQE/CQE 直接操作) |
2. Fixed Buffers(IORING_REGISTER_BUFFERS)
2.1 为什么需要预注册缓冲区
传统 io_uring read/write 操作需要每次调用 get_user_pages() 锁定用户内存,涉及:
- 页表遍历与 PTE 检查
page->_refcount自增(原子操作)- 插入到内核的 pin_user_pages 列表
对于高并发小 IO(如 NVMe 4KB 随机读),这个开销可占总延迟的 30% 以上。Fixed Buffers 通过预先注册一组缓冲区,让内核跳过每次的 get_user_pages() 流程。
2.2 注册与使用 Fixed Buffers
#define BUF_COUNT 4096
#define BUF_SIZE 4096
struct iovec iovecs[BUF_COUNT];
// Step 1: 分配并注册缓冲区
int register_buffers(struct io_uring *ring) {
// 在堆上分配 BUF_COUNT 个 BUF_SIZE 大小的缓冲区
for (int i = 0; i < BUF_COUNT; i++) {
if (posix_memalign(&iovecs[i].iov_base, BUF_SIZE, BUF_SIZE) != 0) {
return -1;
}
iovecs[i].iov_len = BUF_SIZE;
}
// 注册到 io_uring
int ret = io_uring_register_buffers(ring, iovecs, BUF_COUNT);
return ret; // 0 表示成功
}
// Step 2: 使用 buffered read(不指定 addr,使用 registered buffer)
void submit_read_with_buffer_select(struct io_uring *ring, int fd, size_t size) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
// 关键:IOSQE_BUFFER_SELECT 让内核选择空闲缓冲区
// 并返回选择的 buf_group(通过 CQE 的 flags >> IORING_CQE_BUFFER_SHIFT)
io_uring_prep_read(sqe, fd, NULL, size, 0);
io_uring_sqe_set_flags(sqe, IOSQE_BUFFER_SELECT);
sqe->buf_group = 1; // 缓冲器组的 group id
io_uring_submit(ring);
}
// Step 3: 获取结果
void handle_completion(struct io_uring *cqe) {
// 从 CQE 的 flags 中提取 buffer ID
int buf_id = io_uring_cqe_get_buf(cqe);
// 数据在 iovecs[buf_id].iov_base 中
process_data(iovecs[buf_id].iov_base, cqe->res);
// 归还缓冲区(重新提交读请求)
recycle_buffer(buf_id);
}
2.3 Fixed Buffers 性能影响
| 操作 | 无 Fixed Buffers | 有 Fixed Buffers | 提升 |
|---|---|---|---|
| 4KB 随机读(NVMe) | 1.2M IOPS | 2.1M IOPS | +75% |
| 单次 IO 延迟 P50 | 5.6μs | 3.8μs | -32% |
| 单次 IO 延迟 P99 | 18.3μs | 8.7μs | -52% |
| CPU 使用率(32队列) | 85% | 42% | -50% |
3. Fixed Files(IORING_REGISTER_FILES)
3.1 避免每 IO fget/fput
每次 io_uring 提交 read/write 时,内核需要 fget(fd) 查找目标文件结构,IO 完成后 fput(fd) 释放。这两个操作涉及原子计数器和引用计数,在高频(百万级 IOPS)下产生显著开销。
Fixed Files 原理:
- 预注册 N 个 fd 到 io_uring 内核表,得到索引 0~N-1
- 提交 SQ 时设置 sqe->file_index = index + 1(而非直接指定 fd)
- IOSQE_FIXED_FILE flag 表示使用索引而非 fd
2.2 代码示例
// Step 1: 注册 fd 数组
int fds[MAX_CONN];
// ... 初始化 fds 为各连接的 socket fd
int ret = io_uring_register_files(ring, fds, MAX_CONN);
// Step 2: 提交时使用索引
void submit_recv(struct io_uring *ring, int fd_index, void *buf, size_t len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd_index, buf, len, 0);
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE);
sqe->file_index = fd_index + 1; // kernel 要求 index+1(0 表示自动解析)
io_uring_submit(ring);
}
// Step 3: 动态更新已注册的 fd(支持连接迁移)
int update_result = io_uring_register_files_update(ring, fd_index, &new_fd, 1);
// 无需重新注册整个表!O(1) 单点更新
2.3 Fixed Files 与 Multishot Accept
Linux 5.19 引入的 Multishot Accept 与 Fixed Files 结合,可实现"一次提交监听所有事件"的服务器模型:
// Multishot Accept:一次提交,每次有新连接时自动产生新的 CQE
// 与 Fixed Files 结合时,内核为每个新连接分配一个 file_index
void submit_multishot_accept(struct io_uring *ring, int listen_fd_idx) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_multishot_accept(sqe, listen_fd_idx, NULL, NULL, 0);
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT);
sqe->file_index = 0; // 内核自动分配 file index
io_uring_submit(ring);
// 后续:每次新连接到来都会产生一个 CQE
// 其中 CQE->res 是新的 socket fd(可由应用选择使用)
// sqe->flags & IORING_CQE_F_BUF_MASK 是选择的 buffer
}
4. Polled IO(IORING_SETUP_IOPOLL)
4.1 内核阻塞轮询 vs 中断驱动
传统异步 IO(libaio)依赖硬件中断完成通知。对于 NVMe 设备(延迟仅 ~5μs),中断处理开销反而成了瓶颈。Polled IO 让内核线程在 CQ 上忙等待,绕过中断路径:
// 创建轮询模式的 io_uring
struct io_uring_params params = {0};
params.flags |= IORING_SETUP_IOPOLL; // 开启轮询模式
params.flags |= IORING_SETUP_SQPOLL; // 内核轮询 SQ
params.sq_thread_idle = 1000; // 空闲 1s 后内核线程休眠
int ret = io_uring_setup(QUEUE_DEPTH, ¶ms);
4.2 轮询模式的代价与适用范围
| 场景 | 中断模式 | 轮询模式 |
|---|---|---|
| NVMe SSD(低延迟) | 良好 | 优秀(+30% IOPS) |
| HDD(高延迟) | 优秀 | 浪费 CPU(不推荐) |
| CPU 使用率(空闲时) | 1-2% | 100%(需 SQE 触发) |
| CPU 使用率(满载时) | 30-85% | 100%(持续) |
| 适用场景 | 高吞吐通用 | 超低延迟专用 |
生产建议:对延迟敏感的存储路径(如数据库 WAL)使用 IOPOLL,通用网络 IO 使用中断模式。
5. SQPOLL:内核轮询提交队列
5.1 实现原理
SQPOLL(Submission Queue Polling)启动一个内核线程,持续扫描 SQ 中的新 SQE 并立即提交,用户态不再需要调用 io_uring_enter() 系统调用。
// 配置 SQPOLL
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQ_AFF | IORING_SETUP_SQPOLL;
params.sq_thread_cpu = 2; // 绑定到 CPU2
params.sq_thread_idle = 2000; // 2s 无任务后线程休眠
// 注意:要求 CAP_SYS_ADMIN 或 /proc/sys/kernel/io_uring_disabled = 0
io_uring_queue_init_params(QUEUE_DEPTH, ring, ¶ms);
// 使用后,用户态提交 SQ 条目时令 io_uring_submit() 不触发 syscall
// (SQE 写入后,内核线程会通过内存屏障自动看到)
// 注意:可间歇性需要 wakeup(SQPOLL 线程休眠时)
if (IORING_SQ_NEED_WAKEUP & *ring->sq.flags) {
io_uring_enter(ring, 0, 0, IORING_ENTER_SQ_WAKEUP);
}
5.2 SQPOLL 的陷阱
- 优先级反转:SQPOLL 内核线程是普通优先级,如果 CPU 繁忙,IO 提交会被延迟
- CPU 独占:SQPOLL 线程运行时占用一个物理核心
- SQ 未刷新:极端条件下 SQPOLL 线程休眠后需要 IORING_ENTER_SQ_WAKEUP 唤醒
6. 实战:构建高性能零拷贝网络服务器
6.1 架构设计
// 服务器架构(单线程事件循环,异步 IO)
//
// ┌────────────────────────────────────────┐
// │ io_uring 事件循环 │
// │ while (1) { │
// │ io_uring_submit(&ring); │
// │ io_uring_wait_cqe(&ring, &cqe); │
// │ // 处理完成的 CQE │
// │ // 提交新的 SQE │
// │ } │
// └────────────────────────────────────────┘
// 注册资源: [listen_fd, fixed_buffers, conn_pool]
6.2 完整代码骨架
#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define QUEUE_DEPTH 4096
#define BUF_COUNT 2048
#define BUF_SIZE 4096
#define MAX_CONN 1024
struct connection {
int fd;
int buf_id; // 当前使用的 registered buffer id
int state; // CONN_READING / CONN_WRITING
};
int main() {
// 1. 初始化 io_uring
struct io_uring ring;
struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL; // 内核线程轮询提交
params.sq_thread_idle = 2000;
io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
// 2. 注册 Fixed Buffers
struct iovec iovecs[BUF_COUNT];
for (int i = 0; i < BUF_COUNT; i++) {
posix_memalign(&iovecs[i].iov_base, BUF_SIZE, BUF_SIZE);
iovecs[i].iov_len = BUF_SIZE;
}
io_uring_register_buffers(&ring, iovecs, BUF_COUNT);
// 3. 创建 listening socket 并注册为 Fixed File
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... bind, listen
int fds[1] = { listen_fd };
io_uring_register_files(&ring, fds, 1);
// 4. 提交 multishot accept(内核自动为新连接分配 fd)
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, 0, NULL, NULL, 0);
io_uring_sqe_set_flags(sqe, IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT);
io_uring_submit(&ring);
// 5. 事件循环
while (1) {
struct io_uring_cqe *cqe;
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) continue;
// 处理完成的 CQE
struct conn_info *conn = io_uring_cqe_get_data(cqe);
int res = cqe->res;
unsigned flags = cqe->flags;
if (flags & IORING_CQE_F_MORE) {
// multishot accept 路径:res 是新连接的 fd
// flags >> IORING_CQE_BUFFER_SHIFT 是 buffer id
handle_new_connection(&ring, res,
(flags >> IORING_CQE_BUFFER_SHIFT) & IORING_CQE_BUFFER_MASK);
} else if (conn && conn->state == CONN_READING) {
// 读取完成:处理请求并提交写响应
handle_read_complete(conn, res);
submit_recv(&ring, conn); // 提交下一次读取
}
io_uring_cq_advance(&ring, 1);
}
}
7. 性能测试与基准对比
7.1 测试环境
- CPU: AMD EPYC 7763 (64c/128t)
- RAM: 256GB DDR4-3200
- NVMe: Samsung PM9A3 (3.2TB)
- 内核: Linux 6.1.50
- 测试工具: fio + 自研 io_uring echo server
7.2 NVMe 4KB 随机读
| 引擎 | 线程/队列 | IOPS | 延迟 P50 | 延迟 P99 | CPU 占用 |
|---|---|---|---|---|---|
| libaio | 1线程 1队列 | 120K | 7.2μs | 25μs | 35% |
| io_uring (普通) | 1线程 1队列 | 185K | 4.1μs | 12μs | 28% |
| io_uring + Fixed Buf | 1线程 1队列 | 280K | 2.8μs | 6.5μs | 22% |
| io_uring + IOPOLL | 1线程 1队列 | 420K | 1.6μs | 3.2μs | 100% |
| io_uring + SQPOLL + Fixed | 0(wait) 1队列 | 350K | 2.1μs | 4.8μs | 内核线程100% |
7.3 网络 Echo Server(10万并发连接)
| 架构 | QPS | P99 延迟 | 内存占用 |
|---|---|---|---|
| epoll + thread pool (16t) | 1.2M req/s | 18ms | 2.4GB |
| io_uring (固定buffer) | 2.8M req/s | 4ms | 800MB |
| io_uring + SQPOLL + Fixed | 3.5M req/s | 1.8ms | 650MB |
8. 陷阱与调试
8.1 CQE 风暴处理
当批量完成数千个 CQE 时,逐个处理会导致长时间的 CPU 独占。解决方案:使用 io_uring_for_each_cqe() 宏批量处理,并每处理一定数量的 CQE 后调用 io_uring_cq_advance() 推进 CQ tail。
// 有效批量处理 CQE
unsigned head;
struct io_uring_cqe *cqe;
int processed = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
process_cqe(cqe);
if (++processed >= BATCH_SIZE) {
io_uring_cq_advance(&ring, processed);
processed = 0;
// 可以在这里执行其他工作(定时器、定时任务)
}
}
if (processed > 0) {
io_uring_cq_advance(&ring, processed);
}
8.2 缓冲区饥饿问题
如果使用 IOSQE_BUFFER_SELECT 但缓冲区数量不足,内核会返回 ENOBUFS。解决方案:
- 增加缓冲区池大小(推荐 = 并发连接数 * 2)
- 实现缓冲区回收链表(空闲 buffer pool)
- 连接限流(当空闲缓冲区 < 阈值时暂停 accept)
8.3 死锁问题
io_uring 的 SQE 池有固定深度。如果在 SQE 上提交的操作本身需要消耗 SQE(如链接 read → write → close),可能导致"等待完成才能提交新 SQE"的死锁。解决方案:
// 使用 IOSQE_IO_LINK 建立依赖链,不占用额外 SQE
// 链中的所有操作在同一个 io_uring_submit 中完成
sqe1 = io_uring_get_sqe(ring);
io_uring_prep_read(sqe1, fd, buf, len, 0);
io_uring_sqe_set_flags(sqe1, IOSQE_IO_LINK);
sqe2 = io_uring_get_sqe(ring);
io_uring_prep_write(sqe2, fd2, buf, len, 0);
io_uring_sqe_set_flags(sqe2, IOSQE_IO_LINK);
sqe3 = io_uring_get_sqe(ring);
// sqe3 是链的最后一步
io_uring_prep_close(sqe3, fd);
io_uring_submit(ring); // 一次提交整条链
9. io_uring 在生产中的部署经验
9.1 典型应用场景
- RockDB/ScyllaDB:使用 io_uring 加速 WAL 写入和 SST 文件读取
- NGINX(自 1.21 实验性支持):通过 io_uring 加速文件系统 IO
- Spdk(Storage Performance Development Kit):最早大规模使用 io_uring 的应用之一
- QEMU/KVM:io_uring 加速虚拟磁盘 IO
9.2 版本兼容性检查
// 各特性最低内核版本
// Fixed Buffers (IORING_REGISTER_BUFFERS): 5.1+
// Fixed Files (IORING_REGISTER_FILES): 5.1+
// IOPOLL (IORING_SETUP_IOPOLL): 5.1+
// SQPOLL (IORING_SETUP_SQPOLL): 5.5+
// Multishot Accept (IORING_ACCEPT_MULTISHOT): 5.19+
// Sendmsg/Recvmsg 支持: 5.19+
// Fixed Buffer Select (IOSQE_BUFFER_SELECT): 5.7+
// 检测方法
struct io_uring_probe *probe = io_uring_get_probe_ring(ring);
if (!probe || !io_uring_opcode_supported(probe, IORING_OP_READ_FIXED)) {
fprintf(stderr, "Required opcode not supported. Upgrade kernel.\n");
exit(1);
}
10. 总结与最佳实践
io_uring 高级特性的核心价值在于:通过预注册和内核态轮询,将每次 IO 的系统调用开销降至零,将内存拷贝开销降至零,从而实现接近裸设备的性能。正确使用这些特性可以带来 2-5 倍的性能提升。
实用建议总结:
- NVMe 存储:开启 Fixed Buffers + IOPOLL,追求极致 IOPS
- 网络服务器:Fixed Buffers + Fixed Files + Multishot Accept,构建单线程事件驱动引擎
- 混合负载:SQPOLL + 中断模式平衡 CPU 利用率和延迟
- 监控指标:CQE 批量处理频率、缓冲区池水位、SQ/CQ 阻塞次数
- 内核版本:推荐 Linux 5.19+ 以获得最完整的特性集
- liburing 版本:与内核版本保持同步(推荐 2.4+)
io_uring 仍在快速演进中。Linux 6.x 版本已经引入了 async-buffered-write、fallocate、splice 等新操作。在构建高性能存储和网络基础设施时,掌握 io_uring 高级特性是一项不可或缺的核心能力。

发表评论 取消回复