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\n2019 年,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\n2.1 双队列设计:SQ + CQ
\n\nio_uring 的核心数据结构包含两个环形队列:
\n\n- \n
- Submission Queue (SQ):用户态向内核提交 I/O 请求的队列,存储 SQE(Submission Queue Entry) \n
- Completion Queue (CQ):内核完成 I/O 后放置结果的队列,存储 CQE(Completion Queue Entry) \n
SQ 和 CQ 都映射到用户态内存,用户态写 SQE 后只需写一次 tail 指针(伴随一次内存屏障),内核通过 head 指针读取。这意味着在 SQPOLL 模式下,用户态甚至不需要调用 io_uring_enter() 系统投递就能让内核挑取新提交的 SQE。
\n\n2.2 SQPOLL 模式与内核轮询线程
\n\nio_uring 提供两种工作模式:
\n\n- \n
- 传统中断模式(Interrupt-driven):用户态调用 io_uring_enter(fd, to_submit, min_complete, flags) 通知内核收割 SQE。每次调用都是一次系统调用,适合低频率场景 \n
- 内核轮询模式(SQPOLL):创建 io_uring 时指定 IORING_SETUP_SQPOLL,内核启动一个内核线程(io-wq/0)持续轮询 SQ。用户态写入 SQE 后更新 tail 即完成提交,无需系统调用 \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\n2.3 SQE 与 CQE 数据结构
\n\n每个 SQE 128 字节,关键字段:
\n\nstruct 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\nstruct 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\n3.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\nint 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\n3.2 提交 I/O 请求(以 readv 为例)
\n\nstruct 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\n3.3 收割完成事件
\n\nstruct 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\n4.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\n4.2 Fixed Files(IORING_REGISTER_FILES)
\n\n每次提交 I/O 都需要传 fd,内核需要查找文件表。Fixed files 允许将一组 fd 预先注册到 io_uring 的私有文件表,提交时用 index 替代 fd,内核通过 array[index] 直接索引,减少文件查找开销。
\n\nint 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\n4.3 链接操作(IOSQE_IO_LINK)
\n\nio_uring 支持 SQE 链式链接。当需要原子地完成\"读取 header 然后 读取 body\"这类两步操作时,可以使用 IOSQE_IO_LINK 将两个 SQE 串联,内核保证它们按顺序执行。
\n\nstruct 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\n5.1 SQPOLL 单线程瓶颈与多队列扩展
\n\nSQPOLL 模式下只使用一个内核线程轮询 SQ,当单核无法处理 I/O 峰值时(如 NVMe SSD 单盘 200 万 IOPS),会出现优势变劣势。Linux 5.17+ 引入了 IORING_SETUP_ATTACH_WQ,允许多个 io_uring 实例共享同一个内核工作队列(io-wq),从而利用多核并行。
\n\nstruct 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\nio-wq 支持线程池模式(pworkers),内核根据 I/O 压力动态创建/回收线程。实际内核代码为 io-wq(io_uring workqueue),与传统的 workqueue 是两个独立子系统。
\n\n5.2 Buffer 池与 multishot 接收
\n\n对于网络服务器,每个连接都需要自己的读缓冲区。io_uring 的 buffer pool 特性允许预先分配缓冲区池,指定 bgid(buffer group id),配合 IORING_OP_PROVIDE_BUFFERS 与 multishot 接收,实现真正的零拷贝网络:
\n\nstruct 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, ®, 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\nMultishot recv 的含义:一次提交的 recv 会持续接收数据,每次数据到达都产生一个 CQE,直到收到 EAGAIN 或手动取消。在固定缓冲区场景下,每次 CQE 包含 buf_id 和长度,实现真正的零拷贝接收。
\n\n六、性能基准:io_uring vs epoll vs AIO
\n\n典型 NVMe SSD 测试(随机 4K 读取,深度=8,单核,量级参考):
\n\n| 方案 | IOPS(百万) | 延迟(μs) | 系统调用次数 |
|---|---|---|---|
| 同步 pread/pwrite | 0.05 | ~20 | 每次 I/O 1 次 |
| POSIX AIO (io_submit) | 0.1 | ~10 | 每次 I/O 1 次(submit)+ 收割 1 次 |
| epoll + synchronous IO | 0.06 | ~18 | epoll_wait + pread/pwrite 各 1 |
| io_uring (interrupt) | 0.4 | ~3.0 | 每次提交需要 io_uring_enter |
| io_uring (SQPOLL) | 1.5+ | ~1.5 | 0(纯内存操作提交) |
网络场景对比(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\n7.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
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\n8.1 SQPOLL 线程的 CPU 亲和性
\n\n在生产 nginx + io_uring 场景下,SQPOLL 线程会持续占用一个 CPU。如果不做亲和隔离,内核可能将其他工作负载调度到同一核心上,导致 I/O 和处理线程相互干扰。
\n\ncpu_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\npgrep io_uring-sq # 找到 SQPOLL 内核线程\ntaskset -cp 3 <pid> # 绑定到 CPU 3\n\n8.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\n8.3 内核版本兼容性
\n\n| 特性 | 最低内核 | 成熟度 |
|---|---|---|
| 基础 read/write | 5.1 | 稳定 |
| SQPOLL | 5.11 | 稳定 |
| Fixed buffers/files | 5.1+ | API 在 5.13+ 稳定 |
| Multishot recv | 5.19 | 稳定 |
| Buffer ring registration | 5.19 | 稳定 |
| Linked SQE priority | 5.19+ | 稳定 |
| IORING_SETUP_SUBMIT_ALL | 5.18 | 稳定 |
| BPF integration (IORING_OP_BPF) | 6.6+ | 实验 |
生产部署建议:内核版本 ≥ 5.19 是推荐下限;≥ 6.0 则 multishot recv、buffer pool 等特性已非常成熟。
\n\n8.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
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\nint 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
- 用户态调用 io_uring_setup() - 内核分配 io_uring_ctx,初始化 sq_thread、io-wq 工作队列、分配 SQE/CQ 内存,返回 fd \n
- 用户调用 io_uring_enter(fd, to_submit, min_complete, flags):\n
\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 - SQPOLL 内核线程循环:\n
\nio_sq_thread() {\n for (;;) {\n // 读取 SQ tail,处理新 SQE\n 处理新 SQE: io_uring_submit_sqe(sqe);\n // 批量提交到块层/套接字子系统\n // 检查是否需要进入睡眠(sq_thread_idle 超时后)\n }\n}\n - I/O 完成路径:底层设备完成中断 - io_uring_task 被调度 - 写 CQE - 写 cq->head \n
一个微妙的设计:在 Linux 5.18+ 默认启用 IORING_SETUP_SUBMIT_ALL,提交逻辑会一次性处理所有已提交的 SQE,保证顺序语义。
\n\n十、总结与展望
\n\nio_uring 是过去十年最重要的 Linux I/O 进化之一。它的最大贡献不是某个单一的性能数字,而是提供了一个统一的异步 I/O 抽象,使得从编程模型到生产落地都有了\"可选的正确方案\":
\n\n- \n
- 极致延迟 - SQPOLL + fixed buffers + registered files + buffer pool \n
- 通用服务器 - interrupt 模式 + 注册缓冲区 \n
- 云原生兼容 - 内核 version guard,不支持时优雅回退 epoll \n
未来方向:io_uring 正在集成 BPF 扩展,实现可编程内核 I/O 路径;workqueue 独立化以支持更多场景;网络协议栈加速(零拷贝 TCP、QUIC);安全领域也已有基于 io_uring 的创新用例。
\n\n最终,io_uring 的存在提醒我们:系统编程中,\"更好\"的接口不仅仅是性能提升,更是心智模型的简化。
\n
发表评论 取消回复