Linux io_uring 异步 I/O 深度实战:内核革命与生产落地

\n\n

一、前夜:为什么要发明 io_uring

\n\n

在 Linux 异步 I/O 的历史长河中,存在着一个尴尬的事实:我们有 POSIX AIO、有 epoll、有 sendfile,但没有一个真正高效、通用、零拷贝的异步 I/O 框架。POSIX AIO(aio_read/aio_write)受限于 O_DIRECT 约束,无法用于缓冲 I/O,且仅支持文件系统场景;epoll 本质上是事件通知机制,不是 I/O 机制,处理文件 I/O 时仍需同步系统调用。

\n\n

2019 年,Linux 5.1 内核合并了一个改变格局的新特性:io_uring。它由 Jens Axboe(Linux 内核块设备层维护者、bq benchmarks 作者)设计,核心目标是用一个统一的环形队列接口,实现真正的异步、零拷贝、极低开销的 I/O 操作,覆盖从块设备、文件套接字到所有内核 I/O 子系统。

\n\n

到 Linux 6.x 时代,io_uring 已经支持:固定缓冲区(fixed buffers)、固定文件(fixed files)、多核工作线程(IORING_SETUP_SQPOLL / IORING_SETUP_ATTACH_WQ)、缓冲区选择(buffer selection)、sendmsg/recvmsg、tee/splice、各种 opcode 扩展,甚至 BPF 集成。PostgreSQL、Nginx、Rust tokio-uring、Java Loring(Project Loom)等都已接入。

\n\n

二、io_uring 的架构原理

\n\n

2.1 双队列设计:SQ + CQ

\n\n

io_uring 的核心数据结构包含两个环形队列:

\n\n
    \n
  • Submission Queue (SQ):用户态向内核提交 I/O 请求的队列,存储 SQE(Submission Queue Entry)
  • \n
  • Completion Queue (CQ):内核完成 I/O 后放置结果的队列,存储 CQE(Completion Queue Entry)
  • \n
\n\n

SQ 和 CQ 都映射到用户态内存,用户态写 SQE 后只需写一次 tail 指针(伴随一次内存屏障),内核通过 head 指针读取。这意味着在 SQPOLL 模式下,用户态甚至不需要调用 io_uring_enter() 系统投递就能让内核挑取新提交的 SQE。

\n\n

2.2 SQPOLL 模式与内核轮询线程

\n\n

io_uring 提供两种工作模式:

\n\n
    \n
  1. 传统中断模式(Interrupt-driven):用户态调用 io_uring_enter(fd, to_submit, min_complete, flags) 通知内核收割 SQE。每次调用都是一次系统调用,适合低频率场景
  2. \n
  3. 内核轮询模式(SQPOLL):创建 io_uring 时指定 IORING_SETUP_SQPOLL,内核启动一个内核线程(io-wq/0)持续轮询 SQ。用户态写入 SQE 后更新 tail 即完成提交,无需系统调用
  4. \n
\n\n

SQPOLL 模式下的延迟模型:

\n\n
用户态: 写 SQE - wmb() - 写 sq->tail - (内核线程自动看到更新 - 执行 - 写 CQE - 写 cq->head)\n用户态: 读 cq->head - rmb() - 读 CQE
\n\n

代价:内核线程持续占用一个 CPU 核心(通常在 100% 利用率上旋转),适合极高吞吐、延迟敏感的服务。sq_thread_idle 参数(默认 2000ms)可在空闲时让线程休眠。

\n\n

2.3 SQE 与 CQE 数据结构

\n\n

每个 SQE 128 字节,关键字段:

