IO_uring

本文详细介绍了io_uring接口的结构,包括SubmissionQueueEntry和CompletionQueueEvent,以及关键函数如io_uring_setup、io_uring_enter和io_uring_register。讲解了如何创建、操作和管理io环,以及liburing的简化使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

io_uring 的接口

io_uring使用两个队列来传递IO请求和完成情况,这两个队列中的元素结构如下:

 // Submission Queue Entry
struct io_uring_sqe {
   __u8 opcode;        //请求类型,例如IORING_OP_READV
   __u8 flags;         //
   __u16 ioprio;       //优先级,和ioprio_set系统调用的作用类似
   __s32 fd;           //需要操作的文件fd
   __u64 off;          //文件偏移位置
   __u64 addr;         //读写数据地址,如果是readv/writev请求则是iovec数组地址
   __u32 len;          //读写数据长度,如果是readv/writev请求则是iovec数组长度
   union {
     __kernel_rwf_t rw_flags;    //请求相关的选项,其含义与对应的blocking syscall相同,例如preadv2
     __u32 fsync_flags;
     __u16 poll_events;
     __u32 sync_range_flags;
     __u32 msg_flags;   
   };
   __u64 user_data;    //使用者任意指定的字段,在复制到对应的cqe中,一般用于标识cqe与sqe的对应关系。
   union {
     __u16 buf_index;
     __u64 __pad2[3];
   };
};
 
// Completion Queue Event
struct io_uring_cqe {
   __u64 user_data;    //来自对应的sqe中的user_data字段
   __s32 res;          //请求处理结果,和普通IO操作的返回值差不多。一般成功时返回字节数,处理失败时返回-errno。
   __u32 flags;        //暂未使用
};

int io_uring_setup

int io_uring_setup(unsigned entries, struct io_uring_params *params);

这个函数返回一个io_uring的fd,后续通过这个fd来操作io_uring。entries是创建出的io_uring中包含的sqe(请求)数量,必须是1-4096间的2的幂级数。io_uring_params是一个与内核的交互参数,用户态调用者在其中指定需要的参数,内核也在其中反馈实际创建的情况,其定义和解释如下:


struct io_uring_params {
    __u32 sq_entries;                    /* IO请求sqe数量,内核输出 */
    __u32 cq_entries;                    /* IO完成事件cqe数量,内核输出 */
    __u32 flags;                         /* io_uring运行模式和配置,调用者输入 */
    __u32 sq_thread_cpu;
    __u32 sq_thread_idle;
    __u32 resv[5];                       /* 预留空间,用于对其cacheline,同时为将来扩展留下空间 */
    struct io_sqring_offsets sq_off;     /* sqe队列的偏移地址 */
    struct io_cqring_offsets cq_off;     /* cqe队列的偏移地址 */
};
 
struct io_sqring_offsets {
   __u32 head;            /* offset of ring head */
   __u32 tail;            /* offset of ring tail */
   __u32 ring_mask;       /* ring mask value */
   __u32 ring_entries;    /* entries in ring */
   __u32 flags;           /* ring flags */
   __u32 dropped;         /* number of sqes not submitted */
   __u32 array;           /* sqe index array */
   __u32 resv1;
   __u64 resv2;
};

这里需要关注的是io_sqring_offsets和io_cqring_offsets。这是内核分配的ring结构中需要用户态操作部分的相对偏移,用户态程序需要使用mmap将ring结构的内存映射到用户态来供后续交互:

struct app_sq_ring {
    unsigned *head;
    unsigned *tail;
    unsigned *ring_mask;
    unsigned *ring_entries;
    unsigned *flags;
    unsigned *dropped;
    unsigned *array;
};
 
struct app_sq_ring app_setup_sq_ring(int ring_fd, struct io_uring_params *p)
{
    struct app_sq_ring sqring;
    void *ptr;
    ptr = mmap(NULL, p→sq_off.array + p→sq_entries * sizeof(__u32),
    PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
    ring_fd, IORING_OFF_SQ_RING);
    sring→head = ptr + p→sq_off.head;
    sring→tail = ptr + p→sq_off.tail;
    sring→ring_mask = ptr + p→sq_off.ring_mask;
    sring→ring_entries = ptr + p→sq_off.ring_entries;
    sring→flags = ptr + p→sq_off.flags;
    sring→dropped = ptr + p→sq_off.dropped;
    sring→array = ptr + p→sq_off.array;
    return sring;
}

