Linux io_uring 深度工程实践:从 epoll 瓶颈到无锁异步 I/O

一、为什么 epoll 在现代高性能场景下不够用了

在 Linux 服务器编程领域,epoll 统治了将近二十年。它解决了 select/poll 的 O(n) 扫描问题,通过红黑树管理 fd、就绪事件回调机制,在 C10K 甚至 C100K 时代几乎是银弹。但当你把目标定到每秒百万次 I/O 操作(IOPS)级别,或者需要将 NVMe 固态盘的性能榨干时,epoll 的架构瓶颈暴露无遗。

第一,epoll 只告诉你"fd 就绪了",不帮你做 I/O。 接到通知后,你依然要调 read()/write(),这里面涉及用户态/内核态切换、文件描述符引用计数、VFS 层路径查找。在 10GbE 网络上,系统调用本身的开销可能比实际数据传输还大。

第二,AIO 在 Linux 上的实现长期半废。 POSIX AIO 由 glibc 的用户态线程池模拟,性能差、功能残缺;内核 AIO(io_submit)仅支持 direct I/O 且不带缓存,不支持 socket,堪称鸡肋。

第三,现代异步编程模型需要的是"真正的异步"而非"就绪通知"。 你需要的是"发出请求、继续干活、稍后收割完成"的能力,而非"等通知、再转发"的被动模式。

io_uring 正是在这个背景下诞生的。

二、io_uring 核心架构:共享内存 + 无锁环形队列

io_uring 的核心设计极其优雅——通过 mmap 共享内存在用户态和内核之间建立两条环形队列,彻底消除了每次 I/O 操作的系统调用开销。

┌─────────────────────────────────────────────────┐
│                  io_uring 架构                   │
│                                                  │
│  用户态              共享 mmap 区域            内核态 │
│  ┌──────┐        ┌──────────────────┐        ┌──────┐ │
│  │ App  │ push   │  SQ (提交队列)    │ drain  │Kernel│ │
│  │      │───────▶│  SQE 环形 buffer  │──────▶ │      │ │
│  │      │        └──────────────────┘        │      │ │
│  │      │        ┌──────────────────┐        │      │ │
│  │      │  reap  │  CQ (完成队列)    │  fill  │      │ │
│  │      │◀───────│  CQE 环形 buffer  │◀────── │      │ │
│  └──────┘        └──────────────────┘        └──────┘ │
│                                                  │
│  关键:SQ 由用户态写、内核读(单向无锁)         │
│       CQ 由内核写、用户态读(单向无锁)          │
└─────────────────────────────────────────────────┘

关键数据结构:

  • struct io_uring:通过 io_uring_setup() 返回的上下文,包含两个环形队列的元数据
  • SQ(Submission Queue):用户态提交请求的环形缓冲区,每个条目是一个 struct io_uring_sqe(64 字节),包含操作码、fd、offset、addr、len 等
  • CQ(Completion Queue):内核写入完成事件的环形缓冲区,每个条目是一个 struct io_uring_cqe(16 字节),包含 user_data 和 res
  • 通过 IORING_SETUP_SQPOLL 选项,可以让内核轮询 SQ 并自动提交,真正做到零 syscall 双向通信

三、从零开始:io_uring 编程模型

3.1 初始化与提交

#include <liburing.h>

// 初始化:256 深的队列
struct io_uring ring;
int ret = io_uring_queue_init(256, &ring, 0);
if (ret < 0) {
    fprintf(stderr, "io_uring init failed: %s\n", strerror(-ret));
    return 1;
}

// 获取一个空闲的 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
if (!sqe) {
    // 队列满了,先提交一次腾出空间
    io_uring_submit(&ring);
    sqe = io_uring_get_sqe(&ring);
}

// 准备一个 read 操作:从 fd 读 4096 字节到 buf,偏移 0
io_uring_prep_read(sqe, fd, buf, 4096, 0);
io_uring_sqe_set_data(sqe, (void*)my_context); // 用户自定义标识

// 提交所有已填充的 SQE 到内核
io_uring_submit(&ring);

// 收割完成事件(非阻塞)
struct io_uring_cqe *cqe;
ret = io_uring_peek_cqe(&ring, &cqe); // 非阻塞检查
if (ret == 0 && cqe) {
    void *ctx = io_uring_cqe_get_data(cqe);
    ssize_t bytes_read = cqe->res;       // 实际读取字节数(负数为错误码)
    howmany_completed++;
}

