异步IO之IO_Uring

异步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操作的结果数据。

填充参数
mmap共享内存
ring%entries触发
执行I/O
写入结果
mmap共享内存
读取结果
SQE: opcode+参数
CQE: 结果+user_data
用户程序
SQE数组
SQE队列
Worker线程
磁盘/网络
CQE队列
CQE数组
  • 高性能核心机制

​ 基于双环形队列,在填充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(&params,0,sizeof(params));

    struct io_uring ring;
	//ENTRIES_LENGTH 请求的队列深度
	//ring 输出的io_uring实例
	//params 输入/输出参数结构体
    io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);

​ 至此我们就得到了一个初始化的一整套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操作控制了用户态与内核态之间的切换。

epollio_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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值