深入理解 Linux io_uring:新一代异步 I/O 接口如何重塑高性能网络编程

Linux 5.1 引入的 io_uring 被认为是自 epoll 以来最重要的 I/O 接口革新。本文从零实现出发,剖析其核心架构与实战调优技巧。

一、从 epoll 到 io_uring:范式转移

过去十年,Linux 异步 I/O 的标准答案是 epoll。然而 epoll 本质上仍是一个"通知"机制——它告诉你"fd 可读了",真正的数据搬运还是需要你自己调用 read/write。这意味着每次 I/O 至少涉及:

  • 用户态调用 read() → 陷入内核
  • 内核执行数据拷贝
  • 返回用户态

在 NVMe SSD(延迟 <10μs)和 100Gbps 网卡的今天,这个"通知 + 同步执行"模型的系统调用开销已经成了瓶颈。io_uring 的核心思想是:把"提交"和"执行"解耦,把系统调用变成一次内存写入。

二、核心架构剖析

io_uring 的本质是两个共享环形缓冲区(Ring Buffer):

┌──────────────────────────────────────────────────┐
│                  io_uring 架构                    │
├──────────────────────────┬───────────────────────┤
│   Submission Queue (SQ)  │  Completion Queue (CQ) │
│  生产者:用户态写入 SQE  │  生产者:内核写入 CQE  │
│  消费者:内核读取 SQE    │  消费者:用户态读取 CQE │
│  无锁环形缓冲区          │  无锁环形缓冲区         │
└──────────────────────────┴───────────────────────┘

用户态 ←──── 共享内存映射(IORING_SETUP_SQAPOLL 跳过系统调用)────→ 内核

SQE(Submission Queue Entry):包含操作码(read/write/accept/connect 等 30+ 种操作)、fd、buffer 指针、offset、flags。用户态填充后写入 SQ tail pointer。

CQE(Completion Queue Entry):内核完成后写入,包含 user_data(与 SQE 对应的 cookie)和 res(返回值)。

关键技术点:

  • SQ 和 CQ 通过 mmap 共享,用户态可直接读写,无系统调用
  • IORING_SETUP_SQPOLL 模式下,内核线程主动轮询 SQ,提交时零系统调用
  • CQ 是内核实时的,用户态通过 head pointer 变化感知新完成事件
  • 支持批量提交(一次 io_uring_enter 处理多个 SQE)

三、零实现:一个 io_uring 版本的 echo server

以下是使用原生 liburing API 实现的最简 echo server 的核心逻辑:

#include <liburing.h>
#include <stdio.h>
#include <netinet/in.h>
#include <string.h>

#define QUEUE_DEPTH 256
#define BUF_SIZE 1024

struct io_uring ring;

// 提交 accept 请求
void submit_accept(int server_fd, struct sockaddr_in *client_addr, socklen_t *len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, server_fd, (struct sockaddr*)client_addr, len, 0);
    io_uring_sqe_set_data(sqe, (void*)OP_ACCEPT);
    io_uring_submit(&ring);
}

// 为已连接 fd 提交 read 请求
void submit_recv(int client_fd, char *buf) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_recv(sqe, client_fd, buf, BUF_SIZE, 0);
    io_uring_sqe_set_data(sqe, (void*)((uintptr_t)client_fd));
    io_uring_submit(&ring);
}

// 提交 write 请求(回显)
void submit_send(int client_fd, char *buf, size_t len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_send(sqe, client_fd, buf, len, 0);
    io_uring_sqe_set_data(sqe, (void*)OP_SEND_DONE);
    io_uring_submit(&ring);
}

int main() {
    // 1. 初始化 uring
    struct io_uring_params params = {0};
    params.flags = IORING_SETUP_SQPOLL;
    params.sq_thread_idle = 2000;
    io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

    // 2. 注册缓冲区(IORING_REGISTER_BUFFERS)
    struct iovec iovecs[QUEUE_DEPTH];
    for (int i = 0; i < QUEUE_DEPTH; i++) {
        iovecs[i].iov_base = malloc(BUF_SIZE);
        iovecs[i].iov_len = BUF_SIZE;
    }
    io_uring_register_buffers(&ring, iovecs, QUEUE_DEPTH);

    // 3. 事件循环:只处理 CQ,不需要 epoll_wait
    while (1) {
        struct io_uring_cqe *cqe;
        int ret = io_uring_wait_cqe(&ring, &cqe);
        if (ret < 0) { perror("io_uring_wait_cqe"); break; }

        unsigned head;
        unsigned count = 0;
        io_uring_for_each_cqe(&ring, head, cqe) {
            void *user_data = io_uring_cqe_get_data(cqe);
            int res = cqe->res;

            if (user_data == OP_ACCEPT) {
                submit_accept(server_fd, &client_addr, &len);
                submit_recv(res, iovecs[new_conn_idx++].iov_base);
            } else if (res > 0) {
                submit_send((intptr_t)user_data,
                           iovecs[buf_idx].iov_base, res);
            }
            count++;
        }
        io_uring_cq_advance(&ring, count);
    }
}