\n\n
struct io_uring_sqe {\n    __u8   opcode;    // 操作码:IORING_OP_READV / WRITEV / SENDMSG / RECVMSG / ...\n    __u8   flags;     // IOSQE_FIXED_FILE / IOSQE_IO_LINK 等\n    __u16  ioprio;    // 优先级 (ioprio_set)\n    __s32  fd;        // 目标文件描述符\n    union { ... };    // 操作码特定数据\n    __u64  user_data; // 用户自定义标识,CQE 原样带回\n    union {\n        struct { __u16 buf_group; __u16 personality; __u32 len; };\n        __u64 __pad2[3];\n    };\n};
\n\n

每个 CQE 16 字节:

\n\n
struct io_uring_cqe {\n    __u64 user_data;  // 对应 SQE.user_data\n    __s32 res;        // 返回值(负数表示错误码 -errno)\n    __u32 flags;      // CQE 标志\n};
\n\n

一个关键细节:user_data 是核心。在高频场景下,一次提交多个 SQE 后收割多个 CQE,通过 user_data 可以 O(1) 关联回原始上下文。

\n\n

三、用户态编程 API

\n\n

3.1 初始化与队列映射

\n\n
#include <liburing.h>\n\nstruct io_uring ring;\nint ret = io_uring_queue_init(1024, &ring, IORING_SETUP_SQPOLL);\n// 底层调用链:\n// io_uring_setup(entries, &p) - 返回 fd\n// mmap(..., sq_off.array + p.sq_off.array) - 获取 SQ 内存\n// mmap(..., cq_off.array + p.cq_off)    - 获取 CQ 内存
\n\n

返回之后,用户态直接操纵 ring 结构体字段访问 SQ/CQ。对高级应用,也可以用原生系统调用:

\n\n
int ring_fd = io_uring_setup(entries, &p);\nstruct io_uring_sq *sq = &ring.sq;\nstruct io_uring_cq *cq = &ring.cq;\n\nsq->ring_sz = p.sq_off.array + p.sq_entries * sizeof(unsigned);\nsq->ring_ptr = mmap(0, sq->ring_sz, PROT_READ | PROT_WRITE,\n                    MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_SQ_RING);\n\ncq->ring_sz = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);\ncq->ring_ptr = mmap(0, cq->ring_sz, PROT_READ | PROT_WRITE,\n                    MAP_SHARED | MAP_POPULATE, ring_fd, IORING_OFF_CQ_RING);
\n\n

3.2 提交 I/O 请求(以 readv 为例)

\n\n
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nif (!sqe) {\n    // SQ 满了,需要提前收割 CQE\n    io_uring_cq_advance(&ring, completed);\n    sqe = io_uring_get_sqe(&ring);\n}\n\nstruct iovec iov = { .iov_base = buf, .iov_len = buflen };\nio_uring_prep_readv(sqe, fd, &iov, 1, offset);\nio_uring_sqe_set_data(sqe, my_ctx);\nio_uring_submit(&ring);
\n\n

3.3 收割完成事件

\n\n
struct io_uring_cqe *cqe;\nunsigned head, completed = 0;\n\nio_uring_for_each_cqe(&ring, head, cqe) {\n    struct my_request *req = io_uring_cqe_get_data(cqe);\n    if (cqe->res < 0) {\n        handle_error(req, cqe->res);\n    } else {\n        handle_success(req, cqe->res);\n    }\n    completed++;\n}\nio_uring_cq_advance(&ring, completed);
\n\n

四、进阶特性:零拷贝全面解锁

\n\n

4.1 Registered Buffers(IORING_REGISTER_BUFFERS)

\n\n

传统 read/write 系统调用需要在用户态和内核态之间做 copy_from_user/copy_to_user。io_uring 的注册缓冲区特性允许预先 mmap 一块内存向内核注册,后续 I/O 操作直接使用该区域,避免每次调用的拷贝开销。

\n\n
#define BUF_SIZE  (1 << 20)  // 1MB\n#define BUF_COUNT 16\n\nchar buf_pool[BUF_COUNT][BUF_SIZE] __attribute__((aligned(4096)));\nstruct iovec iovs[BUF_COUNT];\n\nfor (int i = 0; i < BUF_COUNT; i++) {\n    iovs[i] = (struct iovec){ buf_pool[i], BUF_SIZE };\n}\n\nint ret = io_uring_register_buffers(&ring, iovs, BUF_COUNT);\n\n// 提交 I/O 时使用注册缓冲区\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_read_fixed(sqe, fd, buf_pool[0], BUF_SIZE, offset, 0);\nsqe->flags |= IOSQE_FIXED_FILE;
\n\n

注册缓冲区的优势:消除 copy_from_user,实现真正的零拷贝(DMA 直接到缓冲区)。代价:注册操作不可逆。

\n\n

4.2 Fixed Files(IORING_REGISTER_FILES)

\n\n

每次提交 I/O 都需要传 fd,内核需要查找文件表。Fixed files 允许将一组 fd 预先注册到 io_uring 的私有文件表,提交时用 index 替代 fd,内核通过 array[index] 直接索引,减少文件查找开销。

\n\n
int fds[] = { listen_fd, file_fd1, file_fd2 };\nio_uring_register_files(&ring, fds, 3);\n\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nsqe->fd = 1;  // 文件表中的第 2 个 fd (0 = listen_fd)\nsqe->flags |= IOSQE_FIXED_FILE;\nio_uring_prep_read(sqe, 1, buf, len, offset);\nio_uring_submit(&ring);
\n\n

4.3 链接操作(IOSQE_IO_LINK)

\n\n

io_uring 支持 SQE 链式链接。当需要原子地完成\"读取 header 然后 读取 body\"这类两步操作时,可以使用 IOSQE_IO_LINK 将两个 SQE 串联,内核保证它们按顺序执行。

\n\n
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe1, fd, header_buf, HDR_SIZE, 0);\nsqe1->flags |= IOSQE_IO_LINK;\n\nstruct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);\nio_uring_prep_read(sqe2, fd, body_buf, body_len, HDR_SIZE);\n// sqe2 不带 IOSQE_IO_LINK:链结束
\n\n

