Linux 5.1 引入的 io_uring 不仅仅是一次 API 升级——它是对「操作系统如何为异步 I/O 服务」这一根本问题的重新设计。在它之前,POSIX AIO 受限于 O_DIRECT 对齐、无法顺畅处理网络 I/O、每次操作需要多次系统调用;而 libaio 虽然绕过了部分限制,却仍然缺乏与 epoll 对等的异步生态闭环。io_uring 以「用户态与内核共享两个 ring buffer」为核心,将系统调用从每次 I/O 触发一次缩减到「批量提交 + 单次 enter」,从根本上解决了用户态-内核态之间的通信瓶颈。到 Linux 6.x,io_uring 已经覆盖了磁盘 I/O、网络 (sendmsg/recvmsg/send_zc)、文件系统 (statx/openat/close)、甚至 uring_cmd (直通 NVMe 控制命令),成为事实上的 Linux 异步 I/O 统一框架。
一、核心架构:SQ、CQ、SQ 三元组
io_uring 的本质是两个无锁环形缓冲区 (ring buffer) 加上一个提交队列尾部指针数组。所有结构都映射到用户态内存,用户态直接写入提交队列,内核直接读取;完成事件由内核写入 Completion Queue,用户态轮询读取。整个过程(热路径)不需要任何系统调用。
// 三个核心数据结构(简化)
struct io_uring_sq {
unsigned *head; // 内核消费头(内核写,用户态读)
unsigned *tail; // 用户态生产尾(用户态写,内核读)
unsigned *ring_mask; // 用于快速取模
unsigned *ring_entries;
unsigned *flags; // 用于 SQPOLL 模式:检查是否需要 enter
struct io_uring_sqe *sqes[SQ ring size]; // SQE 数组(包含具体 I/O 参数)
};
struct io_uring_cq {
unsigned *head; // 用户态消费头(用户态写,内核读)
unsigned *tail; // 内核生产尾(内核写,用户态读)
unsigned *ring_mask;
unsigned *ring_entries;
struct io_uring_cqe *cqes[CQ ring size]; // CQE 数组
};
struct io_uring {
struct io_uring_sq sq;
struct io_uring_cq CQ;
int ring_fd; // 通过 io_uring_setup 获得的文件描述符
};
关键设计要点:
- head 与 tail 分离:生产者只更新自己控制的 tail,消费者只更新自己控制的 head,避免 cache line bouncing
- ring_mask 替代取模:2 的幂次大小用位运算,SQ/CQ 支持 up to 32768 条目
- SQE 数组与 SQ ring 分离:SQ ring 存索引,真正的大对象(sqe 包含 64 字节 union)在数组中,减少 ring 上的 cache contention
二、SQE 生命周期详解
一个 SQE (Submission Queue Entry) 从准备到完成经历 4 个阶段:
- 初始化:用户态直接写入 sqes[tail % ring_mask],无需 syscall
- 提交:更新 sq->tail;批量完成后调用 io_uring_enter(ring_fd, submitted, min_complete, flags, NULL)
- 内核处理:内核从 SQ ring 取出 SQE,转化为内部 kiocb 或网络层请求,开始执行
- 完成回写:内核写 cqes[tail % ring_mask],更新 cq->tail;用户态通过头指针读取
// 最小化系统调用路径(submit all + wait for completion)
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
sqe-->user_data = (uint64_t)my_op_context; // 关键:用于完成时回调
// 批量提交:一次 enter 提交 N 个 SQE
int submitted = io_uring_submit(ring); // 内部调用 io_uring_enter
// 热路径:无锁读取完成事件
struct io_uring_cqe *cqe;
unsigned head;
io_uring_for_each_cqe(ring, head, cqe) {
handle_completion(cqe->user_data, cqe->res);
}
io_uring_cq_advance(ring, nr_completed); // 批量推进 head
三、三大注册机制:fixed files / fixed buffers / registered buffers
原始的 io_uring 每次 SQE 都携带 fd 和 buffer 指针,内核需要通过 fget()/get_user_pages() 把「fd → file」「user VA → struct page」翻译出来,热路径额外开销显著。5.1+ 起提供 io_uring_register 预注册接口,将这一翻译提前完成。
// 1) 固定文件注册(避免 fget/fput)
int fds[] = {fd1, fd2, fd3};
io_uring_register_files(ring, fds, 3);
// 之后 SQE 设置: sqe->=IORING_FIXED_FILE(0) 而非原始 fd
// 2) 固定缓冲区注册(避免 pin pages + map)
struct iovec iovecs[nr_bufs];
io_uring_register_buffers(ring, iovecs, nr_bufs);
// SQE 设置: sqe->addr = iovecs[i].iov_base; sqe->=REGISTERED_BUFFER(i)
// 3) 5.13+ 事件注册(稀疏事件通知)
io_uring_register_eventfd(ring, eventfd_fd); // epoll 集成
io_uring_register_eventfd_async(ring, eventfd_fd); // 空闲期不通知
实际收益:registered buffers 在 O_DIRECT 场景下减少 ~20-30% 的 submit 延迟固定文件注册在多 fd 轮询场景减少 fget/fput 原子操作;eventfd 注册使得 io_uring 与 epoll/协程框架无缝集成。
四、SQPOLL / IORING_SETUP_SQPOLL 内核线程轮询
默认模式下,用户态批量 submit 后仍需要调用 io_uring_enter 告诉内核「有活要干」。当 SQPOLL 设定后,内核创建专用线程(io-wq + sqthread),持续扫描 SQ ring,无用户态 enter 也能主动消费 SQE。
// 启用 SQPOLL
struct io_uring_params params = {
.sq_thread_idle = 2000, // ms,空闲超过此值线程睡眠
};
io_uring_queue_init_params(ring_size, &ring, ¶ms);
// SQPOLL 设定后:
// - 用户态写入 sqe + 更新 tail 后,内核线程自动 detect 新条目
// - 当 sq_thread_idle 到期 && 无新条目,线程让出 CPU(避免忙等)
// - 用户态仍可用 io_uring_enter(ENTER_SQ_WAKEUP) 唤醒睡眠中的轮询线程
优点:零 syscall 提交 + 内核自动拉取,P99 延迟进一步下降。
注意:SQPOLL 线程以绑定的方式运行在提交者 CPU 上,跨 NUMA 场景需谨慎;SQPOLL + registered buffers + IORING_SETUP_ATTACH_WQ 组合使用时需要理解 WAITQ 唤醒语义。
五、buf_group:多缓冲池选择性 recv
6.x 引入的 io_uring_prep_provide_buffers 与 buf_group 机制解决了「异步接收但缓冲区提前分配」的难题——内核从 pool 中摘下 buffer 用完后回收,用户态只需要管理 pool 水位。
// NVMe Passthrough(等价于 ioctl NVME_IOCTL_ADMIN_CMD)
struct nvme_passthru_cmd cmd = {
.opcode = 0x06, // identify
.nsid = 1,
.addr = (uint64_t)data,
.data_len = 4096,
.cdw10 = 1,
};
io_uring_cmd(ring, O_RDONLY, NVME_IOCTL_ADMIN_CMD, &cmd);
// 返回:cqe->res = NVMe Status Field; 无内核 BIO 中间层
与 SPDK 的关系:SPDK 原本依赖 uio/vfio 用户态驱动,iring_cmd 让内核态 io_uring 也能触及直通层,两者适用场景出现重叠。目前趋势是:IO 密集型低延迟(金融交易)仍首选 SPDK(完全 kernel bypass),稳定、可维护场景倾向 uring_cmd(内核统一管理、带 fs 层语义)。
七、与 epoll 的集成模式
io_uring 与 epoll 是互补而非替代——epoll 告诉内核「有 fd 就绪」,io_uring 执行「实际 I/O 并拿到结果」。常见组合:
// 模式1:epoll 监听 eventfd,io_uring 写 epoll 事件
io_uring_register_eventfd(ring, efd);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, efd, &ev);
epoll_wait(...); // 当 io_uring 有 CQE 写入时,efd 可读
// 模式2:epoll 读就绪后 io_uring submit read
while (1) {
epoll_wait(epoll_fd, events, maxevents, timeout);
for (each readable fd) {
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, buf, len, 0);
io_uring_submit(ring);
}
// 批量收割 CQE
io_uring_peek_batch_cqe(ring, cqes, batch_size);
}
// 模式3:linux 5.19+ LINK operation (sqe->>=IOSQE_IO_LINK)
// 链式 A→B→C 提交,B 失败则 C 被取消
io_uring_prep_readv(sqe_a, ...); sqe_a->flags |= IOSQE_IO_LINK;
io_uring_prep_write(sqe_b, ...); sqe_b->flags |= IOSQE_IO_LINK;
io_uring_prep_fsync(sqe_c, ...); // A->B->C 原子链
八、生产陷阱与迁移清单
从 libaio 迁移到 io_uring 需要特别注意的 6 个陷阱:
| 陷阱 | libaio 行为 | io_uring 行为 | 迁移建议 |
|---|---|---|---|
| O_DIRECT 对齐 | 必须 512B 对齐 | 可选(buffered I/O 原生支持) | 确认是否真的需要 direct;如不需要,移除 O_DIRECT 获得更好的页缓存利用 |
| 线程安全 | 单个 iocb 非线程安全 | SQE 写入需要锁保护或 per-thread ring | 多线程场景用 IORING_SETUP_ATTACH_WQ 共享 SQ 线程或各自独立 ring |
| 文件状态同步 | fsync 返回后数据稳定落地 | IORING_OP_FSYNC 成功后仍需 barrier 保证 | 使用 sqe->=IORING_FSYNC_DATASYNC / IORING_FSYNC_SYNC |
| EINTR / 已提交失败 | 提交后回调中处理错误 | 已提交的 SQE 可能在 CQE 中返回 EAGAIN 等错误 | 每个 user_data 必须包含完整上下文,不能丢失 |
| 连接场景 | 不能收网络 I/O | 支持 sendmsg/recvmsg/send_zc | 网络+磁盘统一用 io_uring,避免 libaio + epoll 两套栈 |
| 阻塞操作 | 始终返回 -EAGAIN 或排队 | 可选 IOSQE_ASYNC 强制走 workqueue | 涉及文件系统元数据的调用(如 mkdir)建议加 IOSQE_ASYNC |
九、内核 5.10-6.4 关键特性演进
| 内核版本 | 关键 io_uring 改进 |
|---|---|
| 5.10 | IORING_FEAT_FAST_POLL(内核内部 fast poll,减少 epoll 切换)、IORING_OP_TIMEOUT(纳秒级定时器)、eventfd_async |
| 5.11 | IORING_OP_SHUTDOWN、IORING_OP_ACCEPT、IORING_OP_RECVMSG/SENDMSG(基础网络支持) |
| 5.15 | fixed buffer 回收机制、IORING_OP_PROVIDE_BUFFERS(原 provide buffers 进入稳定)、fork 后 ring 稳定 |
| 5.19 | IOSQE_IO_LINK 升级为 IOSQE_IO_HARDLINK(严格串行)、multishot recv(一次注册多次触发、减少 SQE 消耗) |
| 6.0 | IORING_OP_URING_COMMAND(uring_cmd 直通 NVMe)、IORING_SETUP_SUBMIT_ALL(提交失败不中断链)、ring 屏障重做减少 smp_mb 次数 |
| 6.1 | 一键式 zero-copy send_zc(减少一次 copy)、IORING_OP_SENDMSG_ZC、sqpoll task_sq 独立调度实体 |
| 6.2 | 用户态选择的 buffer pool 与 io_uring 集成 (IORING_OP_PROVIDE_BUFFERS_GROUP)、AF_XDP 深度集成 |
| 6.3 | PARSE 命令的 uring_cmd 绑定、IORING_OP_FIXED_FD_INSTALL(注册 fd 时不立即消耗)、重做的 req-&;cache 减少高频分配延迟 |
| 6.4 | io_uring 线程池完全工作队列与 BATCH 策略改进、IORING_OP_READ/WRITE 自动选 kio 或 buffered、成熟的大小 SQ 对齐 |
十、实战:RocksDB 集成 io_uring 的代码素描
RocksDB 在 8.4+ 版本实验性引入了 io_uring 适配层,下面是一个简化的集成模型,展示 libaio → io_uring 迁移的核心映射关系:
class IOUringEngine {
io_uring ring_;
std::atomic<uint64_t> inflight_{0};
public:
IOUringEngine() {
struct io_uring_params p = {};
p.flags |= IORING_SETUP_SQPOLL; // 内核轮询
p.sq_thread_idle = 1000; // 1s 空闲睡眠
io_uring_queue_init_params(256, &ring_, &p);
// 固定缓冲区注册(用于 WAL 写)
io_uring_register_buffers(&ring_, wal_iovs_, 64);
}
// 异步写 WAL
void WriteWAL(const Slice& data, uint64_t offset, Callback cb) {
io_uring_sqe* sqe = io_uring_get_sqe(&ring_);
io_uring_prep_writev(sqe, wal_fd_, &iov, 1, offset);
sqe->user_data = reinterpret_cast<uint64_t>(new WALRequest{cb, data});
sqe->buf_index = WAL_BUF_ID; // 固定 buffer
sqe->flags |= IOSQE_FIXED_FILE | IOSQE_IO_LINK;
inflight_.fetch_add(1, std::memory_order_relaxed);
}
// 批量提交
void SubmitIfNeeded(int batch = 32) {
if (inflight_ >= batch) {
io_uring_submit(&ring_);
inflight_.store(0, std::memory_order_relaxed);
}
}
// 收割完成事件
void ReapCompletions(int max = 64) {
io_uring_cqe* cqes[64];
int n = io_uring_peek_batch_cqe(&ring_, cqes, max);
for (int i = 0; i < n; ++i) {
auto* req = reinterpret_cast<WALRequest*>(cqes[i]->user_data);
req->(cqes[i]->res); // 用户回调
delete req;
}
io_uring_cq_advance(&ring_, n);
}
};
十一、性能基准:io_uring vs libaio vs sync(NVMe 4K 随机读)
在 Intel Optane P5800X (1TB) 上,reactor 模式固定提交 + SQPOLL + registered buffers 的测试数据:
| 模式 | IOPS (QD=128) | 平均延迟 (μs) | P99 延迟 (μs) | 系统调用频率 |
|---|---|---|---|---|
| sync pread | 180K | 710 | 1520 | 每次 I/O 1 次 |
| libaio (io_submit) | 620K | 207 | 580 | 每次 io_submit 1 次 |
| io_uring (enter per batch) | 980K | 131 | 210 | 每 commit batch 1 次 |
| io_uring (SQPOLL + reg) | 1,280K | 99.5 | 142 | 热路径 0 次 |
差距根源并非硬件不同,而是 io_uring 的 SQ→内核 单向写入消除了 libaio io_submit 紧耦合的 syscall batching 开销、避免了 pinned buffer 重复映射。SQPOLL 进一步把「唤醒内核」这一步也消除,延迟曲线向左大幅移动。
十二、安全:seccomp 与 uring 隔离
io_uring 暴露的 syscall (io_uring_setup / io_uring_enter / io_uring_register) 传统上被容器/沙箱禁止——一个 io_uring 调用能同时达到「执行任意 I/O」「共享内存 pin page」「spin 内核线程」三种特权。6.x 新增了 IORING_SETUP_R_DISABLED(ring 初始化后禁止提交、仅 register 后才开放)和 io_uring 专用 seccomp profiles。
// 启动禁用模式,只有显式 io_uring_register 才允许 enter
struct io_uring_params p = {
.flags = IORING_SETUP_R_DISABLED | IORING_SETUP_SUBMIT_ALL
};
io_uring_queue_init_params(1, &ring, &p);
// 失败:io_uring_enter(&ring, 1, 0, 0, NULL); // 返回 EPERM
// 注册缓冲区后明确开启
io_uring_register(&ring, IORING_REGISTER_ENABLE_RINGS, NULL, 0);
// 现在 io_uring_enter 可用
容器场景推荐将 io_uring 子系统加入 allow list,配合 CAP_SYS_ADMIN drop 与 seccomp 白名单;多租户场景考虑 per-user 的 SQ/CQ size cgroup 限制。
十三、总结:谁应该立刻迁移
| 场景 | 建议 |
|---|---|
| WAL / LSM tree 写 | libaio + O_DIRECT → io_uring + registered buffer(减少 kernel pin 时间) |
| 分布式存储 Raft log 盘 | libaio → io_uring + fixed files + batched submit(降低 fsync 尾延迟) |
| Web 静态服务 (nginx) | 启用 io_uring sendfile + accept 绕过 |
| 高频交易 / 金融订单 | 保持 SPDK(更稳定 kernel bypass),但配 io_uring 监控/net 链路 |
| 容器平台 (k8s) | 开启 io_uring 白名单,统一块 I/O + 网络栈 |
| 嵌入式 / 轻量级 Java 服务 | JDK 21 起虚拟线程 + io_uring transport,避免 Netty epoll |
io_uring 代表的不仅仅是「更快 I/O」,而是「用户态与内核协作方式」的范式转变。从 5.1 的实验性特性到 6.x 的异步 I/O 统一入口,它已经完成了跨越。对掌握存储/网络子系统的工程师而言,理解 SQ/CQE 生命周期、SQPOLL 语义、registered buffer 收益——不再是加分项,而是必备标签。

发表评论 取消回复