这里的一个疑问是为什么要让用户态程序再调用一次mmap,而不是在内核中就将地址映射好直接返回,这显然让接口的使用复杂度上升了很多。笔者认为这么做唯一的意义在于尽量保留了用户态程序对地址空间的控制力,可能在一些特殊场景下程序会需要特定的地址空间用于特殊用途,内核直接映射可能引入难以发现的问题。为了解决接口易用性的问题,liburing中封装了io_uring_queue_init接口,对于没有上述特殊需求的程序,直接使用这个接口即可。

io_uring_enter

int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);

在程序向sqring,即请求队列中插入了IO请求后,需要通知内核开始处理,这时就需要调用io_uring_enter。参数中的fd是io_uring的fd,to_submit是提交的IO请求数。

min_complete可以用来阻塞等待内核完成特定数量的请求,前提是flags中设置IORING_ENTER_GETEVENTS。这个功能可以单独调用来等待内核处理完成。需要注意的是由于采用共享内存队列的方式来同步请求完成情况,因此程序也可以不使用这个接口而是直接判断cqring的状态来获取IO完成情况并处理cqring中的完成事件。

io_uring_register

int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);

IORING_REGISTER_FILES。内核异步处理sqe请求时,需要保证fd不会在处理过程中被关闭,因此需要在开始处理前增加fd引用计数,结束后再减少。而调用这个接口后就可以避免这种反复的引用计数操作。在调用后指定的文件fd的引用计数会增加,后续提交请求时只要在sqe的flags中指定IOSQE_FIXED_FILE就不会再修改引用计数。如果不再需要操作这个fd,可以用IORING_UNREGISTER_FILES这个opcode解除注册。
IORING_REGISTER_BUFFERS。在使用O_DIRECT模式时,内核需要先映射用户态的页面,处理完后再解除映射,这也是一种重复开销。使用这个opcode后,就可以把指定的buffer页面固定映射到内核中,处理请求时就不需要反复映射、解除映射

// io_uring, tcp server
// multhread, select/poll, epoll, coroutine, iouring
// reactor
// io 

#include <liburing.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#define ENTRIES_LENGTH		4096

enum {

	READ,
	WRITE,
	ACCEPT,

};

struct conninfo {
	int connfd;
	int type;
};

void set_read_event(struct io_uring *ring, int fd, void *buf, size_t len, int flags) {
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	io_uring_prep_recv(sqe, fd, buf, len, flags);
	struct conninfo ci = {
		.connfd = fd,
		.type = READ
	};
	memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));
}

void set_write_event(struct io_uring *ring, int fd, const void *buf, size_t len, int flags) {
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	io_uring_prep_send(sqe, fd, buf, len, flags);
	struct conninfo ci = {
		.connfd = fd,
		.type = WRITE
	};
	memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));
}

void set_accept_event(struct io_uring *ring, int fd,
	struct sockaddr *cliaddr, socklen_t *clilen, unsigned flags) {
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	io_uring_prep_accept(sqe, fd, cliaddr, clilen, flags);
	struct conninfo ci = {
		.connfd = fd,
		.type = ACCEPT
	};
	memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));

}

int main() {
	int listenfd = socket(AF_INET, SOCK_STREAM, 0);  // 
    if (listenfd == -1) return -1;
// listenfd
    struct sockaddr_in servaddr, clientaddr;
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(9999);
    if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) {
            return -2;
    }
	listen(listenfd, 10);
	struct io_uring_params params;
	memset(&params, 0, sizeof(params));
// epoll --> 
	struct io_uring ring;
	io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);
	socklen_t clilen = sizeof(clientaddr);
	set_accept_event(&ring, listenfd, (struct sockaddr*)&clientaddr, &clilen, 0);
	char buffer[1024] = {0};
//
	while (1) {
		struct io_uring_cqe *cqe;
		io_uring_submit(&ring);
		int ret = io_uring_wait_cqe(&ring, &cqe);
		struct io_uring_cqe *cqes[10];
		int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10);
		int i = 0;
		unsigned count = 0;
		for (i = 0;i < cqecount;i ++) {
			cqe = cqes[i];
			count ++;
			struct conninfo ci;
			memcpy(&ci, &cqe->user_data, sizeof(ci));
			if (ci.type == ACCEPT) {
				int connfd = cqe->res;
				set_read_event(&ring, connfd, buffer, 1024, 0);
			} else if (ci.type == READ) {
				int bytes_read = cqe->res;
				if (bytes_read == 0) {
					close(ci.connfd);
				} else if (bytes_read < 0) {
				} else {
					printf("buffer : %s\n", buffer);
					set_write_event(&ring, ci.connfd, buffer, bytes_read, 0);
				}
			} else if (ci.type == WRITE) {
				set_read_event(&ring, ci.connfd, buffer, 1024, 0);
			}
		}
		io_uring_cq_advance(&ring, count);
	}
}