更强大的 flag: IOSQE_IO_HARDLINK,当下一个 SQE 失败时跳过后续链。

\n\n

五、多核扩展与 NUMA 亲和

\n\n

5.1 SQPOLL 单线程瓶颈与多队列扩展

\n\n

SQPOLL 模式下只使用一个内核线程轮询 SQ,当单核无法处理 I/O 峰值时(如 NVMe SSD 单盘 200 万 IOPS),会出现优势变劣势。Linux 5.17+ 引入了 IORING_SETUP_ATTACH_WQ,允许多个 io_uring 实例共享同一个内核工作队列(io-wq),从而利用多核并行。

\n\n
struct io_uring master_ring;\nio_uring_queue_init_params(1024, &master_ring, &(struct io_uring_params){\n    .flags = IORING_SETUP_SQPOLL | IORING_SETUP_ATTACH_WQ,\n    .sq_thread_idle = 1000,\n});\n\nstruct io_uring worker_ring;\nio_uring_queue_init_params(1024, &worker_ring, &(struct io_uring_params){\n    .flags = IORING_SETUP_ATTACH_WQ,\n    .wq_fd = master_ring.ring_fd,\n});
\n\n

io-wq 支持线程池模式(pworkers),内核根据 I/O 压力动态创建/回收线程。实际内核代码为 io-wq(io_uring workqueue),与传统的 workqueue 是两个独立子系统。

\n\n

5.2 Buffer 池与 multishot 接收

\n\n

对于网络服务器,每个连接都需要自己的读缓冲区。io_uring 的 buffer pool 特性允许预先分配缓冲区池,指定 bgid(buffer group id),配合 IORING_OP_PROVIDE_BUFFERS 与 multishot 接收,实现真正的零拷贝网络:

\n\n
struct io_uring_buf_reg reg = {\n    .ring_addr = (unsigned long)provider->buf_ring,\n    .ring_entries = 1024,\n    .bgid = 1,\n};\nio_uring_register_buf_ring(&ring, &reg, 0);\n\nstruct io_uring_sqe *sqe = io_uring_get_sqe(&ring);\nio_uring_prep_recv_multishot(sqe, client_fd, NULL, 0, 0);\nsqe->buf_group = 1;\nsqe->flags |= IOSQE_BUFFER_SELECT;\nio_uring_submit(&ring);\n\n// CQE 收割时:cqe->flags & IORING_CQE_F_BUFFER\nuint16_t buf_id = cqe->flags >> IORING_CQE_BUFFER_SHIFT;
\n\n

Multishot recv 的含义:一次提交的 recv 会持续接收数据,每次数据到达都产生一个 CQE,直到收到 EAGAIN 或手动取消。在固定缓冲区场景下,每次 CQE 包含 buf_id 和长度,实现真正的零拷贝接收。

\n\n

六、性能基准:io_uring vs epoll vs AIO

\n\n

