io_uring的介绍和实现

io_uring的核心思想: 通过用户态和内核态共享的内存区域, 实现零拷贝的请求提交和完成通知

代码见最下方

三大设计原则
  1. 零拷贝: 请求和响应通过内存共享传递, 无需数据拷贝
  2. 零系统调用: 理想情况下, I/O操作完全不需要系统调用
  3. 批处理友好: 一次可以提交多个请求, 一次可以收割多个完成

两个环形缓冲区

  1. 提交队列(SQ):Submission Queue 用于提交IO操作。
  2. 完成队列(CQ):Completion Queue 用于记录IO操作的结果。

这两个缓冲区在 内核 和 用户空间 之间共享,提升吞吐量,避免缓冲区频繁的复制。

通过io_uring可以将本来需要多个系统调用的操作批处理成一组由内核执行的操作,从而消除大量的上下文切换和系统调用的开销。

io_uring和epoll有什么区别?

两者都适用于异步网络操作,但epoll不进行io操作,只是一个就绪指示器,只能用于部分文件。io_uring不限于网络系统的调用。

io_uring不仅仅是异步IO,而是通用的系统调用批处理接口。

io_uring不是一个事件系统,而是一个通用的一部系统调用机制。

​ 经典的UNIX I/O系统调用(比如read())都是同步且阻塞 的。你调用它们,程序就会挂起,直到请求的操作完成。对于read()来说,这意味着"数据已经到达"。

问题在于,如果你想同时从多个地方读取数据 该怎么办?如果我们在从一个地方读取时阻塞了,而另一个地方有了数据,却因为阻塞而没被处理怎么办?它可能会一直等下去!

​ 为了解决这个问题,传统上我们可以使用非阻塞模式或者设置闹钟来中断操作,但这些方法都有局限。最终,最有效的方式是使用描述符就绪机制 ,这样我们可以说"当这些描述符中的任何一个有数据可读时叫醒我,并告诉我哪些描述符已经就绪"。之后,我们依次调用每个已就绪的描述符,并确保这些调用不会阻塞,因为我们知道它们都有数据在等待处理。

​ 然而,io_uring的思路完全不同。它从根本上重新审视了这个问题:与其等内核告诉我们某些事情准备就绪,以便我们去处理,不如我们提前告诉内核我们想做什么,把想做的事放到一个队列中,让内核自己去做,做完后内核再放到另外一个队列中让我们来取结果

​ 这几乎总是我们想要的结果。在传统模型中,用户程序首先调用内核询问某个操作是否就绪,接着立刻再次调用内核来执行这个操作。用户程序在两次调用之间什么都不做。如果我们能将这两个步骤合并,让内核自已处理整个过程,用户程序就不需要参与了。

​ io_uring的操作也相对简单:它把一次调用拆成两步一一你提交一个请求,稍后拿到结果。尽管请求和响应的编码方式不同(调用名称和参数存放在提交的内存缓冲区中,而不是传统的函数调用),效果是一样的一一内核受理用户程序的请求。

​ 整个过程依赖两个队列:一个是提交队列,你把请求放进去;另一个是完成队列,你从中获取执行结果。

核心结构体:

  1. struct io_uring : io_uring 实例
  2. struct io_uring_params: 实例的参数
  3. struct io_uring_cqe :cq队列的元素,内核存放处理完成事件的队列 中的元素
  4. struct io_uring_sqe:sq队列的元素,用户提交给内核 需要进行处理的事件队列 中的元素

核心函数:

  1. io_uring_queue_init_params() : io_uring结构体初始化函数
  2. io_uring_get_sqe():在sq队列中申请一个元素sqe
  3. io_uring_get_cqe():在cq队列中申请一个元素cqe
  4. io_uring_prep_accept():将accept操作添加到sqe队列的函数
  5. io_uring_prep_recv():将recv操作添加到sqe队列的函数
  6. io_uring_prep_send():将send操作添加到sqe队列的函数
  7. io_uring_submit():提交sqe队列,通知内核来处理事件的函数
  8. io_uring_wait_cqe():等待内核处理完成一个事件的函数
  9. io_uring_peek_batch_cqe():批量获取内核处理完成事件的函数
  10. io_uring_cq_advance():更新cq队列指针的函数

