io_uring 深度解析

一、引言:从协程到异步 IO

在高性能网络编程中,我们一直追求"同步的编程方式,异步的执行性能"。协程通过用户态切换和非阻塞 IO 实现了这一目标,但其底层仍然依赖 epoll 等事件通知机制。epoll 本身是同步非阻塞的------它告诉我们"IO 就绪了",但实际的读写操作(read/write/recv/send)仍然是同步的:调用读函数时,数据从内核拷贝到用户空间的过程是阻塞的。

那么,有没有一种机制,能把"读请求的发起"和"数据拷贝的完成"也异步化?答案是 io_uring

io_uring 是 Linux 内核从 5.1 版本开始引入(5.4 版本趋于稳定)的全新异步 IO 接口,它彻底改变了 Linux 下异步 IO 的编程模型,让用户能够以真正的异步方式发起读写、网络操作,甚至 accept/connect。

二、io_uring 出现的背景

在 io_uring 之前,Linux 上的异步 IO 方案存在诸多不足:

  • AIO(libaio):只支持 O_DIRECT 方式的磁盘 IO,不支持网络 socket,且接口复杂,使用场景受限。

  • epoll:事件通知模型,通知就绪后仍需同步执行读写,存在用户态/内核态切换和数据拷贝开销。

  • 线程池模拟:为每个请求创建一个线程,上下文切换开销大,无法支撑海量连接。

随着 NVMe SSD 和高速网络的普及,软件层面的开销逐渐成为瓶颈。io_uring 应运而生,它借鉴了 SPDK 等用户态驱动的设计思想,通过共享内存环形队列实现零拷贝、系统调用减少和真正的异步提交/完成。

三、io_uring 的核心原理

3.1 两个环形队列

io_uring 在内核和用户空间之间创建了两个共享的环形队列:

  • 提交队列(SQ,Submission Queue):用户程序将请求(如读、写、accept)放入 SQ。

  • 完成队列(CQ,Completion Queue):内核处理完请求后,将结果放入 CQ。

用户程序只需将请求放入 SQ,然后通知内核;内核在后台处理,完成后将结果放入 CQ。用户程序可以在任意时刻从 CQ 中收割完成的事件。

这种设计实现了真正的异步:发起 IO 请求的线程不需要等待结果,内核异步执行,完成后通知。

3.2 共享内存与零拷贝

io_uring 使用 mmap 将内核中的 SQ 和 CQ 映射到用户空间,用户程序可以直接操作这些队列,无需通过系统调用进行数据拷贝。相比 epoll 需要将事件从内核拷贝到用户(epoll_wait 返回时拷贝),io_uring 进一步减少了开销。

3.3 三个核心系统调用

io_uring 只提供了三个系统调用,但通常我们使用封装库 liburing 来简化开发:

  • io_uring_setup:初始化 io_uring 实例,在内核中分配队列内存。

  • io_uring_enter:通知内核处理已提交的请求(也可以等待完成事件)。

  • io_uring_register:注册文件描述符或内存缓冲区,减少后续映射开销。

liburing 将这些系统调用封装成更易用的函数,如 io_uring_queue_initio_uring_get_sqeio_uring_submitio_uring_wait_cqe 等。

3.4 工作流程(以读请求为例)

  1. 用户调用 io_uring_get_sqe 从 SQ 中获取一个提交队列项。

  2. 使用 io_uring_prep_read 等函数填充该项(fd、buffer、长度等)。

  3. 调用 io_uring_submit 将 SQ 中待处理的请求提交给内核。

  4. 内核异步执行读操作,完成后将结果(读取的字节数)放入 CQ。

  5. 用户调用 io_uring_wait_cqeio_uring_peek_cqe 获取完成队列项。

  6. 处理完结果后,调用 io_uring_cqe_seen 标记该项已被消费,释放 CQ 空间。

四、io_uring 与 epoll 的对比

特性 epoll io_uring
模型 Reactor(事件就绪通知) Proactor(完成事件通知)
IO 操作 同步读写(用户主动调用 read/write) 异步读写(内核自动完成数据拷贝)
系统调用 每轮事件循环至少一次 epoll_wait,每次读写还有 recv/send 批量提交和收割,减少系统调用次数
内存拷贝 epoll_wait 返回事件数组需拷贝;read/write 需拷贝数据 共享内存队列,无额外拷贝(数据本身仍需拷贝到用户 buffer,但可注册固定 buffer 减少开销)
使用复杂度 较低,成熟稳定 稍高,需要理解环形队列和异步生命周期
适用场景 通用网络服务器,尤其是连接数极高但每个连接活跃度低 高 IOPS 场景(如数据库、对象存储)、结合高速网络和 NVMe 的零拷贝应用

