io_uring的机理和跟epoll的对比

在高性能网络编程的领域,我们一直在追求更低的延迟、更高的吞吐量。从 select、poll 到 epoll,每一次演进都带来了巨大的性能提升。而今天,io_uring 的出现,正引领着一场更深刻的变革。它不仅仅是 epoll 的简单升级,而是一种从根本上改变应用与内核交互方式的全新范式。

深入 io_uring 的内部:一套高效的"零拷贝"物流系统

io_uring 的高性能并非魔法,而是源于其精巧的内部设计。它在你的应用程序和内核之间,建立了一套基于共享内存的高效"物流系统"。

这套系统由两个核心组件构成:

  1. 提交队列 (Submission Queue - SQ): 你的"发货单"暂存区。

  2. 完成队列 (Completion Queue - CQ) : 内核送回的"收货回执"暂存区。

这两个队列存在于通过 mmap 创建的共享内存中,这意味着你的应用和内核可以像访问自家内存一样读写它们,彻底消除了传统系统调用中昂贵的内存拷贝开销

整个工作流程如下:

  1. 打包任务 (构建 SQE) : 你需要发的每一个 I/O 请求,都会被打包成一个"任务单元",即 io_uring_sqe (Submission Queue Entry)。这个结构体里详细记录了任务的所有信息:要操作的 fd、数据要读/写到哪个 buffer (addr)、buffer 的多长 (len)、具体的操作类型 (opcode) 等。

  2. "零拷贝"入队: 你的应用直接在共享内存的 SQ 中填写这个 SQE。这并非传统意义的拷贝,而是直接写入,效率极高。

  3. 通知内核: 通过一次 io_uring_submit() 系统调用(这是一个非常轻量的调用),直接批量把整个队列里的请求直接提交到系统里面。

  4. 内核异步处理: 内核的工作线程被唤醒,它直接从共享内存的 SQ 中取出任务,然后发起真正的异步 I/O 操作。在此期间,你的应用程序完全是自由的。

  5. 结果返回 : I/O 操作完成后,内核会在共享内存的 CQ 中放入一个"完成回执",即 io_uring_cqe (Completion Queue Entry),里面包含了操作的结果(如成功读取的字节数)。(注意此时想读写缓存里的内容已经被读进到咱们预先设置好的buffer 里面了)

  6. 应用获取结果: 你的应用通过检查 CQ 来获取已完成的任务结果,然后直接进行业务处理。

这套机制的核心优势在于:批量化、全异步、零拷贝

qps测试对比:

100W个包每个包 2 * 64Bytes

100W个包每个包 4 * 64Bytes

cpp 复制代码
#include <stdio.h>
#include <liburing.h>      // 包含了所有 io_uring 的函数和数据结构定义
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>


// 定义三种不同的事件类型,用于在请求完成时区分是哪种操作
#define EVENT_ACCEPT   	0  // 接受新连接事件
#define EVENT_READ		1  // 读数据事件
#define EVENT_WRITE		2  // 写数据事件

/**
 * @brief 连接信息结构体
 * 这个结构体作为上下文信息,会被附加到每一个提交给 io_uring 的请求 (SQE) 上。
 * 当请求完成后,内核会原封不动地将它返回到完成队列 (CQE) 中。
 * 这样,我们就能知道完成的这个事件是关于哪个 fd、属于哪种类型的操作。
 */
struct conn_info {
	int fd;     // 事件关联的文件描述符
	int event;  // 事件的类型 (EVENT_ACCEPT, EVENT_READ, or EVENT_WRITE)
};


/**
 * @brief 初始化TCP服务器
 * 这是一个标准的网络编程函数,用于创建、绑定和监听一个套接字。
 * @param port 服务器监听的端口号
 * @return 创建的监听套接字文件描述符
 */
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  // io_uring 队列的深度,表示可以同时处理多少个在途请求
#define BUFFER_LENGTH		1024  // 每个连接的读写缓冲区大小

/**
 * @brief 准备一个异步接收数据的请求
 * @param ring io_uring 实例的指针
 * @param sockfd 要接收数据的文件描述符 (连接 fd)
 * @param buf 接收数据的缓冲区
 * @param len 缓冲区的长度
 * @param flags 接收操作的标志,通常为0
 * @return int 
 */
int set_event_recv(struct io_uring *ring, int sockfd,
				      void *buf, size_t len, int flags) {
    
    // 1. 从提交队列 (Submission Queue, SQ) 中获取一个可用的空槽位 (Submission Queue Entry, SQE)
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

    // 2. 创建本次请求的上下文信息
	struct conn_info read_info = {
		.fd = sockfd,
		.event = EVENT_READ, // 标记这是一个读事件
	};
	
    // 3. 使用 liburing 提供的帮助函数来填充 SQE,告诉内核这是一个 recv 操作
	io_uring_prep_recv(sqe, sockfd, buf, len, flags);

    // 4. 将我们的上下文信息拷贝到 SQE 的 user_data 字段中
	memcpy(&sqe->user_data, &read_info, sizeof(struct conn_info));
    return 0;
}


/**
 * @brief 准备一个异步发送数据的请求
 */
int set_event_send(struct io_uring *ring, int sockfd,
				      void *buf, size_t len, int flags) {
    
    // 步骤同 set_event_recv
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

	struct conn_info write_info = {
		.fd = sockfd,
		.event = EVENT_WRITE, // 标记这是一个写事件
	};
	
	io_uring_prep_send(sqe, sockfd, buf, len, flags);
	memcpy(&sqe->user_data, &write_info, sizeof(struct conn_info));
    return 0;
}