怎么识别 添加到sq队列的元素 和 从cq队列中取出来的元素?

io_uring_cqe 和 io_uring_sqe 结构体都有一个参数user_data,这个参数可以用来识别。在初始化io_uring_sqe 时赋值;在cq队列取出来时检测。

那么赋值什么呢?通常赋值一个结构体,user_data和该结构体都是8个字节

复制代码
struct conn_info {
	int fd;		// 操作的文件描述符(监听 fd/连接 fd)
	int event;	// 事件类型(EVENT_ACCEPT/READ/WRITE)
};
// 对结构体进行赋值
struct conn_info accept_info = {
    .fd = sockfd,
    .event = EVENT_READ,
};
// 对user_data进行赋值
memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));

对io_uring代码进行三点说明:

  1. io_uring会在while外面调用一次io_uring_prep_accept函数,而epoll是在while里面调用accept函数。因为使用iouring必须先告诉内核 我要执行什么操作,内核才会执行什么操作 , 而epoll是直接监听sockfd,内核直接告诉我,下面有哪些fd触发的事件。 所以使用uring必须先告诉内核 我要执行什么操作 accept/recv/send ,如果没有这个accept,不告诉内核我要accept,那么这个程序就不能正常启动,只会在while中空转。 一次sqe,内核只会执行一次。
  2. while里面的逻辑:
    1. 提交申请,通知内核,执行sqe
    2. 阻塞等待内核至少返回一个处理结果(确保有返回的结果)
    3. 批量读取内核返回的结果
    4. for循环遍历这些结果
    5. 如果是ACCEPT事件(内核已经完成accept操作),就把建立连接和读取这个连接的数据加入sqe
    6. 如果是RECV事件(内核已经完成recv操作),就把 发送数据 加入sqe
    7. 如果是SEND事件(内核已经完成send操作),就把 读取数据 加入sqe
    8. for循环结束,向前推进,处理完的事件

这些加入到sqe的事件,会在for循环结束后 while执行到submit时,异步执行

  1. struct io_uring_cqe结构体的res参数需要特殊说明。
    1. 对accept:res>0:返回客户端的fd res<0: 失败
    2. 对recv:res>0:读取到的字节数 res=0:关闭连接 res<0:失败
    3. 对send:res>0:发送成功的字节数 res<0: 失败

uring_tcp_server.c 有注释

复制代码
#include <stdio.h>
#include <liburing.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>

#define EVENT_ACCEPT    0  // 接受连接事件
#define EVENT_READ      1  // 读数据事件
#define EVENT_WRITE     2  // 写数据事件

struct conn_info {
	int fd;		// 操作的文件描述符(监听 fd/连接 fd)
	int event;	// 事件类型(EVENT_ACCEPT/READ/WRITE)
};
/*
	双队列:SQ:提交队列 CQ:完成队列
	直接提交「I/O 操作」给内核,内核完成后通过 CQ 通知用户
	可一次性提交多个SQE,仅需调用一次io_uring_submit,也可一次性获取多个CQE
*/

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;
}
// 两个队列sq cq 大小
#define ENTRIES_LENGTH		256
// 缓存区大小
#define BUFFER_LENGTH		256

int set_event_recv(struct io_uring *ring, int sockfd,
				      void *buf, size_t len, int flags) {
	// 获取一个空的 SQE
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

/*
struct conn_info :4+4=8 字节
__u64	user_data; unsigned long long :8 字节
将user_data 一分为二,可以存储两个参数
使用 堆内存 + 指针 是可靠安全的,这种memcpy不推荐

为什么先io_uring_prep_recv载memcpy
因为先初始化再赋值。io_uring_prep_recv是初始化函数
*/
	struct conn_info accept_info = {
		.fd = sockfd,
		.event = EVENT_READ,
	};
	// 初始化 SQE:异步 recv 操作
	io_uring_prep_recv(sqe, sockfd, buf, len, flags);
	// 将上下文写入 SQE 的 user_data 字段(后续 CQE 会带回该数据)
	// user_data是用户自己设置的对这个连接的标识,内核不会修改
	memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));
}