五、io_uring 编程实战:TCP Server 示例

以下代码演示使用 io_uring 构建一个简单的 TCP 回显服务器,支持异步 accept、recv、send。

cpp 复制代码
#include <liburing.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>

#define QUEUE_DEPTH 256
#define MAX_EVENTS 128

// 自定义连接信息结构体,用于 user_data
struct conn_info {
    int fd;
    int event;  // EVENT_ACCEPT, EVENT_READ, EVENT_WRITE
};

enum {
    EVENT_ACCEPT = 1,
    EVENT_READ,
    EVENT_WRITE,
};

void set_event_accept(struct io_uring *ring, int listen_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *info = malloc(sizeof(struct conn_info));
    info->fd = listen_fd;
    info->event = EVENT_ACCEPT;
    // 准备 accept 操作
    io_uring_prep_accept(sqe, listen_fd, NULL, NULL, 0);
    io_uring_sqe_set_data(sqe, info);  // 保存自定义数据
}

void set_event_read(struct io_uring *ring, int client_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *info = malloc(sizeof(struct conn_info));
    info->fd = client_fd;
    info->event = EVENT_READ;
    char *buf = malloc(4096);
    io_uring_prep_recv(sqe, client_fd, buf, 4096, 0);
    io_uring_sqe_set_data(sqe, info);
    // 注意:需要将 buf 保存到 info 或其他地方以便写回,此处简化
}

void set_event_write(struct io_uring *ring, int client_fd, char *data, int len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *info = malloc(sizeof(struct conn_info));
    info->fd = client_fd;
    info->event = EVENT_WRITE;
    io_uring_prep_send(sqe, client_fd, data, len, 0);
    io_uring_sqe_set_data(sqe, info);
}

int main() {
    struct io_uring ring;
    struct io_uring_params params;
    memset(&params, 0, sizeof(params));
    // 初始化 io_uring,队列深度为 QUEUE_DEPTH
    io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

    // 创建 TCP 监听 socket
    int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_addr.s_addr = INADDR_ANY,
        .sin_port = htons(8888)
    };
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 128);

    // 提交第一个 accept 请求
    set_event_accept(&ring, listen_fd);
    io_uring_submit(&ring);

    struct io_uring_cqe *cqes[MAX_EVENTS];
    while (1) {
        // 等待至少一个完成事件
        int nready = io_uring_wait_cqe(&ring, &cqes[0]);
        if (nready < 0) {
            perror("io_uring_wait_cqe");
            break;
        }
        // 获取一批完成事件(非阻塞)
        int n = io_uring_peek_batch_cqe(&ring, cqes, MAX_EVENTS);
        for (int i = 0; i < n; i++) {
            struct io_uring_cqe *cqe = cqes[i];
            struct conn_info *info = (struct conn_info*)io_uring_cqe_get_data(cqe);
            int ret = cqe->res;  // 操作结果,如 accept 返回 client_fd,recv 返回字节数

            if (info->event == EVENT_ACCEPT) {
                if (ret >= 0) {
                    int client_fd = ret;
                    // 设置该 client 的读事件
                    set_event_read(&ring, client_fd);
                } else {
                    perror("accept failed");
                }
                // 重新提交 accept,接受下一个连接
                set_event_accept(&ring, listen_fd);
            } else if (info->event == EVENT_READ) {
                if (ret > 0) {
                    // 收到数据,写回客户端(简单回显)
                    // 实际使用中需要将数据保存到 info 中,这里省略
                    char *buf = (char*)(cqe->user_data); // 需要正确获取 buf,简化处理
                    set_event_write(&ring, info->fd, buf, ret);
                } else if (ret == 0) {
                    // 对端关闭
                    close(info->fd);
                } else {
                    perror("recv error");
                    close(info->fd);
                }
                free(info);
            } else if (info->event == EVENT_WRITE) {
                // 写完成,可以关闭或继续读
                free(info);
                // 可在此处再次设置读事件以保持长连接
                // set_event_read(&ring, info->fd);
            }
        }
        // 消费掉这批 CQE
        io_uring_cq_advance(&ring, n);
        // 提交新添加的 SQE
        io_uring_submit(&ring);
    }

    io_uring_queue_exit(&ring);
    return 0;
}