/**
 * @brief 准备一个异步接受新连接的请求
 */
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);

	struct conn_info accept_info = {
		.fd = sockfd,        // 这里是监听的 sockfd
		.event = EVENT_ACCEPT, // 标记这是一个 accept 事件
	};
	
	io_uring_prep_accept(sqe, sockfd, (struct sockaddr*)addr, addrlen, flags);
	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 实例。这是最关键的一步。
    // 内核会创建提交队列 (SQ) 和完成队列 (CQ) 所需的共享内存。
	io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);
	
    // 准备并提交第一个 accept 请求。
    // 服务器必须先提交一个 accept 请求,才能接收客户端的连接。
	struct sockaddr_in clientaddr;	
	socklen_t len = sizeof(clientaddr);
	set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
	
	char buffer[BUFFER_LENGTH] = {0};

    // 进入主事件循环
	while (1) {
        
        // 1. 提交请求
        // 调用 io_uring_submit(),这是一个系统调用。
        // 它会通知内核去检查提交队列 (SQ) 中是否有新的请求需要处理。
        // 我们可以一次性准备多个请求,然后通过一次 submit 全部提交。
		io_uring_submit(&ring);

        // 2. 等待完成事件
		struct io_uring_cqe *cqe;
        // io_uring_wait_cqe() 会阻塞程序,直到完成队列 (Completion Queue, CQ) 中至少有一个完成事件 (CQE)。
        // 这类似于 epoll_wait() 的作用。
		io_uring_wait_cqe(&ring, &cqe);

        // 3. 批量获取完成事件
		struct io_uring_cqe *cqes[128];
        // io_uring_peek_batch_cqe() 尝试从 CQ 中一次性取出多个已完成的事件,非常高效。
        // 它返回实际获取到的事件数量。
		int nready = io_uring_peek_batch_cqe(&ring, cqes, 128);

		int i = 0;
        // 4. 遍历并处理所有已完成的事件
		for (i = 0; i < nready; i++) {

			struct io_uring_cqe *entries = cqes[i];
			struct conn_info result;
            // 从 CQE 的 user_data 中拷贝出我们之前存入的上下文信息
			memcpy(&result, &entries->user_data, sizeof(struct conn_info));

            // 根据事件类型,执行不同的逻辑
			if (result.event == EVENT_ACCEPT) {
                
                // 一个 accept 请求完成了,意味着一个新客户端连接进来了。
                // 我们需要立刻提交下一个 accept 请求,以便能继续接收其他客户端。
				set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
				
                // entries->res 字段存放了 I/O 操作的结果。
                // 对于 accept 操作,它就是新连接的文件描述符 connfd。
				int connfd = entries->res;

                // 为这个新连接提交一个读请求,准备接收客户端发来的数据。
				set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);

			} else if (result.event == EVENT_READ) {  // 一个读请求完成了

                // 对于读写操作,entries->res 是成功读/写的字节数。
				int ret = entries->res;

				if (ret == 0) { // ret 为 0 表示客户端关闭了连接
					close(result.fd);
				} else if (ret > 0) { // 成功读取到数据
                    // 提交一个写请求,将刚刚读取到的数据原样发回 (Echo Server)
					set_event_send(&ring, result.fd, buffer, ret, 0);
				}
			}  else if (result.event == EVENT_WRITE) { // 一个写请求完成了
				
                // 数据已经成功发回给客户端。
                // 继续为这个连接提交一个读请求,等待客户端的下一次数据。
				set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
			}
		}

        // 5. 标记事件已消费
        // io_uring_cq_advance() 告诉内核,我们已经处理完了 nready 个完成事件。
        // 内核可以安全地重用这部分完成队列的空间了。
        // 这一步非常重要,必须调用!
		io_uring_cq_advance(&ring, nready);
	}

}

结论:

epoll 已经足够优秀,但它仍然遵循"应用查询就绪态,应用执行I/O"的两步走模式。而 io_uring 则通过 Proactor 模式和基于共享内存的无锁队列,将这一过程简化为"应用提交任务,内核完成任务"的一步到位模式。极致的性能: 大幅减少了系统调用次数和用户态/内核态的切换,消除了内存拷贝,最大化地利用了CPU。

相关推荐
pandarking2 小时前
[CTF]攻防世界:easy_laravel 学习
java·学习·web安全·laravel·ctf
Han.miracle2 小时前
数据结构与算法--006 和为s的两个数字(easy)
java·数据结构·算法·和为s的两个数字
AAA简单玩转程序设计2 小时前
Java集合“坑王”:ArrayList为啥越界还能浪?
java·前端
AAA简单玩转程序设计2 小时前
别再把Java枚举当“花瓶”!它能办大事
java·前端
Java天梯之路2 小时前
Spring Boot 启动流程源码解析:从 `main()` 到 Web 服务就绪
java·spring boot·面试
漂亮的小碎步丶2 小时前
【3】Spring事务管理
java·数据库·spring
WZTTMoon2 小时前
Spring Boot Swagger3 使用指南
java·spring boot·后端·swagger3
Java天梯之路2 小时前
Spring Boot 钩子全集实战(一):构造与配置阶段
java·spring boot·面试
AuroraWanderll2 小时前
C++类和对象--访问限定符与封装-类的实例化与对象模型-this指针(二)
c语言·开发语言·数据结构·c++·算法