int set_event_send(struct io_uring *ring, int sockfd,
				      void *buf, size_t len, int flags) {

	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	struct conn_info accept_info = {
		.fd = sockfd,
		.event = EVENT_WRITE,
	};
	
	io_uring_prep_send(sqe, sockfd, buf, len, flags);
	memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));

}

int set_event_accept(struct io_uring *ring, int sockfd, struct sockaddr *addr,
					socklen_t *addrlen, int flags) {
	// io_uring 的提交队列(SQ)中获取一个空闲的 sqe
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);

	struct conn_info accept_info = {
		.fd = sockfd,
		.event = EVENT_ACCEPT,
	};
	
	// void io_uring_prep_accept 除了第一个参数其他的都于accept()参数一致 
	// 初始化异步 accept 操作,告诉 sqe 要执行accept异步操作
	// 初始化请求,但不执行,submit申请后,才会异步执行accept操作
	io_uring_prep_accept(sqe, sockfd, (struct sockaddr*)addr, addrlen, flags);
	memcpy(&sqe->user_data, &accept_info, sizeof(struct conn_info));

}

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));
	// 创建 io_uring 实例,队列长度 ENTRIES_LENGTH=1024
	struct io_uring ring;
/*
int io_uring_queue_init_params (
unsigned int entries, // 提交队列(SQ)和完成队列(CQ)的大小,即队列能容纳的最大请求数
struct io_uring *ring, // 指向 io_uring 结构体的指针,该结构体用于管理 io_uring 实例
const struct io_uring_params *p // 用于传递初始化的参数,若为 NULL,则使用默认参数
);
返回值:成功时返回 0,失败时返回负的错误码。
*/
	io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);

	// 提交第一个异步 accept 操作(监听客户端连接)
	struct sockaddr_in clientaddr;	
	socklen_t len = sizeof(clientaddr);
	set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
/*
    这里accept只是初始化,而不会执行,在submit后才会执行
    那这里为什么还要调用accept
    和epoll的accept逻辑有什么区别?
    epoll是在while外面先将sockfd加入监听,然后在while里面
    取出sockfd上的事件,然后再accept建立新连接,并对新的客户端fd进行监听

    这里iouring中while外面的accept有何用?

    iouring必须先告诉内核 我要执行什么操作,内核才会执行什么操作
    而epoll是直接监听sockfd,内核直接告诉我,下面有哪些fd触发的事件
    所以使用uring必须先告诉内核 我要执行什么操作 accept/recv/send
    如果没有这个accept,不告诉内核我要accept,那么这个程序就不能正常启动,只会在while中空转
    一次sqe,内核只会执行一次
*/	
	// 数据缓冲区(用于读写客户端数据)
	char buffer[BUFFER_LENGTH] = {0};
