文章目录
异步IO之IO_Uring
简介:对于程序来说有异步与同步之分,同理对于单个API仍有同步与异步的差别。
引言
前面的文章我们介绍了程序中的异步编程(协程详解以及网络IO的协程框架 - +_+0526 - 博客园)。他们对于需要长时间等待的IO函数,例如:read()、write()、recv()、send()等,执行无法立即得到结果的阻塞函数,使用切换上下文、多路复用等方式避开其阻塞状态。究其根本在于上述IO函数在读写数据时始终需要在用户态/内核态之间切换,同时由于数据读写的滞后造成了线程的阻塞和运行低效。那么有没有一种技术可以从根源上解决问题?有的兄弟,有的。本文介绍的IO_Uring就旨在解决这一问题:提出了真正的异步IO,即提交请求立即返回,将任务交给内核,用户只需要提交请求即可得到响应。本文将从原理出发,使用一个简单的例子介绍用法,最后通过qps工具比较传统IO的epoll服务器方案于IO_Uring服务器方案之间的性能。
一、架构与原理
- 环形队列数据结构
之所以被叫做IO_Uring是因为它的核心就在于两个环形队列(Ring Buffer),操作系统同时维护两个环形队列:提交队列(Submission Queue,SQ)、完成队列(Completion Queue,CQ)。
SQ中的每一项数据被称为提交队列项(Submission Queue Entries, SQE),它作为用户与内核间通信的元数据。其常常包含以下属性:
struct io_uring_sqe {
__u8 opcode; // 操作码 (READ, WRITE, ACCEPT, etc.)
__u64 addr; // 数据地址 (缓冲区指针)
__u32 len; // 数据长度
__u64 user_data; // 用户标识符 (用于匹配请求)
// ... flags, fd, 其他参数
};
同理CQ中的每一项被称为完成队列项(Completion Queue Entries, CQE),用于承载IO操作的结果数据。
struct io_uring_cqe {
__u64 user_data; // 对应 SQE 的 user_data
__s32 res; // 操作结果 (类似系统调用返回值)
__u32 flags;
};
每一个SQE通过user_data
对应一个CQE,表示这是同一个IO的读写操作。光组织这两个环形队列并没有很大意义,IO_Uring其高性能的源头在于程序SQE通过mmap直接将数据写到读取到内核缓冲区中的SQ中,后续的工作只需要交给内核,完美避开了状态的切换,实现了数据的零拷贝操作。而内核完成了提交的IO操作,同样放入CQ中,用户就读取到IO操作的结果数据。
- 高性能核心机制
基于双环形队列,在填充SQ时,用户作为生产者只修改队尾;而内核作为消费者只操作队头,CQ反之。这种机制确保了在不使用锁的情况下做到同步执行。
同时IO_Uring提供的io_uring_enter接口支持批处理SQE:一次性提交多个SQE,系统维持一个顺序链,指明SQE的提交顺序,确保事件可以正确按照提交顺序执行。
为进一步提高程序执行效率,IO_Uring还提供了轮询机制:在启用IORING_SETUP_SQPOLL后,内核创建专属轮询线程,持续轮询sq_ring队尾,检测到更新立即通知Worker线程马上消费SQ中的请求。
二、基于IO_Uring的高并发服务器
介绍了这么多相关概念,最重要的还是要落在实践。本文介绍了基于IO_Uring的服务器echo程序囊括了常用的接口,以及通用的编程流程。需要注意的是,IO_Uring需要Linux内核5.4及以上版本支持。
- 初始化IP与端口
初始化服务器IP与监听端口:
int init_server(unsigned short port) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(struct sockaddr_in));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons(port);
if (-1 == bind(sockfd, (struct sockaddr*)&serveraddr, sizeof(struct sockaddr))) {
perror("bind");
return -1;
}
listen(sockfd, 10);
return sockfd;
}
- 初始化IO_Uring参数
初始化params结构体,该结构体包含了SQ、CQ的核心参数:
struct io_uring_params {
__u32 sq_entries; // 请求的提交队列大小
__u32 cq_entries; // 请求的完成队列大小
__u32 flags; // 配置标志
__u32 sq_thread_cpu; // SQ轮询线程绑定的CPU
__u32 sq_thread_idle; // SQ轮询线程空闲超时(ms)
__u32 features; // 内核返回的支持特性
__u32 wq_fd; // 异步工作队列fd
__u32 resv[3]; // 保留字段
struct io_sqring_offsets sq_off; // SQ内存布局偏移
struct io_cqring_offsets cq_off; // CQ内存布局偏移
};
初始化io_uring实例结构体,它包含了所有队列的状态:
struct io_uring {
struct io_uring_sq sq; // 提交队列状态
struct io_uring_cq cq; // 完成队列状态
unsigned flags; // 特征标志
int ring_fd; // io_uring文件描述符
unsigned features; // 支持特性
void *pad[3]; // 填充字段
};
调用io_uring_queue_init_params接口,初始化上述结构体。调用该函数会在底层调用__sys_io_uring_setup填充params中的偏移量和实际队列的大小,同时将SQ与CQ队列的指针映射到内核缓冲区中。
struct io_uring_params params;
memset(¶ms,0,sizeof(params));
struct io_uring ring;
//ENTRIES_LENGTH 请求的队列深度
//ring 输出的io_uring实例
//params 输入/输出参数结构体
io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms);
至此我们就得到了一个初始化的一整套IO_Uring机制。
- IO操作
对于网络场景,accept、send、recv这些IO操作如何通过IO_Uring发起请求并接受数据呢?
IO_Uring提供的accept操作函数原型如下:
void io_uring_prep_accept(struct io_uring_sqe *sqe,
int sockfd,
struct sockaddr *addr,
socklen_t *addrlen,
int flags);
对照系统着accept函数接口发现除了需要一个SEQ队列的指针其他并没有什么区,但它们在底层却是完全不同的操作:当该函数被调用,填充SQE结构,简化如下:
void io_uring_prep_accept(struct io_uring_sqe *sqe, ...) {
memset(sqe, 0, sizeof(*sqe)); // 清空sqe
sqe->opcode = IORING_OP_ACCEPT; // 设置操作码
sqe->fd = sockfd; // 目标套接字
sqe->off = 0; // 偏移量(未使用)
sqe->addr = (unsigned long)addr; // 客户端地址缓冲区
sqe->len = *addrlen; // 地址缓冲区长度
sqe->accept_flags = flags; // 额外标志
sqe->user_data = 0; // 用户自定义标识(可后续设置)
}
此时该SQE还未被提交到内核,相当于在队列中进行注册。
int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags){
//取到提交队列
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
//异步io操作
struct conn_info accept_info = {
.fd = sockfd,
.event = EVENT_ACCEPT
};
//将事件放到提交队列中 regist
io_uring_prep_accept(sqe,sockfd,(struct sockaddr*)addr,addrlen,flags);
//因为sqe仍然是指针 所以仍可以对其成员进行操作
memcpy(&sqe->user_data,&accept_info,sizeof(struct conn_info));
}
所以与accept不同io_uring_prep_accept仅仅是根据用户态数据做的准备工作,进一步的操作直到用户主动提交后内核才会对该IO进行处理。
其余IO操作:send、recv同理它们都是通过用户数据填充SQE准备用户态数据。
- submit提交
服务器循环接受请求,用户先调用接口set_event_accept准备数据,io_uring_submit提交请求到内核,通过io_uring_enter通知内核。此时内核检测到IORING_OP_ACCEPT操作,异步处理accept操作:
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
set_event_accept(&ring,sockfd,(struct sockaddr*)&clientaddr,&len,0);
while(1){
//将提交队列交给worker
int submitted = io_uring_submit(&ring);
if (submitted < 0) {
fprintf(stderr, "Error submitting I/O: %d\n", submitted);
break;
}
...
}
此时用户请求交付给内核接管并操作,那么如何取到操作结果呢?
- wait与就绪队列获取数据
用户提交请求后紧接着将当前线程阻塞等待,直到CQ中有一个完成事件可用。
while(1){
...//提交操作
struct io_uring_cqe *cqe;
//代码在此处进入阻塞
int ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
fprintf(stderr, "Error waiting for CQE: %d\n", ret);
break;
}
struct io_uring_cqe *cqes[128];
//此处的cqes[128] 是按照位置对应在就绪队列的位置
int nready = io_uring_peek_batch_cqe(&ring,cqes,128);//类比 epoll_wait
...
}
此时底层通过__io_uring_peek_cqe检查CQ中是否有就绪事件,若此时CQ为空则进入阻塞状态。直到内核向CQ中写入新数据后唤醒该线程,同时还需要结合接口io_uring_peek_batch_cqe获取完成的CQE。
unsigned io_uring_peek_batch_cqe(struct io_uring *ring,
struct io_uring_cqe **cqes,
unsigned count);
批量获取CQE,count值指定期望一次性获取的CQE数量。
- 处理事件
此时cqes中储存所有完成事件,依次处理这些事件:
for(i = 0; i < nready; i++) {
struct io_uring_cqe *entries = cqes[i];
struct conn_info result;
memcpy(&result, &entries->user_data, sizeof(struct conn_info));
if(result.event == EVENT_ACCEPT) {
// 处理新连接
} else if(result.event == EVENT_READ) {
// 处理读取数据
} else if(result.event == EVENT_WRITE) {
// 处理写入数据
}
}
在新连接到来时,获取到新连接的文件描述符,继续提交一个accept的SQE用于继续监听连接;为获取到的新连接准备一个EVENT_READ状态的SQE,准备接收数据:
if(result.event == EVENT_ACCEPT) {
set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
printf("set_event_accept\n");
int connfd = entries->res;
set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);
}
当完成事件状态为EVENT_READ,说明当前事件可读,从res中读取到数据后提交一个EVENT_WRITE事件向客户端回发数据。
else if(result.event == EVENT_READ) {
int ret = entries->res;
printf("set_event_recv ret: %d, %s\n", ret, buffer);
if (ret == 0) {
close(result.fd); // 客户端关闭连接
} else if (ret > 0) {
set_event_send(&ring, result.fd, buffer, ret, 0);
}
}
处理发送数据事件同理:
else if (result.event == EVENT_WRITE) {
int ret = entries->res;
printf("set_event_send ret: %d, %s\n", ret, buffer);
set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
}
至此一个简单的服务器echo程序完成。
qps测试与比对
光说不练假把式,这里我们使用基于epoll的事件驱动reactor与IO_Uring对比qps,使用简易发包工具(TCP简易发包工具 - +_+0526 - 博客园),测试在不同包长的情况下的数据处理耗时。
- 64字节数据、50个线程、100个连接、1000000次请求:
- 128字节数据、50个线程、100个连接、1000000次请求:
- 256字节数据、50个线程、100个连接、1000000次请求:
- 512字节数据、50个线程、100个连接、1000000次请求:
可以看到在相同数据量级的条件下IO_Uring始终要比epoll在qps上有一定的胜出。
问题
问题一:同样都是零拷贝IO_Uring与DPDK有什么不同?
mmap仅可以对内核中的限制部分进行数据控制,而DPDK可以不受任何限制操作内核中直接映射的物理地址。
特性 | mmap(如 io_uring 中的应用) | DPDK 中的零拷贝 |
---|---|---|
应用场景 | 通用 I/O(如文件、网络) | 高性能网络数据平面处理 |
内存映射对象 | 内核缓冲区(如文件页缓存、套接字缓冲区) | 直接映射物理内存(绕过内核协议栈) |
用户控制程度 | 部分控制(通过共享内存) | 完全控制(直接操作网卡和内存) |
内核参与度 | 依赖内核处理 I/O 操作 | 内核旁路(Kernel Bypass) |
性能目标 | 减少系统调用和数据拷贝 | 极致网络包处理性能(百万级 PPS) |
问题二:用户程序通过io_uring_wait_cqe阻塞线程。同样都会导致线程阻塞,那么IO_Uring比传统IO高并发IO操作优势在哪?
从结果上来说它们都会阻塞线程。
传统IO每进行一次读写操作都需要经过一次完整的系统调用,发生耗时的用户态/内核态之间的切换,特别是用户需要同时读写多个文件只有依次等待读写操作的完成。
IO_Uring则可以同时将多个IO请求提交到SQ队列中,在底层完全只需要依次系统调用io_uring_enter或循环队列中的轮询实现,即使在这个过程中发生阻塞,但是它仅仅是等待请求队列中的任一个就绪。
例如如果程序需要读写100个文件。
传统方式需要依次进行100次状态切换。
IO_Uring通过往循环队列中提交SQE,io_uring_wait_cqe阻塞线程到有任务完成,相当于把100次系统调用分摊给100个IO操作,大大降低单个IO操作所消耗的事件。
问题三:IO_Uring与epoll的操作大部分都类似,那么它们的区别在哪里?
从代码流程上来说,它们的接口与操作逻辑大致相同,都是通过初始化将事件交付等待就绪。
epoll核心在于高效地监控多个文件描述符的状态(EPILLIN,EPOLLOUT),简单来说它告诉你哪些fd可以进行IO操作了。
IO_Uring不仅是通知用户哪些IO已经就绪,并且在同时时已经将结果带回,换言之它接管了IO操作控制了用户态与内核态之间的切换。
epoll | io_uring | |
---|---|---|
核心功能 | I/O 就绪事件通知 | 异步 I/O 操作执行引擎 |
操作对象 | 文件描述符 (FD) 状态 | 具体的 I/O 操作请求 (SQE) |
关键优势 | 高效监控大量 FD 状态变化 | 超低开销批处理、真正异步执行、功能广泛 |
系统调用 | epoll_wait + 每个 I/O 操作都需 read /write 等 | 批量提交 (一次 io_uring_enter 提交多个 SQE) |
异步性 | 通知异步,执行同步 (仍需调 syscall) | 请求提交和操作执行完全异步 |
适用场景 | 高并发连接管理 (通知谁可读/可写) | 极高 IOPS 需求 (网络/磁盘)、需要执行广泛异步操作 |
编程模型 | Reactor (通知 + 应用执行) | Proactor-like (请求 + 内核执行 + 结果通知) |
高级特性 | 无 | 注册缓冲/文件、内核轮询、丰富操作类型 |
总结
IO_Uring作为最新Linux的异步IO框架,通过环形队列和内存映射实现了真正意义上的零拷贝技术。从epoll的事件接管fd将IO交给回调函数执行,到将IO提交就绪队列进行接管,只给用户暴露提交和结果。用户只需要填充SQE将其提交到SQ中立即返回,此时IO_Uring通过一系列技术实现异步IO执行,最后将数据填充到CQE装入CQ中,用户只需要在获取结果时通过io_uring_wait_cqe获取数据。最后通过在不同包长的情况下与epoll的qps对比得出性能差异。
相关链接:https://github.com/0voice