典型 NVMe SSD 测试(随机 4K 读取,深度=8,单核,量级参考):

\n\n\n\n\n\n\n\n\n\n\n\n\n
方案IOPS(百万)延迟(μs)系统调用次数
同步 pread/pwrite0.05~20每次 I/O 1 次
POSIX AIO (io_submit)0.1~10每次 I/O 1 次(submit)+ 收割 1 次
epoll + synchronous IO0.06~18epoll_wait + pread/pwrite 各 1
io_uring (interrupt)0.4~3.0每次提交需要 io_uring_enter
io_uring (SQPOLL)1.5+~1.50(纯内存操作提交)
\n\n

网络场景对比(10Gbps HTTP 静态文件服务器,量级参考):

\n\n
    \n
  • epoll/nginx:~120K req/s (单核,256 连接)
  • \n
  • io_uring/nginx (io_uring 模块):~150K req/s,CPU 降低 20%
  • \n
  • io_uring + send/recv fixed buffers + fixed files:~180K req/s,内存带宽降低 40%
  • \n
\n\n

七、生态与语言绑定

\n\n

7.1 C/C++ 生态成熟度

\n\n
    \n
  • liburing:官方用户态库,与内核 io_uring 同步演进,封装 SQE/CQE 操作、缓冲区注册、缓冲区池、SQPOLL 管理
  • \n
  • rocksdb:USE_URING 编译选项直接使用 io_uring 做 WAL 写入和 SST 读取
  • \n
  • PostgreSQL 17+:实验性 backend 动态选择 io_uring 读取 heap/WAL
  • \n
\n\n

7.2 各语言绑定

\n\n
    \n
  • Rust:tokio-uring(Tokio 的 io_uring 后端)、glommio(基于 DPDK/LRing)、ringbahn
  • \n
  • Go:gosurr、uring(syscall 直接封装),或通过 cgo 调用 liburing
  • \n
  • Java:Project Loom 的 Loring、Java 22+ 的 VirtualThread 底层在 Linux 已试探性使用 io_uring 做文件系统异步
  • \n
  • C#/.NET:在 .NET 9+ 中做了初步探索
  • \n
\n\n

八、生产环境实战陷阱与调优

\n\n

8.1 SQPOLL 线程的 CPU 亲和性

\n\n

在生产 nginx + io_uring 场景下,SQPOLL 线程会持续占用一个 CPU。如果不做亲和隔离,内核可能将其他工作负载调度到同一核心上,导致 I/O 和处理线程相互干扰。

\n\n
cpu_set_t cpuset;\nCPU_ZERO(&cpuset);\nCPU_SET(io_cpu, &cpuset);\nio_uring_ring.sq_thread_cpu = io_cpu;\nio_uring_ring.flags |= IORING_SETUP_SQ_AFF;
\n\n

或者通过 taskset 设置已创建 io_uring 后的内核线程:

\n\n
pgrep io_uring-sq  # 找到 SQPOLL 内核线程\ntaskset -cp 3 <pid>  # 绑定到 CPU 3
\n\n

8.2 内存顺序与 barrier 误用

\n\n

在裸写 SQE/CQE(不使用 liburing)时,memory barrier 错误是一种极其隐蔽的 bug:写 SQE 内容后更新 tail 必须用 wmb(),读 CQE 前用 rmb()。错误顺序会导致内核看到\"半写\"的 SQE 或用户态读到\"半写\"的 CQE。

\n\n
// 正确顺序\nsqe->opcode = IORING_OP_READV;\nsqe->fd = fd;\nsqe->off = offset;\nsqe->addr = (uintptr_t)iov;\nsqe->len = 1;\nwmb();  // 写 SQE 字段 - 内存屏障 - 写 tail\nsq->tail = new_tail;\n\n// 正确读顺序\nhead = cq->head;\nrmb();  // 等待 head 已同步\n// 现在读取 CQE[ head ] 的 res 和 flags 是安全的
\n\n

8.3 内核版本兼容性

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
特性最低内核成熟度
基础 read/write5.1稳定
SQPOLL5.11稳定
Fixed buffers/files5.1+API 在 5.13+ 稳定
Multishot recv5.19稳定
Buffer ring registration5.19稳定
Linked SQE priority5.19+稳定
IORING_SETUP_SUBMIT_ALL5.18稳定
BPF integration (IORING_OP_BPF)6.6+实验
\n\n