/*
while里面的逻辑:
1. 提交申请,通知内核,执行sqe
2. 阻塞等待内核至少返回一个处理结果(确保有返回的结果)
3. 批量读取内核返回的结果
4. for循环遍历这些结果
5. 如果是ACCEPT事件(内核已经完成accept操作),就把建立连接和读取这个连接的数据加入sqe
6. 如果是RECV事件(内核已经完成recv操作),就把 发送数据 加入sqe
7. 如果是SEND事件(内核已经完成send操作),就把 读取数据 加入sqe
8. for循环结束,向前推进,处理完的事件
这些加入到sqe的事件,会在for循环结束后 while执行到submit时,异步执行
*/
	while (1) {
		// 把所有待执行的异步请求(SQE)交给内核
		io_uring_submit(&ring);
		
		struct io_uring_cqe *cqe;
		// 等待内核处理完至少一个请求
		io_uring_wait_cqe(&ring, &cqe);
/*
struct io_uring_cqe {
    __s32    res;        // 异步操作的执行结果
对accept:res>0:返回客户端的fd res<0: 失败
对recv:res>0:读取到的字节数 res=0:关闭连接  res<0:失败
对send:res>0:发送成功的字节数 res<0: 失败
    __u32    flags;      // 操作的标志位(几乎不用)
    __u64    user_data;  // 用户自定义数据(核心!与SQE的user_data一致)
};
*/
		// 批量获取所有已完成的请求结果(CQE)包括第一个
		struct io_uring_cqe *cqes[128];
		int nready = io_uring_peek_batch_cqe(&ring, cqes, 128);  // epoll_wait
		
		// 遍历处理所有已完成的 CQE
		int i = 0;
		for (i = 0;i < nready;i ++) {

			struct io_uring_cqe *entries = cqes[i];
			// 从 CQE 的 user_data 取出上下文
			struct conn_info result;
			memcpy(&result, &entries->user_data, sizeof(struct conn_info));
			// 处理 异步 accept 完成
            if (result.event == EVENT_ACCEPT) {
/*
这里在accept完成后又进行了accept操作,如果不再进行accept操作,那么是不是就不能建立新连接了?
accept 完成后必须立刻重新注册一个新的 accept 事件。

屏蔽了这里的accept函数后,还可以建立多个连接,但只有第一个连接可以收发数据。
屏蔽了 "处理完第一个 accept 后重新注册新 accept" 的代码,但内核的监听队列还在:后续客户端发起连接时,
内核依然会完成三次握手,把连接放进监听队列,所以客户端视角看 "能建立多个连接";
但因为没有重新注册 accept 事件,服务器应用层无法从监听队列取出后续连接,会一直积压在内核里,应用层完全 "看不见"。

那如果建立了连接后,再次accept操作,而没有新的连接,那么是不是就一直在监听这sockfd的accept操作
一旦有了新连接,就去建立?
只要没有新连接,这个 accept 请求就会 "挂起" 在 io_uring 的等待队列中,不占用 CPU;
一旦有新连接到达,内核会立刻完成这个 accept 请求,触发 CQE(完成事件),
服务器处理后再注册新的 accept,如此循环,保证始终有一个 accept 请求在监听,不会漏接新连接。
*/
                // 重新提交异步 accept(保证持续监听新连接)
                set_event_accept(&ring, sockfd, (struct sockaddr*)&clientaddr, &len, 0);
                // 获取 accept 的结果:客户端连接 fd
                int connfd = entries->res;
                // 为新连接提交异步 recv 操作(等待客户端发数据)
                set_event_recv(&ring, connfd, buffer, BUFFER_LENGTH, 0);
            }// 处理 异步 recv 完成   
			else if (result.event == EVENT_READ) {
                // recv 的返回值:>0 表示读取到字节数;0 表示客户端关闭连接;<0 错误
                int ret = entries->res;
                if (ret == 0) {
                    // 客户端关闭连接,关闭 fd
                    close(result.fd);
                } else if (ret > 0) {
                    // 读取到数据,提交异步 send 操作(回显数据给客户端)
                    set_event_send(&ring, result.fd, buffer, ret, 0);
                }
            }// 处理 异步 send 完成    
			else if (result.event == EVENT_WRITE) {
				int ret = entries->res;
				//printf("set_event_send ret: %d, %s\n", ret, buffer);
				// send 完成后,重新提交异步 recv(等待客户端下一次发数据)
				set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
			}
		}
		// 推进/更新 CQ 队列的指针(告知内核已处理完这些 CQE)
		io_uring_cq_advance(&ring, nready);
	}
}

无注释版本

复制代码
#include <stdio.h>
#include <liburing.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>

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