liburing

上文中提到,由于io_uring要实现强大的功能和最优的效率,因此其接口和使用方式会比较复杂。但对于大部分不需要极致IO性能的场景和开发者来说,只使用io_uring的基本功能就能获得大部分的性能收益。当只需要基本功能时,io_uring的复杂接口中很大一部分是不会使用的,同时一部分初始化操作也是基本不变的。因此,io_uring的作者又开发了liburing来简化一般场景下io_uring的使用。使用liburing后,io_uring初始化时的大部分参数都不再需要填写,也不需要自己再做内存映射,内存屏障和队列管理等复杂易错的逻辑也都封装在liburing提供的简单接口中,大幅降低了使用难度。
io_uring的创建与销毁

在liburing中封装了io_uring的创建与销毁操作接口,几乎不需要指定任何参数即可完成创建:

  1. struct io_uring ring;

  2. io_uring_queue_init(ENTRIES, &ring, 0);

  3. io_uring_queue_exit(&ring);

提交请求与处理完成事件

struct io_uring_sqe sqe;
struct io_uring_cqe cqe;
/* get an sqe and fill in a READV operation */
sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iovec, 1, offset);
/* tell the kernel we have an sqe ready for consumption */
io_uring_submit(&ring);
/* wait for the sqe to complete */
io_uring_wait_cqe(&ring, &cqe);
/* read and process cqe event */
app_handle_cqe(cqe);
io_uring_cqe_seen(&ring, cqe);

上面这段代码是io_uring作者提供的样例,其中体现了几个关键操作接口:

io_uring_get_sqe(&ring),从请求队列中获取一个空闲的请求结构。
io_uring_prep_readv(sqe, fd, &iovec, 1, offset),构造一个readv读请求。
io_uring_submit(&ring),将请求提交给内核。
io_uring_wait_cqe(&ring, &cqe),等待请求完成。这是一个阻塞操作,还有一个非阻塞版本:io_uring_peek_cqe。
io_uring_cqe_seen(&ring, cqe),通知内核已经完成对完成事件的处理。

通过这些接口,开发者已经能够很容易的写出异步IO的代码。

### 如何在C++中使用io_uring进行异步IO操作 #### 创建和初始化io_uring实例 为了利用`io_uring`执行异步I/O,在程序启动之初需先建立一个`io_uring`对象。此过程涉及指定参数,如入口数量等配置项[^3]。 ```cpp #include <liburing.h> struct io_uring ring; int ret; ret = io_uring_queue_init(32, &ring, 0); if (ret < 0) { perror("queue init failed"); } ``` 这段代码展示了如何导入必要的头文件并尝试创建具有默认选项的`io_uring`队列,其中包含了最多32个待处理的操作请求。 #### 提交读写请求到SQ(提交队列) 一旦有了可用的`io_uring`结构体,就可以准备向其提交具体的I/O指令了。对于简单的文件读取来说: ```cpp struct iovec iov; iov.iov_base = buf; // 假设buf是一个预分配好的缓冲区地址 iov.iov_len = sizeof(buf); struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, &iov, 1, offset); // 文件描述符fd,偏移量offset由外部提供 ``` 这里定义了一个`iovec`类型的变量用来表示数据传输的目标位置,并通过`io_uring_prep_read()`函数设置好相应的读取命令给定至特定文件的位置上。 #### 获取已完成的任务结果 当应用程序准备好检查是否有任何已结束的任务时,则可以从CQ(完成队列)里提取信息出来: ```cpp struct io_uring_cqes cqe; unsigned head; while ((head = io_uring_peek_batch_cqe(&ring, &cqe, 1)) != UINT_MAX) { struct io_uring_cqe *cqeptr = &cqe[0]; if (cqeptr->res >= 0) { /* 成功 */ } else { /* 失败 */ } io_uring_cq_advance(&ring, 1); } // 完成所有事件处理后记得同步队列状态 io_uring_submit_and_wait(&ring, 1); ``` 上述片段说明了怎样循环遍历当前存在的完成条目列表,并依据返回的状态码判断每次操作的成功与否。最后一步确保即使没有新的任务加入也能让CPU知道我们已经完成了现有工作的确认工作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值