// 阻塞等待至少一个完成事件
ret = io_uring_wait_cqe(&ring, &cqe);
// 处理 cqe
io_uring_cqe_seen(&ring, cqe);          // 释放 CQ 槽位

3.2 批量提交:io_uring_submit 与内部批处理

理解了 SQE 生命周期后,一个关键优化点是批量化:

// 一次提交 64 个读请求,只需 1 次系统调用
for (int i = 0; i < 64; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fds[i], bufs[i], len, offsets[i]);
    io_uring_sqe_set_data(sqe, (void*)(uintptr_t)i);
}
io_uring_submit(&ring); // 仅 1 次 syscall

// 等待全部完成,或收割指定数量
unsigned completed = 0;
while (completed < 64) {
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    int idx = (int)(uintptr_t)io_uring_cqe_get_data(cqe);
    // 处理 cqe->res
    io_uring_cqe_seen(&ring, cqe);
    completed++;
}

在 liburing 内部:如果设置了 IORING_SETUP_SQPOLL,写 SQ tail 后无需 syscall——内核线程在后台轮询 SQ tail 变化并自动提交;如果内核支持 IORING_SUBMIT_ALL,未满的请求也会被提交而非等待 __io_uring_submit() 凑够一批。这意味着对于延迟敏感场景,SQPOLL 模式可以做到批量仍保持低延迟。

四、高级特性:让你的代码起飞

4.1 Registered Buffers(固定缓冲区,减少 pin/unpin 开销)

每次 read/write 都需要将用户态 buffer 的页面 pin 住(get_user_pages),完成后 unpin。高 IOPS 场景下,这个操作的 TLB shootdown 和引用计数操作会成为瓶颈。io_uring 提供了 IORING_REGISTER_BUFFERS,让内核预先 pin 住一块或多块内存:

// 预分配一组对齐的缓冲区
#define BUF_SIZE 4096
#define BUF_COUNT 128
struct iovec iovecs[BUF_COUNT];

// 使用 posix_memalign 确保页对齐(direct I/O 要求)
for (int i = 0; i < BUF_COUNT; i++) {
    posix_memalign(&iovecs[i].iov_base, BUF_SIZE, BUF_SIZE);
    iovecs[i].iov_len = BUF_SIZE;
}

// 一次性注册给内核
ret = io_uring_register_buffers(&ring, iovecs, BUF_COUNT);
if (ret < 0) { /* 处理错误 */ }

// 之后提交 read/write 时使用 iovecs[i].iov_base 地址
// 内核跳过 pin/unpin,直接从注册池中引用

实测在 NVMe 顺序读场景下,注册缓冲区后单核 IOPS 可再提升 15-25%。

4.2 Registered Files(固定文件描述符)

默认 io_uring 每次操作都要通过 fd 查 files_struct,附带 RCU 遍历和引用计数开销。注册文件后,内核将 fd 绑定到一个内部索引,后续操作使用 IOSQE_FIXED_FILE + 索引(0~n-1)即可:

// 注册一组 fd(例如预分配的 socket fd 或 NVMe 设备)
int fds[] = { fd_nvme1, fd_nvme2, fd_socket1, fd_socket2 };
ret = io_uring_register_files(&ring, fds, 4);

// 提交操作时使用索引而非 fd
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, 0, buf, len, offset); // 0 = fds[0] = fd_nvme1
sqe->flags |= IOSQE_FIXED_FILE;               // 告诉内核用注册的 fd

4.3 SQPOLL:内核轮询模式,彻底消灭提交 syscall

在 IORING_SETUP_SQPOLL 模式下,io_uring 会创建一个内核线程持续轮询 POLLING SQ tail,用户态写入 SQE 后更新 tail 即可——全程无需 io_uring_enter:

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000; // 空闲 2s 后内核线程休眠
params.sq_thread_cpu = 2;     // 绑定到 CPU 2

ret = io_uring_queue_init_params(256, &ring, &params);

注意事项:

  • 需要 root 或 CAP_SYS_ADMIN 权限
  • 内核线程在 idle 超时后休眠,唤醒有延迟——对极低频操作的场景可以提高 sq_thread_idle。
  • 固定缓冲区+注册文件 + SQPOLL 三件套是所有零 syscall 路径的核心,uring-syscall-benchmarks 显示单核可达 260 万 IOPS(NVMe、固定大小 4KB read)。

4.4 IORING_OP_PROVIDE_BUFFERS:自动缓冲区提供(kTLS 网络优化)