#define ENTRIES_LENGTH 128
#define BUFFER_LENGTH  128

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(serveraddr));
    serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
    serveraddr.sin_family = AF_INET;
    serveraddr.sin_port = htonl(port);
    if(-1 == bind(sockfd,(struct sockaddr*)&serveraddr,sizeof(struct sockaddr))){
        perror("bind");
        return -1;
    }
    listen(sockfd, 10);
    return sockfd;
}
// user_data是用户自己设置的对这个连接的标识,内核不会修改
int set_event_accept(struct io_uring *ring,
    int sockfd,struct sockaddr * clientaddr,socklen_t * addrlen,int flags){
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info accept_info = {
        .fd = sockfd,
        .event = EVENT_ACCEPT,
    };
    io_uring_prep_accept(sqe, sockfd, clientaddr, addrlen, flags);
    memcpy(&sqe->user_data, &accept_info, sizeof(accept_info));
}

int set_event_recv(struct io_uring *ring,int connfd,
                void *buffer,size_t len,int flags){
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info accept_info =
    {
        .fd = connfd,
        .event = EVENT_READ,
    };
    io_uring_prep_recv(sqe, connfd, buffer, len, flags);
    memcpy(&sqe->user_data, &accept_info, sizeof(accept_info));
}
int set_event_send(struct io_uring *ring,int connfd,
                void *buffer,size_t len,int flags){
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info accept_info =
    {
        .fd = connfd,
        .event = EVENT_WRITE,
    };
    io_uring_prep_send(sqe, connfd, buffer, len, flags);
    memcpy(&sqe->user_data, &accept_info, sizeof(accept_info));
}
int main(int argc,char * argv[]){
    unsigned short port = 2048;
    int sockfd = init_server(port);

    struct io_uring ring;
    struct io_uring_params params;
    memset(&params, 0, sizeof(params));
    io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);

    struct sockaddr_in clientaddr;
    socklen_t len = sizeof(clientaddr);
    set_event_accept(&ring, sockfd, (struct sockaddr *)&clientaddr, &len, 0);
    char buffer[BUFFER_LENGTH];
    while(1)
    {
        io_uring_submit(&ring);
/*
struct io_uring_cqe {
    __s32    res;        // 异步操作的执行结果
对accept:res>0:返回客户端的fd res<0: 失败
对recv:res>0:读取到的字节数 res=0:关闭连接  res<0:失败
对send:res>0:发送成功的字节数 res<0: 失败
*/
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);

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

        int i = 0;
        for (i = 0; i < nready; i++){
            struct io_uring_cqe *entry = cqes[i];
            struct conn_info result;
            memcpy(&result, &entry->user_data, sizeof(result));
            if(result.event == EVENT_ACCEPT){
                set_event_accept(&ring, sockfd,
                                 (struct sockaddr *)&clientaddr, &len, 0);
                int connfd = entry->res;
                set_event_recv(&ring,connfd, buffer, BUFFER_LENGTH, 0);
            }else if(result.event == EVENT_READ){
                int ret = entry->res;
                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){
                set_event_recv(&ring, result.fd, buffer, BUFFER_LENGTH, 0);
            }
        }
        io_uring_cq_advance(&ring, nready);
    }
    return 0;
}
相关推荐
BingoGo1 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php
JaguarJack1 小时前
OpenSwoole 26.2.0 发布:支持 PHP 8.5、io_uring 后端及协程调试改进
后端·php·服务端
JaguarJack1 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
后端·php·服务端
BingoGo1 天前
推荐 PHP 属性(Attributes) 简洁读取 API 扩展包
php
JaguarJack2 天前
告别 Laravel 缓慢的 Blade!Livewire Blaze 来了,为你的 Laravel 性能提速
后端·php·laravel
郑州光合科技余经理3 天前
代码展示:PHP搭建海外版外卖系统源码解析
java·开发语言·前端·后端·系统架构·uni-app·php
feifeigo1233 天前
matlab画图工具
开发语言·matlab
dustcell.3 天前
haproxy七层代理
java·开发语言·前端
norlan_jame3 天前
C-PHY与D-PHY差异
c语言·开发语言
多恩Stone3 天前
【C++入门扫盲1】C++ 与 Python:类型、编译器/解释器与 CPU 的关系
开发语言·c++·人工智能·python·算法·3d·aigc