io_uring:高性能异步 I/O

目录

io_uring 原理及核心数据结构

io_uring 和 epoll的区别 

io_uring实现一个TCP服务器

io_uring适用内核版本为5.10以上


io_uring 原理及核心数据结构

io_uring 来自资深内核开发者 Jens Axboe 的想法,他在 Linux I/O stack 领域颇有研究。 从最早的 patch aio: support for IO polling 可以看出,这项工作始于一个很简单的观察:随着设备越来越快, 中断驱动(interrupt-driven)模式效率已经低于轮询模式 (polling for completions) —— 这也是高性能领域最常见的主题之一。

  1. 在设计上是真正异步的(truly asynchronous)。只要 设置了合适的 flag,它在系统调用上下文中就只是将请求放入队列, 不会做其他任何额外的事情,保证了应用永远不会阻塞

  2. 支持任何类型的 I/O:cached files、direct-access files 甚至 blocking sockets。

    由于设计上就是异步的(async-by-design nature),因此无需 poll+read/write 来处理 sockets。 只需提交一个阻塞式读(blocking read),请求完成之后,就会出现在 completion ring。

  3. 灵活、可扩展:基于 io_uring 甚至能重写(re-implement)Linux 的每个系统调用。

每个 io_uring 实例都有两个环形队列(ring),在内核和应用程序之间共享:

提交队列:submission queue (SQ)

完成队列:completion queue (CQ)

这两个队列:

都是单生产者、单消费者,size 是 2 的幂次;

提供无锁接口(lock-less access interface),内部使用 内存屏障做同步(coordinated with memory barriers)。

使用方式

  • 请求

    应用创建 SQ entries (SQE),更新 SQ tail;内核消费 SQE,更新 SQ head。
  • 完成

    • 内核为完成的一个或多个请求创建 CQ entries (CQE),更新 CQ tail;
    • 应用消费 CQE,更新 CQ head。
    • 完成事件(completion events)可能以任意顺序到达,到总是与特定的 SQE 相关联的。
    • 消费 CQE 过程无需切换到内核态。

io_uring 和 epoll的区别 

epoll 和 io_uring 都是 Linux 下用于处理 I/O 事件的机制,它们有以下区别:

设计理念与用途

  • epoll:是基于 poll 和 select 改进的 I/O 多路复用机制,专门用于监控多个文件描述符(如 socket、文件等 )上的事件(读、写、异常等 )。它采用事件驱动模式,只在注册的文件描述符发生事件时触发,避免无效扫描。适用于事件驱动的网络编程场景,像监视多个客户端连接的服务器 。比如 Nginx、Redis 等都基于 epoll 构建。
  • io_uring:是更广泛的异步 I/O 框架,不仅用于事件通知,还能直接执行 I/O 操作。旨在提高大规模并发 I/O 操作性能,可处理网络 I/O、文件 I/O、内存映射等多种场景,目标是实现 Linux 下一切基于文件概念的异步编程 。

实现机制

  • epoll:使用红黑树管理需要监听的文件描述符,用一个事件队列存放 I/O 就绪事件。调用 epoll_wait 时,内核将已就绪的事件从内核空间拷贝到用户空间,用户程序依次处理这些事件。若有大量事件就绪,需多次系统调用处理。
  • io_uring:基于两个共享环形缓冲区,即提交队列(SQ )和完成队列(CQ ) 。用户程序将 I/O 请求写入 SQ,内核处理完 I/O 操作后,把结果写入 CQ,用户程序从 CQ 异步获取结果。通过这种方式,减少了用户态到内核态的上下文切换次数,且支持批量提交和处理 I/O 请求 。比如初始化 1000 个 I/O 请求,可一次提交,而 epoll 需逐个处理 。

I/O 操作类型支持

  • epoll:主要功能是监听和处理文件描述符上的事件,本身不直接执行 I/O 操作。当监听到事件就绪后,仍需通过系统调用(如 readwrite 等 )来完成实际的 I/O 读写 。
  • io_uring:不仅能处理事件通知,还可直接执行多种 I/O 操作,如读写文件、网络 I/O 操作(sendrecvaccept 等 ) 。