生产部署建议:内核版本 ≥ 5.19 是推荐下限;≥ 6.0 则 multishot recv、buffer pool 等特性已非常成熟。

\n\n

8.4 中断风暴与 NAPI 权重

\n\n

当 io_uring 用在网卡上(IORING_OP_SENDMSG)+ NAPI 权重调整时,若每轮都提交 RQE 触发 TX completion,可能中断风暴影响工作线程。建议:

\n\n
    \n
  • 设置 net.core.netdev_budget_usecs 合理上限
  • \n
  • 使用 XDP 绕过内核网络栈(可用 io_uring 提交 XDP 操作 IORING_OP_XDP)
  • \n
  • 将网卡RX队列绑定到 io_uring 轮询核,避免跨 NUMA 访问
  • \n
\n\n

8.5 容器与 seccomp 限制

\n\n

在容器环境中使用 io_uring 时会遇到一个坑:io_uring 三个系统调用(io_uring_setup、io_uring_enter、io_uring_register)都相对较新,不少容器的 seccomp profile 默认放行 io_uring_setup 但禁止 io_uring_enter/register。Docker 官方默认配置在较新版本已允许三个调用,但在企业私有运行时中仍需检查。

\n\n

解决方式:

\n\n
int fd = io_uring_setup(1, &p);\nif (fd < 0 && errno == EPERM) {\n    fprintf(stderr, \"io_uring_setup blocked by seccomp, falling back to epoll mode\n\");\n    return use_epoll_mode();\n}
\n\n

九、源码视角:内核处理流程

\n\n

了解内核侧如何处理 io_uring 请求,有助于精细化调优:

\n\n
    \n
  1. 用户态调用 io_uring_setup() - 内核分配 io_uring_ctx,初始化 sq_thread、io-wq 工作队列、分配 SQE/CQ 内存,返回 fd
  2. \n
  3. 用户调用 io_uring_enter(fd, to_submit, min_complete, flags):\n
    __sys_io_uring_enter() {\n    struct io_uring_ctx = get_ring(ctx);\n    // SYNC模式:同步执行(sqe->flags & IOSQE_ASYNC)\n    // 若 sq->kflags & IORING_SQ_NEED_WAKEUP,唤醒内核轮询线程\n    // wait_for_cqe 或直接收割 CQE\n}
    \n
  4. \n
  5. SQPOLL 内核线程循环:\n
    io_sq_thread() {\n    for (;;) {\n        // 读取 SQ tail,处理新 SQE\n        处理新 SQE: io_uring_submit_sqe(sqe);\n        // 批量提交到块层/套接字子系统\n        // 检查是否需要进入睡眠(sq_thread_idle 超时后)\n    }\n}
    \n
  6. \n
  7. I/O 完成路径:底层设备完成中断 - io_uring_task 被调度 - 写 CQE - 写 cq->head
  8. \n
\n\n

一个微妙的设计:在 Linux 5.18+ 默认启用 IORING_SETUP_SUBMIT_ALL,提交逻辑会一次性处理所有已提交的 SQE,保证顺序语义。

\n\n

十、总结与展望

\n\n

io_uring 是过去十年最重要的 Linux I/O 进化之一。它的最大贡献不是某个单一的性能数字,而是提供了一个统一的异步 I/O 抽象,使得从编程模型到生产落地都有了\"可选的正确方案\":

\n\n
    \n
  • 极致延迟 - SQPOLL + fixed buffers + registered files + buffer pool
  • \n
  • 通用服务器 - interrupt 模式 + 注册缓冲区
  • \n
  • 云原生兼容 - 内核 version guard,不支持时优雅回退 epoll
  • \n
\n\n

未来方向:io_uring 正在集成 BPF 扩展,实现可编程内核 I/O 路径;workqueue 独立化以支持更多场景;网络协议栈加速(零拷贝 TCP、QUIC);安全领域也已有基于 io_uring 的创新用例。

\n\n

最终,io_uring 的存在提醒我们:系统编程中,\"更好\"的接口不仅仅是性能提升,更是心智模型的简化。

\n
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }