一、引言:从协程到异步 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_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 的对比
| 特性 | 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(¶ms, 0, sizeof(params));
// 初始化 io_uring,队列深度为 QUEUE_DEPTH
io_uring_queue_init_params(QUEUE_DEPTH, &ring, ¶ms);
// 创建 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 | 批量提交和收割,系统调用少 |
| 适用场景 | 连接数极高、事件活跃度不均的网络服务 | 高吞吐、低延迟、大量读写操作的场景 |
三点核心区别总结:
-
完成时机不同: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,分别测试短连接和长连接。