阻塞与非阻塞特性

  • epollepoll_wait 可设置为阻塞或非阻塞模式,通常情况下是阻塞的,直到有事件发生才返回 。
  • io_uring:支持完全异步的操作,通过提交和完成队列机制实现非阻塞 I/O 。应用程序提交 I/O 请求后无需等待,可继续执行其他任务,内核处理完后将结果放入完成队列 。

性能表现

  • 在高并发场景下,io_uring 性能优势明显。测试显示,连接数 1000 及以上时,io_uring 性能开始超越 epoll ,其极限性能单 core 在 24 万 QPS 左右,而 epoll 单 core 只能达到 20 万 QPS 左右 。io_uring 能极大减少用户态到内核态的切换次数,在连接数超过 300 时,其用户态到内核态的切换次数基本可忽略不计 。不过在某些特殊场景(如 meltdown 和 spectre 漏洞未修复时 ),io_uring 相对 epoll 的性能提升不明显甚至略有下降 。

编程复杂度

  • epoll:相对简单,开发者只需关注文件描述符的事件注册(epoll_ctl )和事件处理(epoll_wait 返回后的逻辑 ) 。
  • io_uring:功能强大但开发复杂度较高。需深入理解提交队列和完成队列工作机制,手动管理 I/O 请求的提交、结果获取,以及处理队列初始化、事件提交与回收等操作 

io_uring实现一个TCP服务器

#include <stdio.h>
#include <liburing.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>
#include"func.h"


#define EVENT_ACCEPT   	0
#define EVENT_READ		1
#define EVENT_WRITE		2


struct conn_info{
	int fd;
	int event;
};

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;
}

#define ENTRIES_LENGTH      1024
#define BUFFER_LENGTH		1024

int set_event_recv(struct io_uring *ring,int sockfd,void*buffer,size_t len,int flags){
	struct io_uring_sqe *sqe =  io_uring_get_sqe(ring);//从ring中获取一个提交队列
	
	struct conn_info accept_info={
		.fd = sockfd,
		.event=EVENT_READ,
	};

	io_uring_prep_recv(sqe,sockfd,buffer,len,flags);
	memcpy(&sqe->user_data,&accept_info,sizeof(struct conn_info));
	return 0;
}

int set_event_send(struct io_uring *ring,int sockfd,void*buffer,size_t len,int flags){
	struct io_uring_sqe *sqe =  io_uring_get_sqe(ring);//从ring中获取一个提交队列
	
	struct conn_info accept_info={
		.fd = sockfd,
		.event=EVENT_WRITE,
	};

	io_uring_prep_send(sqe,sockfd,buffer,len,flags);
	memcpy(&sqe->user_data,&accept_info,sizeof(struct conn_info));
	return 0;
}

int set_event_accept(struct io_uring*ring,int sockfd,struct sockaddr *addr,socklen_t* len,int flag){
	struct io_uring_sqe *sqe =  io_uring_get_sqe(ring);//从ring中获取一个提交队列
	
	struct conn_info accept_info={
		.fd = sockfd,
		.event=EVENT_ACCEPT,
	};

	io_uring_prep_accept(sqe,sockfd,(struct sockaddr*)addr,len,flag);
	memcpy(&sqe->user_data,&accept_info,sizeof(struct conn_info));
	return 0;
}



