传统异步 I/O 的历史困境

Linux 的异步 I/O(AIO)自内核 2.5 时代引入以来,始终处于一个尴尬的境地。POSIX AIO 由 glibc 在用户空间通过线程池实现,看似异步实则同步阻塞,性能堪忧。而内核原生 AIO(io_submit/io_getevents)虽然真正实现了内核级异步,但限制重重:仅支持 O_DIRECT 直 I/O(绕过页缓存)、不支持 socket 网络操作、每次操作需要系统调用、且要求 I/O 请求大小对齐到块边界。

在 epoll 接管事件通知、协程与用户态线程支配异步编程的时代,内核始终缺少一套统一的、真正的异步操作接口。直到 2019 年,Jens Axboe 带来了 io_uring(最初名为 auring),以最优雅的方式彻底解决了这个困扰 Linux 系统编程二十余年的根本问题。

io_uring 架构设计:共享内存的双环形缓冲区

io_uring 的核心思想极其简洁:在用户态与内核态之间设置两个共享环形缓冲区(ring buffer),彻底消除系统调用在数据传输层面的开销。

┌─────────────────────────┐       ┌─────────────────────────┐
│       用户态进程        │       │        内核线程          │
│                         │       │                         │
│   SQ (Submission Queue) │───写入──▶│  处理 SQE 中的请求        │
│   SQE 环形缓冲区        │       │                         │
│                         │       │  处理完成后写入 CQE       │
│   CQ (Completion Queue) │◀──读取───│  CQE 环形缓冲区          │
│   CQE 环形缓冲区        │       │                         │
└─────────────────────────┘       └─────────────────────────┘

Submission Queue (SQ):用户态将 I/O 请求编码为 Submission Queue Entry(SQE),写入 SQ 环的尾部指针位置,然后更新尾指针。整个过程纯用户态操作,无需系统调用。

Completion Queue (CQ):内核处理完 I/O 请求后,将结果编码为 Completion Queue Entry(CQE),写入 CQ 环。用户态只需读取 CQ 尾指针与内核更新的头部指针之间的差值,即可获得完成事件。

这套设计的精妙之处在于:在 SQPOLL 模式下(内核轮询线程),用户态从提交请求到获取结果的整个生命周期中,完全可以不发起任何系统调用。每次 I/O 操作的开销降低到几次原子内存访问。

核心数据结构详解

1. io_uring_setup — 创建上下文

struct io_uring_params {
    __u32 sq_entries;
    __u32 cq_entries;
    __u32 flags;
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 features;
};

int io_uring_setup(unsigned entries, struct io_uring_params *p);
// 返回 fd,通过 mmap 映射 SQ/SQE 和 CQ/CQE 到用户空间

2. SQE — 30+ 种操作的统一请求格式

struct io_uring_sqe {
    __u8  opcode;     // IORING_OP_READV/WRITEV/POLL_ADD 等
    __u8  flags;      // IOSQE_FIXED_FILE, IOSQE_IO_LINK 等
    __u16 ioprio;
    __s32 fd;
    union { __u64 off; __u64 addr2; };
    union { __u64 addr; __u64 splice_off_in; };
    __u32 len;
    union { __u32 rw_flags; __u32 fsync_flags; ... };
    __u64 user_data;  // 用户自定义标识,原样回传 CQE
    __u16 buf_index;
    __u16 personality;
};

SQE 的 opcode 支持 30 余种操作:文件读写(READV/WRITEV)、套接字操作(SENDMSG/RECVMSG)、文件同步(FSYNC)、POLL_ADD(异步 epoll)、固定缓冲区操作(READ_FIXED/WRITE_FIXED)、文件定位(LSEEK)。一套接口覆盖网络、文件、文件系统的全部 I/O 场景。

3. CQE — 统一完成通知

struct io_uring_cqe {
    __u64 user_data;
    __s32 res;
    __u32 flags;
};

SQPOLL 模式:零系统调用的终极形态

struct io_uring_params params = {
    .flags = IORING_SETUP_SQPOLL,
    .sq_thread_cpu = 0,
    .sq_thread_idle = 2000,
};
io_uring_setup(256, ¶ms);

在 SQPOLL 模式下,内核线程持续轮询 SQ 环,用户态纯内存操作即可完成全部 I/O 生命周期。

Registered Buffers 与 Registered Files

io_uring 允许预注册缓冲区和文件描述符,消除每次 I/O 的页映射和 fd 引用计数开销。

// 注册固定缓冲区
io_uring_register_buffers(&ring, iovecs, 16);
// 使用固定缓冲区读
io_uring_prep_read_fixed(sqe, fd, buf, len, offset, buf_index);
sqe->flags |= IOSQE_FIXED_BUFFER;

// 注册文件
io_uring_register_files(&ring, files_array, 4);
io_uring_prep_read(sqe, file_index, buf, len, offset);
sqe->flags |= IOSQE_FIXED_FILE;

链式操作

io_uring 的 IOSQEIO_LINK 标志允许将多个 SQE 按顺序链式执行,无需用户态等待中间结果。


// 链接 3 个操作: 读头部 → 读正文 → 通知
sqe1->flags |= IOSQE_IO_LINK;
sqe2->flags |= IOSQE_IO_LINK;
// 三个 SQE 一次 submit,内核顺序执行
io_uring_submit(&ring);

性能对比数据

  • IOPS(NVMe 随机读):io_uring+SQPOLL+fixed buffers 可达 170 万+,epoll+read 约 85 万
  • P99 延迟:io_uring median 3-5μs,epoll+read 方案 10-20μs
  • 系统调用:SQPOLL 模式每批可做到 0 次
  • 统一接口:同一套 uring 同时处理 socket 和 file I/O

生产考量

  • 内核 5.1 引入,生产级需 5.11+,最佳体验 5.18+
  • seccomp 可能拦截 io_uring,容器环境需显式放行
  • 多线程写 SQ 需额外同步或使用 per-thread ring
  • 固定缓冲区需合理回收策略防止内存膨胀

io_uring 是 Linux 系统编程近十年最具颠覆性的创新,将异步从用户态模拟推进到内核原生支持,将高吞吐从批量缓存推进到零 syscall 核心路径。正在成为百万级 QPS 与微秒级延迟服务的新事实标准。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部