io_uring 深度解析

张开发
2026/6/7 14:10:47 15 分钟阅读
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 方案存在诸多不足AIOlibaio只支持 O_DIRECT 方式的磁盘 IO不支持网络 socket且接口复杂使用场景受限。epoll事件通知模型通知就绪后仍需同步执行读写存在用户态/内核态切换和数据拷贝开销。线程池模拟为每个请求创建一个线程上下文切换开销大无法支撑海量连接。随着 NVMe SSD 和高速网络的普及软件层面的开销逐渐成为瓶颈。io_uring 应运而生它借鉴了 SPDK 等用户态驱动的设计思想通过共享内存环形队列实现零拷贝、系统调用减少和真正的异步提交/完成。三、io_uring 的核心原理3.1 两个环形队列io_uring 在内核和用户空间之间创建了两个共享的环形队列提交队列SQSubmission Queue用户程序将请求如读、写、accept放入 SQ。完成队列CQCompletion 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_init、io_uring_get_sqe、io_uring_submit、io_uring_wait_cqe等。3.4 工作流程以读请求为例用户调用io_uring_get_sqe从 SQ 中获取一个提交队列项。使用io_uring_prep_read等函数填充该项fd、buffer、长度等。调用io_uring_submit将 SQ 中待处理的请求提交给内核。内核异步执行读操作完成后将结果读取的字节数放入 CQ。用户调用io_uring_wait_cqe或io_uring_peek_cqe获取完成队列项。处理完结果后调用io_uring_cqe_seen标记该项已被消费释放 CQ 空间。四、io_uring 与 epoll 的对比特性epollio_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。#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_fdrecv 返回字节数 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模式。两者区别如下对比维度ReactorepollProactorio_uring事件通知通知“IO 就绪”仍需用户主动调用读写通知“IO 完成”数据已经在内核/user buffer 中读写操作用户负责实际读写可能阻塞内核自动完成读写用户无需再调用用户代码复杂度需要维护读写状态机处理部分读写只需提交请求并处理结果逻辑更线性系统调用次数每事件至少 epoll_wait read/write批量提交和收割系统调用少适用场景连接数极高、事件活跃度不均的网络服务高吞吐、低延迟、大量读写操作的场景三点核心区别总结完成时机不同Reactor 通知的是“可以执行 IO 操作了”Proactor 通知的是“IO 操作已经完成”。代码编写方式Reactor 需要用户实现数据的读取和写入可能非完全Proactor 用户只需提交请求内核帮忙完成数据搬运。底层优化潜力Proactor 更容易实现零拷贝如注册内存缓冲区和内核侧优化Reactor 的读写操作仍然需要跨越用户态/内核态边界。七、性能测试关注点要评估 io_uring 相比 epoll 的优势可以从以下几个维度进行测试吞吐量QPS使用相同的硬件和网络环境压测工具如 wrk分别测试 epoll 和 io_uring 实现的 HTTP 服务器对比不同并发下的请求处理能力。延迟分布测试平均延迟、P99、P999 延迟观察 io_uring 是否在重负载下保持更低延迟。系统调用开销使用perf或strace统计每请求系统调用次数io_uring 批量提交模式下显著减少。CPU 占用率在相同 QPS 下比较 CPU 使用率或者固定 CPU测试最大 QPS。IOPS 测试对于磁盘读写场景使用fio对比 io_uring 和 libaio 的性能差异。常见测试参数建议并发数 128、512、1024、2048消息大小 64B、1KB、4KB分别测试短连接和长连接。

更多文章