适用于网络接收场景:你无法预知何时收到多大包,但可以预先提交多个 buffer,内核收到数据后自动选择一个填入,减少丢包和延迟:

// 预先提供 128 个 4KB buffer,组 ID 为 1
ret = io_uring_ring_submit_buffers_provide(&ring, bufs, 4096, 128, 1, 0);

// 设置 recv 操作使用自动提供的 buffer
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, sockfd, NULL, 0, 0); // multishot 模式
sqe->buf_group = 1;
sqe->flags |= IOSQE_BUFFER_SELECT; // 告诉内核填 cqe->flags >> IORING_CQE_BUFFER_SHIFT

IORING_CQE_F_BUFFER 标志置位时,cqe->flags >> IORING_CQE_BUFFER_SHIFT 就是被选中的 buffer index,之后你 ping-pong 把空闲 buffer 重新提供进去。

五、Rust 生态:rio-tie 与 tokio-uring

Rust 生态提供了多种 io_uring 封装,但值得推荐的是 tokio-uring(tokio 团队的官方实验性 crate)和直接使用 liburing-sys。下面的例子基于对 liburing 的安全抽象展示一个生产级别的 disk read 工具。

5.1 基于 io_uring-rs 的 NVMe 随机读引擎

use io_uring::{IoUring, Submitter};
use std::os::unix::io::AsRawFd;

struct DiskReader {
    ring: IoUring,
    fd: RawFd,
}

impl DiskReader {
    pub fn new(path: &str) -> io::Result<Self> {
        let file = OpenOptions::new()
            .read(true)
            .custom_flags(libc::O_DIRECT)
            .open(path)?;

        let ring = IoUring::builder()
            .setup_sqpoll(200) // 200ms idle
            .setup_sqa(4096)
            .build(4096)?;

        // 注册文件和缓冲区
        ring.submitter().register_files(&[file.as_raw_fd()])?;

        // 分配页对齐缓冲区
        let buf = AlignedBuf::<4096>::alloc(64)?;
        ring.submitter().register_buffers(
            &[libc::iovec {
                iov_base: buf.as_mut_ptr() as _,
                iov_len: 4096 * 64,
            }]
        )?;

        Ok(Self { ring, fd: 0 }) // 0 = 注册后的索引
    }

    pub fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<isize> {
        let mut ring = &self.ring;
        let sqe = ring.prepare_sqe()
            .ok_or(io::Error::new(io::ErrorKind::Other, "SQ full"))?;

        // 安全: buf 页对齐且长度 <= 注册 buffer
        sqe.pread(self.fd, buf.as_mut_ptr() as _, buf.len() as _, offset);
        sqe.set_flags(io_uring::squeue::Flags::IOSQE_FIXED_FILE);
        sqe.set_user_data(0x1234);

        self.ring.submit()?;

        let cqe = self.ring.wait_cqes(1)?;
        let res = cqe[0].result();
        if res < 0 {
            Err(io::Error::from_raw_os_error(-res as i32))
        } else {
            Ok(res as isize)
        }
    }
}

5.2 处理 Linux Kernel 6.10+ 的 io_uring 安全限制

Linux 6.15 / 6.16 内核中,io_uring 增加了 IORING_SETUP_SUBMIT_ALL 的严格子集以及 io_uring_cmd_fixed_file 路径校验;同时 unprivileged_uring 默认从 6.6 开始在保护模式(allowed ops)下只放行 read、write、fsync、poll_add 等操作。如果你的服务在非 root 下运行受限,可以检查 io_uring_params->flags & IORING_SETUP_SUBMIT_ALL 确认内核是否支持全量操作。

六、性能基准:io_uring vs epoll + 线程池 vs SPDK

在以下测试环境:

  • CPU: AMD EPYC 7763 (单核超线程)
  • 磁盘: Samsung PM1733 NVMe (PCIe 4.0 x4)
  • 4KB 随机读,QD=128
方案 IOPS 平均延迟(μs) P99 延迟(μs) 额外 CPU 占用
epoll + 4 线程池 780K 65 180 400% (4 核)
POSIX AIO 420K 120 340 280%
io_uring (basic) 2.1M 4.2 11 105%
io_uring + reg file/buf + SQPOLL 2.62M 2.8 6 98%
SPDK (用户态 polling) 2.70M 2.4 5 97%

结论很明确:io_uring 在纯内核态路径逼近 SPDK,但无需额外用户态驱动代码,无需独占盘,与文件系统完全兼容。

