引言:为什么 io_ung 取代了 Linux AIO
Linux 的 I/O 演进历程中,POSIX AIO (libaio) 一直是异步 I/O 的官方解决方案。然而,libaio 自诞生起就饱受诟病:仅支持 O_DIRECT 文件 I/O,不支持网络 I/O,且每次操作都需要复杂的 iocb 结构。2019 年 Linux 5.1 引入的 io_uring 彻底改变了这一局面,它提供了一个统一的、高性能的异步 I/O 接口,不仅解决了 libaio 的诸多缺陷,还将 Linux 异步 I/O 性能推向了新高度。
io_uring 不仅是一个新的系统调用接口,更是一种全新的内核-用户空间协作模式。它通过共享内存环形队列(Submission Queue / Completion Queue)消除了系统调用的开销,支持零拷贝操作,并且可以用于文件 I/O、网络 I/O、事件通知等几乎所有场景。本文将深入剖析 io_uring 的核心机制,并展示如何用它构建生产级的高性能应用。
一、架构总览:io_uring 的三环模型
io_uring 的核心设计是三个环形缓冲区(ring buffer),它们位于内核与用户空间共享的内存区域中:
┌──────────────────────────────────────────────────┐
│ 用户空间 (Userspace) │
│ │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Submission │ │ Completion Queue │ │
│ │ Queue │ │ (CQ) │ │
│ │ (SQ) │ │ │ │
│ │ │ │ ┌──┐┌──┐┌──┐┌──┐ │ │
│ │ ┌──┐┌──┐ │ │ │CQE││CQE││CQE││..│ │ │
│ │ │SQE││SQE│ │ │ └──┘└──┘└──┘└──┘ │ │
│ │ └──┘└──┘ │ └─────────────────────┘ │
│ └─────────────┘ │
│ ↓ io_uring_enter │
│ ─────────────────────────────────────── │
│ 内核空间 │
│ ┌──────────────────────────────────┐ │
│ │ io_uring 内部处理引擎 │ │
│ │ ┌──────────┐ ┌──────────────┐ │ │
│ │ │IOPOLL模式│ │任务执行工作池 │ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
SQ (Submission Queue): 用户提交 SQE → 内核消费 SQE
CQ (Completion Queue): 内核写入 CQE → 用户消费 CQE
SQEs: Submission Queue Entries — 用户准备好的操作描述
CQEs: Completion Queue Entries — 内核返回的操作结果
关键设计要点:
- SQ 是单向生产者:只有用户空间向 SQ 写入 SQE(单一生产者),内核从中读取(单一消费者)
- CQ 也是单向:只有内核向 CQ 写入 CQE(单一生产者),用户从中读取(单一消费者)
- 无需系统调用即可提交:填充 SQE 后无需调用任何 syscall,只需在批量提交时调用 io_uring_enter
- 内存屏障保证可见性:通过适当的写读屏障(write/read barrier)保证 SQ/CQ 的顺序一致性
二、初始化与队列配对 (Queue Pair)
每个 io_uring 实例由一个 io_uring_params 结构体配置,它决定了 SQ 和 CQ 的大小、特性和行为模式:
#include <liburing.h>
struct io_uring_params params = {0};
// 配置参数:启用高级特性
params.flags = IORING_SETUP_SQPOLL; // 内核线程轮询 SQ(无系统调用模式)
params.sq_thread_idle = 2000; // 内核轮询线程空闲超时 (ms)
struct io_uring ring;
int ret = io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
// 检查特性支持
if (params.features & IORING_FEAT_SINGLE_MMAP) {
printf("支持单 mmap 模式\n");
}
if (params.features & IORING_FEAT_NODROP) {
printf("CQ 不满时不会丢弃完成事件\n");
}
io_uring_params 重要字段解析:
| 字段 | 含义 | 典型值 |
|---|---|---|
| sq_entries | SQ 条目数 | 256 - 4096 |
| cq_entries | CQ 条目数(通常为 SQ 的 2-4 倍) | 512 - 16384 |
| flags | io_uring 行为标志位 | IORING_SETUP_SQPOLL / IORING_SETUP_IOPOLL |
| sq_thread_cpu | SQPOLL 线程绑定的 CPU 核 | 0-NR_CPUS |
| features | 内核支持的特性位(只读输出) | 运行时检测 |
SQPOLL 模式 vs 传统模式
SQPOLL (Submission Queue Poll) 是 io_uring 最强大的特性之一。它创建一个内核线程(io-wq-worker)专门轮询 SQ,用户填写 SQE 后完全不需要调用 io_uring_enter 就能让内核执行操作。
// 传统模式:用户显式提交
// prepare SQE → io_uring_submit() → 等待 CQE
// SQPOLL 模式:内核自动轮询
// prepare SQE → 等待 CQE(零系统调用!)
// SQPOLL 的取消与休眠
// 当 SQ 中没有待处理任务时,内核线程会休眠 sq_thread_idle 毫秒后停止
// 新任务到来时会唤醒线程
SQPOLL 的代价与权衡:
- ✅ 零系统调用开销(仅首次唤醒时有 syscall)
- ✅ 更低延迟(内核线程预消费)
- ⚠️ 占用一个 CPU 核心(轮询时持续消耗 CPU)
- ⚠️ 需要 root 或 CAP_SYS_NICE 权限
- ⚠️ 不适合偶尔提交任务的应用
三、Submission Queue Entry (SQE) 详解
SQE 是 io_uring 的基本操作单元,64 字节对齐的结构体包含所有操作所需的信息:
struct io_uring_sqe {
__u8 opcode; // 操作码:IORING_OP_READV, IORING_OP_WRITEV 等
__u8 flags; // IOSQE_* 标志
__u16 ioprio; // I/O 优先级(类似 ioprio_set)
__s32 fd; // 目标文件描述符(负数表示固定文件索引)
union { // 操作数据偏移或用户指针
__u64 off;
...
};
union {
__u64 addr; // 缓冲区地址(readv/writev)
...
};
__u32 len; // 字节长度
union {
__kernel_rwf_t rw_flags; // RWF_* 标志
...
};
__u64 user_data; // 用户标识,回传到 CQE
__u16 buf_index; // 注册缓冲区组索引(fixed buffer)
__u16 personality; // 注册 personality 索引
union {
__s32 splice_fd_in;
__u16 file_index; // 固定文件索引
};
__u64 __pad2[2]; // 保留
} __attribute__((packed, aligned(8)));
核心操作码一览:
- IORING_OP_READ / IORING_OP_WRITE — 基本读写
- IORING_OP_READV / IORING_OP_WRITEV — 散布/聚集读写 (readv/writev)
- IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED — 使用注册缓冲区的读写
- IORING_OP_FSYNC — 文件同步
- IORING_OP_SENDMSG / IORING_OP_RECVMSG — 网络消息收发
- IORING_OP_ACCEPT / IORING_OP_CONNECT — TCP 连接操作
- IORING_OP_EPOLL_CTL — epoll 控制操作
- IORING_OP_FALLOCATE / IORING_OP_FADVISE — 文件预分配和建议
- IORING_OP_OPENAT / IORING_OP_CLOSE — 文件打开/关闭
- IORING_OP_PROVIDE_BUFFERS — 提供预注册缓冲区缓冲池
四、Completion Queue Entry (CQE) 与事件处理
当 SQE 执行完成后,内核会在 CQ 中写入 CQE。用户通过检查 CQE 获取操作结果:
struct io_uring_cqe {
__u64 user_data; // 与 SQE 中的 user_data 一致(用于匹配请求/响应)
__s32 res; // 操作结果(类似 syscall 返回值,负数表示错误)
__u32 flags; // CQE 标志(如 IORING_CQE_F_BUFFER, CQE_F_MORE)
};
// 批量等待和收割完成事件
struct io_uring_cqe *cqe;
unsigned head;
int completed = 0;
io_uring_for_each_cqe(&ring, head, cqe) {
// cqe->user_data 帮助识别哪个请求已完成
// cqe->res >= 0 表示成功(返回实际读写字节数)
// cqe->res < 0 表示错误(如 -EAGAIN, -EINVAL)
handle_completion(cqe->user_data, cqe->res);
completed++;
}
// 批量提交完成确认(一次性标记已消费)
io_uring_cq_advance(&ring, completed);
// 阻塞等待(最少等待 min_wait 个事件)
int ret = io_uring_wait_cqe(&ring, &cqe);
CQE flags 标志位解读:
- IORING_CQE_F_BUFFER — 此 CQE 使用了 provide buffer 机制(buf 包含实际缓冲区索引)
- IORING_CQE_F_MORE — 后续还有更多相关 CQE(链式通知)
- IORING_CQE_F_NOTIF — 这是 send_zc 的完成通知,数据已真正写入对端
- IORING_CQE_F_BUF_MORE — 缓冲区中还有更多数据(用于流式接收)
五、高级优化技术
5.1 注册缓冲区 (Registered Buffers / Fixed Buffers)
每次 io_uring 操作都需要在内核中 pin 住用户内存页,这是固定开销。通过预注册缓冲区,可以消除每次操作的开销:
// 1. 准备缓冲区
char buffer[BUFFERS_COUNT][BUFFER_SIZE];
int ret = io_uring_register_buffers(&ring, iovecs, BUFFERS_COUNT);
// 2. 在 SQE 中使用固定缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read_fixed(sqe, fd, buffer[idx], len, offset, buf_idx);
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_BUFFER_SELECT;
// 结果:零内存 pin/unpin 开销
性能影响:注册缓冲区可以将单次操作开销降低约 1-2μs,在高频率小 I/O 场景下提升显著。
5.2 固定文件 (Registered Files)
类似注册缓冲区,预注册文件描述符可以避免每次操作的 fd 查找:
// 预注册常用文件
int fds[] = {file_fd_1, file_fd_2, eventfd, epollfd};
io_uring_register_files(&ring, fds, 4);
// 在 SQE 中使用文件索引(索引 = fds 数组下标)
sqe->fd = 0; // 直接用索引,不是真正的 fd
sqe->flags |= IOSQE_FIXED_FILE;
// 优点:消除 fdtable 查找、引用计数管理
5.3 Linked SQE(链接操作链)
通过 IOSQE_IO_LINK 标志可以将多个 SQE 串联为原子操作链:
// 链接操作:先 read → 然后 write(前一个完成后才执行下一个)
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe1, fd_src, &iov_src, 1, src_offset);
sqe1->user_data = OP_READ;
struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe2, fd_dst, &iov_dst, 1, dst_offset);
sqe2->user_data = OP_WRITE;
// IOSQE_IO_LINK 链接两个操作
sqe1->flags |= IOSQE_IO_LINK;
// 如果 read 失败,write 会自动失败为 -ECANCELED
sqe2->flags |= IOSQE_IO_HARDLINK; // 即使前一个失败也执行
io_uring_submit(&ring);
5.4 SQE 批处理与 Drain
通过 IOSQE_IO_DRAIN 标志可以将之前提交的所有操作与后续操作隔离:
// 场景:必须等所有Pending I/O 完成后才能执行后续操作
sqe_drain->opcode = IORING_OP_NOP; // NOP 作为 drain barrier
sqe_drain->flags |= IOSQE_IO_DRAIN;
// 提交 drain 后,drain 操作及其后的操作必须等待之前所有操作完成
六、io_uring 与 epoll 的关系
很多人问:「io_uring 会取代 epoll 吗?」答案是:io_uring 不是替代品,而是互补层。
// io_uring 内部使用 eventfd + epoll 来管理事件通知
// 常见集成模式:
// 模式1: io_uring 提交 + epoll_wait 等待
int ret = io_uring_submit_and_wait(&ring, 1);
// 类似 submit + wait_cqe,但底层可以用 eventfd + epoll 通知
// 模式2: io_uring 替代 accept + read/write 循环
// 传统 epoll server: epoll_wait → accept → recv/send
// io_uring server: 批量提交 ACCEPT → RECV → SEND 链接操作链
io_uring 在网络 I/O 的优势:
- 批量操作:不是一次 accept 一个连接,而是先提交 N 个 ACCEPT SQE
- 零拷贝网络:IORING_OP_SEND_ZC 支持真正零拷贝发送
- 超时控制:IORING_OP_TIMEOUT SQE 可以为等待设置精确超时
- 连接池管理:注册 eventfd、注册 fd,全异步生命周期
七、高性能 Web Server 实战案例
一个基于 io_uring 的最小化 HTTP 服务器骨架,展示如何构建生产级服务:
#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define BACKLOG 4096
#define BUF_SIZE 4096
#define MAX_CONNECTIONS 16384
struct conn_info {
int fd;
unsigned type; // CONN_TYPE_ACCEPT / CONN_TYPE_READ / CONN_TYPE_WRITE
unsigned buflen;
char buf[BUF_SIZE];
};
enum { CONN_TYPE_ACCEPT, CONN_TYPE_READ, CONN_TYPE_WRITE };
struct io_uring ring;
void server_loop(int listen_fd) {
io_uring_queue_init(16384, &ring, IORING_SETUP_SQPOLL | IORING_SETUP_COOP_TASKRUN);
// 1. 先提交一个 ACCEPT 操作
submit_accept(listen_fd);
while (1) {
// 2. 等待完成事件
struct io_uring_cqe *cqe;
io_uring_submit_and_wait(&ring, 1);
io_uring_peek_cqe(&ring, &cqe);
// 3. 分发完成事件
struct conn_info *info = (void*)(uintptr_t)cqe->user_data;
switch (info->type) {
case CONN_TYPE_ACCEPT:
// 新连接到达
submit_accept(listen_fd); // 重新提交 ACCEPT
submit_read(new_conn_fd); // 读取客户端请求
break;
case CONN_TYPE_READ:
// HTTP 请求读取完成
if (cqe->res > 0) {
// 解析请求并生成响应
submit_write(info->fd, response_buf, response_len);
} else {
close_connection(info->fd);
}
break;
case CONN_TYPE_WRITE:
// 响应发送完成
submit_read(info->fd); // 读取下一个请求(keep-alive)
break;
}
io_uring_cqe_seen(&ring, cqe);
}
}
void submit_accept(int listen_fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_info *info = malloc(sizeof(*info));
info->fd = 0;
info->type = CONN_TYPE_ACCEPT;
// 预分配新连接的 conn_info
struct conn_info *new_info = malloc(sizeof(*new_info));
new_info->fd = 0;
new_info->type = CONN_TYPE_READ;
io_uring_prep_accept(sqe, listen_fd, &new_info->peer_addr,
&new_info->peer_len, 0);
sqe->user_data = (uint64_t)info; // 完成事件关联到 accept info
}
void submit_read(int fd) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_info *info = get_conn_info(fd);
info->type = CONN_TYPE_READ;
io_uring_prep_recv(sqe, fd, info->buf, BUF_SIZE, 0);
sqe->user_data = (uint64_t)info;
}
void submit_write(int fd, const void *buf, unsigned len) {
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
struct conn_info *info = get_conn_info(fd);
info->type = CONN_TYPE_WRITE;
memcpy(info->buf, buf, len);
info->buflen = len;
io_uring_prep_send(sqe, fd, buf, len, 0);
sqe->user_data = (uint64_t)info;
}
八、Provide Buffers 机制(类似 Linux 的 mmap 网络接收)
Provide Buffers 是 io_uring 的高级特性,允许用户预分配缓冲区池,内核在收到数据时自动从池中取出一个缓冲区使用,避免传统 recv() 中数据被截断或需要多次调用的问题:
// 1. 注册 Provide Buffers 池
struct iovec iovecs[BUFFERS_COUNT];
for (int i = 0; i < BUFFERS_COUNT; i++) {
iovecs[i].iov_base = aligned_alloc(4096, BUFFER_SIZE);
iovecs[i].iov_len = BUFFER_SIZE;
}
struct io_uring_provide_buf pb = {
.addr = (uint64_t)iovecs[idx].iov_base,
.len = BUFFER_SIZE,
.bid = idx, // buffer ID
};
io_uring_prep_provide_buffers(sqe, pb.addr, pb.len, 1, bgid, idx);
// 2. 在网络接收 SQE 中启用缓冲区选择
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = bgid; // 指定缓冲区组(buffer group ID)
// 3. 完成后从 CQE 获取实际使用的缓冲区
if (cqe->flags & IORING_CQE_F_BUFFER) {
int buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
// iovecs[buf_id] 中就是接收到的数据
// cqe->res 表示实际接收的字节数
}
优势:
- 避免两次 recv 调用(先 peek 获取大小,再读数据)
- 支持自动选择空闲缓冲区
- 适合高并发 Web 代理和反向代理场景
九、Send-ZC:真正的零拷贝网络发送
Linux 6.1+ 引入的 send_zc 是 io_uring 中一个里程碑式特性,它允许用户数据在不经过内核缓冲区的情况下直接发送到网络,实现真正的零拷贝:
// send_zc 骨架代码
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, fd, data_ptr, data_len, 0, IOSQE_ASYNC);
sqe->user_data = (uint64_t)op_ctx;
io_uring_submit(&ring);
// 关键:send_zc 产生两个 CQE
// 1. 快速通知:IO 已提交(写回 CQE res = data_len)
// 2. 真正完成:数据已离开内核缓冲区写入网卡
// 通过 CQE flags == IO_CQE_F_NOTIF 区分"释放缓冲区"的 CQE
// 收到 NOTIF CQE 之后才能重用用户缓冲区
send_zc 的缓冲区管理:
- 提交 send_zc 后用户缓冲区仍然被占用
- 收到 faster CQE(完成通知)后不能立即重用
- 只有当 NOTIF CQE 到来(标志位 CQE_F_NOTIF)时,缓冲区才被释放
- 这个设计确保了即使 kernel sendfile 的错误回退缓冲区复用场景下也安全
十、io_uring 性能基准测试
在典型的硬件环境下(NVMe SSD, 10GbE 网卡, Linux 6.2),io_uring 与各种 I/O 模型的性能对比:
| 模型 | 随机读(IOPS) | 延迟(p99) | CPU占用 |
|---|---|---|---|
| 同步 read() | ~150K | ~120μs | 高 |
| libaio (O_DIRECT) | ~280K | ~45μs | 中 |
| io_uring (basic) | ~350K | ~30μs | 中 |
| io_uring (fixed buf + SQPOLL) | ~420K | ~8μs | 低 |
| io_uring (iopOLL polled) | ~550K | ~3μs | 最低 |
关键发现:
- io_uring + SQPOLL 在 IOPS 上比 libaio 提升 ~50%
- 固定缓冲区可额外降低约 30% 的延迟方差
- IOPOLL 模式(轮询完成而非中断)可将 p99 延迟降至个位数μs
- io_uring 网络场景下(sendmsg/recvmsg),连接密集型 workload 可减少 40% 系统调用次数
十一、工程最佳实践
11.1 错误处理与资源管理
io_uring 编程容易出现资源泄漏问题(SQE 未释放、CQE 未 seen 等):
// ❌ 最常见的错误:忘记 io_uring_cqe_seen
while (1) {
io_uring_wait_cqe(&ring, &cqe);
process(cqe); // 处理完成事件
// 忘记 io_uring_cqe_seen → CQ 阻塞 → 后续 CQE 不会被写入
}
// ✅ 正确做法:批量消费 CQE 后 cq_advance
unsigned head;
io_uring_for_each_cqe(&ring, head, cqe) {
process(cqe);
}
io_uring_cq_advance(&ring, completed_count);
// cq_advance 安全地通知内核 CQ slot 已释放
11.2 使用 eventfd 与外部事件循环集成
当 io_uring 与 select/poll/select 等共存时,需要 eventfd 桥接:
// 注册 eventfd 用于 io_uring 通知
int efd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
io_uring_register_eventfd(&ring, efd);
// 在其他 select/poll/epoll 循环中监听 efd
struct epoll_event ev = { .events = EPOLLIN, .data.fd = efd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, efd, &ev);
// 当 io_uring 有完成事件时,efd 变为可读
// 读取 efd 后就绪后调用 io_uring_peek_cqe 检查实际事件
11.3 内存屏障注意事项
io_uring 依赖弱序内存模型,不正确的数据可见性会导致微妙 bug:
// Shop: 必须写入 SQE 数据 → 写屏障 → 更新 SQ tail
sqe->opcode = IORING_OP_READ;
sqe->fd = fd;
sqe->off = offset;
sqe->addr = (uint64_t)buf;
sqe->len = len;
sqe->user_data = id;
// 写 barrier 确保数据可见
smp_wbarrier();
*sqring->tail = sqring->tail_shadow + 1;
// 然后 io_uring_enter 或 wait 触发内核处理
11.4 任务分发架构
在生产环境,推荐使用「io_uring + worker pool」模式替代纯轮询服务器:
// 架构: 主线程(io_uring) → 工作线程(epoll) → 工作队列
//
// 主线程:
// - 负责 io_uring 初始化、网络 accept、批量收发
// - 将收到的请求推入无锁 mpsc 队列
//
// 工作线程:
// - 从 mpsc 队列取任务
// - 执行业务逻辑(DB 查询、模板渲染、文件读写)
// - 将响应通过 io_uring 或 eventfd 通知主线程发送
//
// 分离关注点:IO 密集型操作走 io_uring,计算密集型操作走工作线程池
十二、总结与展望
io_uring 是 Linux 内核在异步 I/O 领域二十年来最重要的设计革新,它解决了 libaio 长期存在的痛点,引入了共享内存环形队列的新范式。掌握 io_uring 不仅需要理解其 API,更要理解其核心设计理念——将内核与用户空间的协作模式从「请求-响应」转化为「生产-消费」。
展望未来:
- io_uring 将继续扩展操作类型:文件监视 (inotify/fanotify)、设备控制 等都已在开发中
- 与 eBPF 深度集成:io_uring 操作可以在 BPF 程序中发起,实现可编程 I/O 流水线
- 跨机器 io_uring:分布式存储系统中 io_uring 操作跨节点的协议正在探索中
- 语言绑定生态成熟:tokio-uring (Rust)、python-uring 等高级语言封装日益完善
对于任何需要极致 I/O 性能的 Linux 应用——无论是 Web 服务器、数据库、消息队列还是存储系统——掌握 io_uring 已经从「锦上添花」变成了「必备技能」。理解它,用它,你应用的 IOPS 上限将重新定义。

发表评论 取消回复