关键点说明:

  • 每个 SQE 通过 io_uring_sqe_set_data 绑定一个 conn_info 结构体,用于区分事件类型和携带 fd。

  • io_uring_peek_batch_cqe 批量获取完成事件,类似 epoll_wait

  • 处理完一个 accept 后,必须重新提交新的 accept 请求,否则无法接受新连接(与 epoll 不同,epoll 只需注册一次,而 io_uring 每次完成都需要重新提交)。

  • 内存管理:每个请求的缓冲区(如 recv 的 buf)需要妥善管理,可以在 conn_info 中保存指针,完成后释放或重用。

六、Reactor 与 Proactor 模式的区别

io_uring 的实现背后是 Proactor 模式,而 epoll 是典型的 Reactor 模式。两者区别如下:

对比维度 Reactor(epoll) Proactor(io_uring)
事件通知 通知"IO 就绪",仍需用户主动调用读写 通知"IO 完成",数据已经在内核/user buffer 中
读写操作 用户负责实际读写,可能阻塞 内核自动完成读写,用户无需再调用
用户代码复杂度 需要维护读写状态机,处理部分读写 只需提交请求并处理结果,逻辑更线性
系统调用次数 每事件至少 epoll_wait + read/write 批量提交和收割,系统调用少
适用场景 连接数极高、事件活跃度不均的网络服务 高吞吐、低延迟、大量读写操作的场景

三点核心区别总结:

  1. 完成时机不同:Reactor 通知的是"可以执行 IO 操作了",Proactor 通知的是"IO 操作已经完成"。

  2. 代码编写方式:Reactor 需要用户实现数据的读取和写入(可能非完全),Proactor 用户只需提交请求,内核帮忙完成数据搬运。

  3. 底层优化潜力:Proactor 更容易实现零拷贝(如注册内存缓冲区)和内核侧优化,Reactor 的读写操作仍然需要跨越用户态/内核态边界。

七、性能测试关注点

要评估 io_uring 相比 epoll 的优势,可以从以下几个维度进行测试:

  1. 吞吐量(QPS):使用相同的硬件和网络环境,压测工具(如 wrk)分别测试 epoll 和 io_uring 实现的 HTTP 服务器,对比不同并发下的请求处理能力。

  2. 延迟分布:测试平均延迟、P99、P999 延迟,观察 io_uring 是否在重负载下保持更低延迟。

  3. 系统调用开销 :使用 perfstrace 统计每请求系统调用次数,io_uring 批量提交模式下显著减少。

  4. CPU 占用率:在相同 QPS 下,比较 CPU 使用率;或者固定 CPU,测试最大 QPS。

  5. IOPS 测试 :对于磁盘读写场景,使用 fio 对比 io_uring 和 libaio 的性能差异。

常见测试参数建议:并发数 128、512、1024、2048,消息大小 64B、1KB、4KB,分别测试短连接和长连接。

相关推荐
REDcker4 小时前
Android Bionic Libc 原理与实现综述
android·c++·c·ndk·native·bionic
charlie1145141915 小时前
通用GUI编程技术——图形渲染实战(二十四)——GDI Region与裁切:不规则窗口与可视化控制
c++·windows·学习·c·图形渲染·win32
charlie1145141911 天前
嵌入式Linux驱动开发——模块参数与内核调试:让模块“活“起来的魔法
linux·驱动开发·学习·c
zaim13 天前
计算机的错误计算(二百二十六)
java·python·c#·c·错数·mpmath
charlie1145141914 天前
通用GUI编程技术——Win32 原生编程实战(二十二)——GDI 位图操作:BitBlt、StretchBlt 与图像处理
c++·windows·学习·c·win32
Felven5 天前
M. Minimum LCM
c
charlie1145141915 天前
嵌入式C++教程实战之Linux下的单片机编程:从零搭建 STM32 开发工具链(5):调试进阶篇 —— 从 printf 到完整 GDB 调试环境
linux·c++·单片机·学习·嵌入式·c
REDcker8 天前
C++ new、堆分配与 brk / mmap
linux·c++·操作系统·c·内存
qq_2837200510 天前
C++ 基础:STL 原理介绍 + 实用技巧
c++·stl·c·模板库