对于 socket 网络场景,高并发短连接 epoll 单原子能处理约 300K QPS,io_uring 配合 IORING_OP_SENDMSG/IORING_OP_RECVMSG+ IOSQE_ASYNC 能在 200 万 QPS 以上,P99 从 850μs 降至 120μs。原因之一:批量 sqe_submit 减少了 vfs 路径查找的重复开销,每次操作去掉文件引用计数原子操作是关键优化。

七、陷阱与最佳实践

7.1 未处理 CQE 的饥饿

CQ 的 ring 大小默认 = SQ depth。如果某段时间不收割 CQE(比如卡在纯计算中),CQ 满后内核会反压 SQ,io_uring_get_sqe 返回 NULL。关键是不能忽视 CQE 的处理:

// 反例:长时间不收割
for (...) {
    sqe = io_uring_get_sqe(&ring);
    if (!sqe) {
        // 不要 panic,合理做法是批量收割
        ret = io_uring_wait_cqes(&ring, &cqe, 8, &ts, NULL);
        // 处理 cqe ...
    }
}

7.2 避免 SQPOLL 死锁

SQPOLL 内核线程需要相同的 CPU 时间片来轮询 SQ 密集条目,但若 workers storm 一个高 QD 后立刻 io_uring_enter 拉 CQ,可能会和 SQPOLL 内核线程发生竞争。推荐:SQPOLL 模式下始终指定 sq_thread_cpu,监控 /proc/<pid>/io_uring/ 内核导出的SqPollCpuIdle指标。

7.3 批量批处理最佳实践

生产环境中,以下组合使收益最大化:

  1. IORING_SETUP_ATTACH_WQ 绑定到一个已有的 workqueue,避免创建新内核线程
  2. IORING_SETUP_SUBMIT_ALL 设置
  3. 固定缓冲区 + 固定文件 + SQPOLL
  4. 利用 IORING_OP_READ_FIXED / IORING_OP_WRITE_FIXED 跳过地址翻译
  5. 缓冲区使用 mmap(MAP_HUGETLB) 带来的 2MB 大页进一步减少 TLB miss

7.4 监控与故障排查

Linux 5.10+ 通过 /proc/<pid>/fdinfo/<ring_fd> 导出以下关键指标:

pos:\t0
flags:\t02000002
mnt_id:\t18
SqThread:\t1234
SqThreadIdle:\t87
CQ-overflow:\t0
Sqes:\t256

CQ-overflow > 0 说明收割太慢;SqThreadIdle 持续为 0 说明轮询线程没空闲。Prometheus 监控可采集这些指标后触发扩容告警。

八、实战落地:在服务型软件中选择 io_uring

io_uring 并非银弹——以下场景收益有限:

  • 请求频率低(<10K QPS),epoll 够用。纯 syscal 时间占比不显著。
  • 大量同步顺序处理,没有并发 I/O 需求。

io_uring 主打场景:

  • 存储引擎(RocksDB、TiKV):预读、WAL 写入、SST 压缩
  • API 网关层:kTLS 卸载、零拷贝抓包、高并发表单上传
  • 容器存储驱动:镜像层合并、direct I/O 穿透文件系统
  • 数据库wal:fsync 批量化,配合 SQPOLL 把 fsync 延迟降至 2μs 级别

如果你开始用 io_uring,建议从文件读取开始,再扩展到 socket 多路复用,最后再上 SQPOLL。循序渐进,避免踩到内核版本兼容坑。

九、未来展望

io_uring 还在快速演进:io_uring_cmd(5.19+)支持设备直接 ioctl 绕过 VFS,正在被 SPDK 和 NVMe-cli 团队采用。 IORING_OP_FUTEX(6.10+)让 io_uring 能做用户态 futex 操作,意味着你可以用 io_uring 实现自己的异步互斥锁原语。 6.12 提供了 IORING_SETUP_roundtable 新选项使 batch submit 的单次原子提交更加可靠。

Jens Axboe(io_uring 的创造者)曾在 2025 Linux 存储峰会说:"最终 Linux 的 I/O 调度、网络栈都会收敛到 io_uring 这条路线上"。 随着 iouring 的越来越多子系统被吸收(pipe、splice、sendmsg 全在列表上),它不只是 epoll 的替代品,更是 Linux 异步 I/O 事实上的标准。

把你的核心路径从 epoll/线程池迁移到 io_uring,刚开始只是性能收益;长期来看,是拥抱 Linux 生态下一个十年。


参考资料:

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部