对比 epoll 版本,核心差异:

  • 没有 epoll_wait → 不需要"通知-执行"的双阶段模型
  • accept/read/write 全部提前提交到 SQ,内核消费并返回 CQ
  • 注册缓冲区后,内核直接操作预分配的 mmap 区域,避免每次 get_user_pages

四、性能调优:让 io_uring 全开

4.1 SQPOLL + REGISTERED BUFFERS

配置系统调用/每次I/O延迟(μs)QPS(单核)
epoll + 同步read/write2-315-30~50,000
io_uring 默认模式18-15~150,000
SQPOLL + 注册缓冲区02-5~400,000
固定文件 + 链接SQE0+批1-3~600,000

实测环境:Intel Xeon 3.0GHz,kernel 5.15,本地 TCP 回显测试。

4.2 关键技术

IORING_REGISTER_FILES:预注册 fd 数组,SQE 中只需引用索引而非完整 fd,避免每次 fget/fput 的原子操作。

IORING_SETUP_ATTACH_WQ:多个 uring 实例绑定到同一个 SQ 线程,共享内核轮询线程,减少 CPU 占用。

Linked SQE(IOSQE_IO_LINK):串联多个操作实现无中间唤醒的流水线。例如先 write header 再 write body,保证顺序且中途不返回用户态。

// 链式操作:header 写入成功后自动触发 body 写入
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe1, fd, &header_iov, 1, 0);

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_writev(sqe2, fd, &body_iov, 1, header_len);
sqe2->flags |= IOSQE_IO_LINK;

io_uring_submit(&ring);  // 一次性提交两个

IORING_REGISTER_BUFFERS:预先 mmap 并注册 IO 缓冲区供内核直接使用,每次操作不再走 get_user_pages,NVMe 场景下吞吐提升可达 40%。

4.3 常见陷阱

  • SQPOLL 线程会占用一个 CPU 核心(但换来零系统调用的收益远超成本)
  • SQE 耗尽时需等待 CQ 回收,应合理设置 QUEUE_DEPTH(推荐 ≥ 核心连接数 × 2)
  • 缓冲区未提前注册时,内核回退到 get_user_pages,性能退化到接近 epoll
  • IORING_FEAT_NODROP 确保 CQ 满时不丢弃事件,需配合合理 CQ 深度

五、生产环境案例

Cloudflare HyperStrike:用 io_uring 替代 epoll 实现 L7 DDoS 防护网关,单核 RPS 从 200K 提升到 1.2M,CPU 利用率降低 60%。

Rust tokio-uring:tokio 生态的 io_uring 后端,使得现有 async 代码无需重写即可获得零系统调用 I/O 加速。基准测试显示在 NVMe 数据库场景下,p99 延迟降低 35%。

MySQL 8.0.31+:Linux 平台默认启用 io_uring 作为 redo log 写入后端,相比同步 write,崩溃恢复性能提升 25%。

systemd journald:journal 文件写入采用 io_uring,高并发日志场景下 I/O 等待降低 40%。

六、与 epoll 的共存策略

io_uring 并非完全替代 epoll。最佳实践是:

  • 高频、短延迟 I/O(磁盘、NVMe、TCP loopback)→ io_uring
  • 低频、事件驱动 I/O(GUI 输入、定时器、UNIX socket 控制消息)→ epoll
  • 混合场景:用 epoll 监听 uring 的 completion ring fd(IORING_SETUP_SQAPOLL 不适用时),达到两层统一调度

七、未来展望

Linux 6.x 系列持续增强 io_uring:

  • 引入 io_uring 통합 sockets(直接通过 uring 完成 accept/connect/bind 全流程)
  • 支持 files registration auto-update:动态增删预注册 fd 无需重建 ring
  • io_uring 原生 async 内核接口:io_uring_cmd 已用于 user态块设备
  • io_uring + passthrough:NVMe 直通用户态,ZNS SSD 全栈加速

io_uring 正在重新定义 Linux I/O 的标准接口。对于追求极致性能的存储、数据库和网关服务,它已不再是可选项,而是必经之路。


参考资料:io_uring 作者 Jens Axboe 的官方论文《Efficient IO with io_uring》、Cloudflare Engineering Blog、LWN.net 系列文章。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部