int main(int argc , char*argv[]){
     
	unsigned short port = 9999;
	int sockfd = init_server(port);

	struct io_uring_params params;
	memset(&params, 0, sizeof(params));

	struct io_uring ring;
	io_uring_queue_init_params(ENTRIES_LENGTH,&ring,&params);//初始化
    //ring这个结构体包含了提交队列和完成队列

    struct sockaddr_in clinaddr;
    socklen_t clinlen = sizeof(clinaddr);
    set_event_accept(&ring,sockfd,(struct sockaddr*)&clinaddr,&clinlen,0);

	char buffer[BUFFER_LENGTH]={0};

	while(1){
		io_uring_submit(&ring);
		// io_uring 提交队列中的 I/O 操作请求提交给内核进行处理


		struct io_uring_cqe *cqe;
		io_uring_wait_cqe(&ring,&cqe);//
		//该函数会阻塞当前进程,直到 io_uring 完成队列中有新的 I/O 操作完成事件。
		//存储到 cqe 指针中。

		struct io_uring_cqe *cqes[128];
		int nready = io_uring_peek_batch_cqe(&ring,cqes,sizeof(cqes));

		int i;
		for(i=0;i<nready;i++){
			struct io_uring_cqe *entries=cqes[i];
			struct conn_info reslut;
			memcpy(&reslut,&entries->user_data,sizeof(struct conn_info));

			if(reslut.event==EVENT_ACCEPT){
				set_event_accept(&ring,sockfd,(struct sockaddr*)&clinaddr,&clinlen,0);
				// printf("set_event_accept\n"); //

				int condfd= entries->res;
				set_event_recv(&ring, condfd, buffer, BUFFER_LENGTH, 0);
			}else if(reslut.event==EVENT_READ){
				int ret =entries->res;
				// printf("set_event_recv ret: %d, %s\n", ret, buffer); 
				if(ret==0){
					close(reslut.fd);
				}else{
					set_event_send(&ring, reslut.fd, buffer, BUFFER_LENGTH, 0);
				}
			}else if(reslut.event==EVENT_WRITE){

				int ret =entries->res;
				// printf("set_event_send ret: %d, %s\n", ret, buffer);
				set_event_recv(&ring, reslut.fd, buffer, BUFFER_LENGTH, 0);
			}

		}
		io_uring_cq_advance(&ring,nready);
		
	}
    
}

struct io_uring_params 结构体包含了多个字段,用于配置 io_uring 队列的各种行为和特性,例如提交队列(SQ)和完成队列(CQ)的条目数量、标志位、线程相关参数等。

io_uring结构体 它代表了整个 io_uring 实例,包含了提交队列(Submission Queue,SQ)和完成队列(Completion Queue,CQ)。

io_uring_queue_init_params此函数会根据 params 结构体中的参数对 ring 进行初始化,从而设置提交队列和完成队列的大小、特性等。

io_uring_prep_accept函数的作用是将一个 accept 操作封装到 io_uring 的提交队列条目(Submission Queue Entry, SQE)中。accept 操作通常用于在服务器端接受客户端的连接请求,当有新的客户端连接时,该操作会返回一个新的套接字描述符,用于与客户端进行通信。

// io_uring_submit函数作用是把io_uring 提交队列中的 I/O 操作请求提交给内核进行处理

io_uring_wait_cqe 是 io_uring 库提供的一个函数,用于等待完成队列中有新的 I/O 操作完成事件。

int nready = io_uring_peek_batch_cqe(&ring, cqes, sizeof(cqes)); 是 io_uring 库中的一个关键操作,用于批量从完成队列(Completion Queue, CQ)中获取已完成的 I/O 操作条目

与epoll取就绪队列有几分相似

io_uring_prep_recv(sqe, sockfd, buffer, len, flags); 是 io_uring 库中的一个函数调用,用于准备一个接收数据的 I/O 操作,并将其添加到 io_uring 的提交队列条目(Submission Queue Entry,SQE)中。

io_uring_prep_send(sqe, sockfd, buffer, len, flags); 是 io_uring 库中的一个关键函数调用,其主要作用是准备一个发送数据的 I/O 操作,并将这个操作封装到 io_uring 的提交队列条目(Submission Queue Entry, SQE)中

io_uring_cq_advance 函数位于一个 for 循环之后,这个 for 循环用于遍历从完成队列中获取到的所有完成条目,并根据条目的事件类型(如 EVENT_ACCEPTEVENT_READEVENT_WRITE)进行相应的处理。处理完所有完成条目后,调用 io_uring_cq_advance 函数,告知 io_uring 实例这些条目已经处理完毕。

io_uring适用内核版本为5.10以上