性能媲美epoll的io_uring

24 篇文章 36 订阅
17 篇文章 2 订阅
文章介绍了同步和异步的概念,以网络编程为例,解释了epoll在高并发下的局限性。然后提出异步处理方案,通过队列和内存映射实现零拷贝,减少性能损耗。io_uring是Linux内核5.10后引入的新技术,它提供了类似Windowsiocp的高性能异步IO功能,简化了io_uring的使用。最后,文章给出了使用liburing库实现高并发服务器的示例代码,并总结了io_uring的优势和限制。
摘要由CSDN通过智能技术生成

前言

在聊 io_uring之前,我们先聊两个概念:同步异步

同步

所谓的同步,也就是说,所有事情的发生,都是按照一条时间线串行进行的。下一件事情必定要等到当前事情执行结束并返回结果,才能执行。

用在网络编程上,就好比一旦进入了 read函数阻塞,则下面的所有步骤都不可进行,accept函数同样也是如此。

即便是号称Linux下性能最高效的 epoll,如果单线程执行,他其实也是同步的。

比如使用 epoll同时监听百万个并发,当有上万个可读事件同时触发时,它仍然要排队挨个处理(除非开启多线程,使用线程池),这样 socket buffer一旦被挤满了,即使再有客户端发来消息,也是触发不了可读事件了。

因此,这是单线程 epoll处理高并发必然会带来的瓶颈。

异步

所谓的异步,它强调的是无需等待事件返回,即可进行下一步骤的操作。等于说不用一直卡在某个地方,导致时间浪费。

比如上述的场景,如果我们想要异步去做,该如何实现呢?

一种很容易想到的方法,是使用队列来做异步解耦。队列做异步解耦的思想比较常见,比如 kafka就是这一思想的集大成者。当然我们这里用不了这么复杂。

简单一点的实现,即:我们专门用一个线程,用来监听文件描述符是否可读,一旦有可读事件,先将消息读出来,push到读队列中,处理实际业务的主线程去消费读队列中的数据。可写事件也是如此,业务线程不管描述符是否可写,直接将数据push到写队列中。当可写事件触发的时候,这个专门的线程将写队列中的数据消费出来,发送给对端。

换言之,当业务线程能从读队列消费到数据的时候,它拿到的就已经是从fd中读出来的数据了。这个听起来是不是和Windows下的iocp比较相似?

有人可能有疑问:既然这样,那我们是不是相当于把数据进行了两次拷贝?从内核空间到队列,再从队列到业务空间?那比常规的直接读取多了一次拷贝,如何能提升性能呢?

解决办法当然是有的,那就是使用mmap内存映射,这样相当于队列中引用的仅仅是这块内存的指针,做到数据的零拷贝。

要实现这一套,只需要两个队列,一个读队列,我们叫 cq,一个写队列,我们叫 sq,外加一个 mmap管理内存池即可。

io_uring

有人可能觉得,这些东西说起来一套一套的,但实现起来未必那么简单,还是要单独开启一个线程,和 epoll比起来也没觉得有多少优势,要是也能像 epoll那样,内核将这一套管起来,那才算有那么点意思。

诚如所愿,Linux内核5.10以后,引入了 io_uring,他就是这样一种技术,通过该技术,可以实现Linux下达到Windows系统iocp相似的性能效果,甚至要略高于epoll

io_uring主要提供三个接口,分别为:

  • io_uring_setup

    • 函数原型:

      • int io_uring_setup(u32 entries, struct io_uring_params *p)
        
    • 函数作用:

      • 创建一个sq和一个cq
    • 参数说明:

      • entires: 队列的大小
      • p: 用来配置io_uring环形队列,内核返回的cqsq的信息也通过该参数带回来
    • 返回值:

      • 返回一个文件描述符fd,应用随后可以将这个文件描述符传给mmap进行调用,用来映射环形队列的内存,以及作为后续io_uring_registerio_uring_enter操作的句柄。
  • io_uring_register

    • 函数原型:

      • int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args)
        
    • 函数作用:

      • 注册用于异步IO的文件或用户缓冲区,使内核能长时间持有对该文件 在内核内部的数据结构引用,或创建应用内存的长期映射
        • 这个操作只会在注册时执行一次,而不是每个IO请求都会处理,因此减少了IO开销
    • 参数说明:

      • fdio_uring_setup返回的文件描述符

      • opcode:操作代码,用来指定被锁定资源的类型及方式, 常用的如:

        • IORING_REGISTER_BUFFERS
          • arg指向一个包含nr_args向的struct iovec数组,与iovec相关的缓冲区将被锁定在内存中
        • IORING_REGISTER_BUFFERS2
          • arg指向io_uring_rsrc_register结构体,nr_args应该设置为结构体中的字节数
        • IORING_REGISTER_BUFFERS_UPDATE
          • 用新的缓冲区更新已注册的缓冲区,要么将稀疏条目转换为真实条目,要么替换现有条目。
          • arg必须包含一个指向io_uring_rsrc_update2结构体的指针,其中包含开始更新的偏移量,以及一个数组
            结构。
      • arg

        • 一般与opcode结合使用
      • nr_args

        • 一般与opcode结合使用
    • 返回值:

      • 成功返回0或一个特定值,取决于opcode的设置
      • 失败返回负数
  • io_uring_enter

    • 函数原型:

      • int io_uring_enter(unsigned int fd,unsigned int to_submit,
        	unsigned int min_complete,unsigned int flags,sigset_t *sig);
        
    • 函数作用:

      • 使用共享的 SQCQ初始化和完成IO
      • 单次调用同时执行:提交新的 I/O 请求;等待 I/O 完成。
    • 参数说明:

      • fd:io_uring_setup返回的文件描述符
      • to_submit:指定要从提交队列提交的I/O数。
    • 返回值:

      • 成功返回使用的I/O数量。如果to_submit为零或提交队列为空,则该值可以为零。注意,如果创建环形队列时指定了IORING_SETUP_SQPOLL,则返回值通常与to_submit相同,因为提交发生在系统调用的上下文之外。

liburing

内核提供了这几个接口,但是由于其参数复杂,opcode众多,因此它并不像epoll那样用起来轻量级,sqcq需要我们自己去构建,内存也需要我们自己去管理。这一套实现起来还是相当麻烦的。好在,网上早就有大神把这一套封装好了,liburing就是这样一套 开源库,我们可以直接拿去使用。

下载安装方法如下所示:

git clone https://github.com/axboe/liburing.git
./configure
make
sudo make install

io_uring实现高并发服务器

我们结合liburing,实现一个高并发服务器的代码如下:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>

#include <liburing.h>

#define ENTRIES_LENGTH		1024

enum {
	EVENT_ACCEPT = 0,
	EVENT_READ,
	EVENT_WRITE
};

typedef struct _conninfo {
	int connfd;
	int event;
} conninfo;


void set_send_event(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) {
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	io_uring_prep_send(sqe, sockfd, buf, len, flags);
	conninfo info_send = {
		.connfd = sockfd,
		.event = EVENT_WRITE,
	};
	memcpy(&sqe->user_data, &info_send, sizeof(info_send));

}

void set_recv_event(struct io_uring *ring, int sockfd, void *buf, size_t len, int flags) {
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
	io_uring_prep_recv(sqe, sockfd, buf, len, flags);
	conninfo info_recv = {
		.connfd = sockfd,
		.event = EVENT_READ,
	};
	memcpy(&sqe->user_data, &info_recv, sizeof(info_recv));
}

void set_accept_event(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_uring_prep_accept(sqe, sockfd, addr, addrlen, flags);
    conninfo info_accept = {
        .connfd = sockfd,
        .event = EVENT_ACCEPT,
    };
    memcpy(&sqe->user_data, &info_accept, sizeof(info_accept));

}


int main() {
    //创建服务器socket
	int sockfd = socket(AF_INET, SOCK_STREAM, 0); 	
	struct sockaddr_in servaddr;
	memset(&servaddr, 0, sizeof(struct sockaddr_in)); 
	servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY); 
    servaddr.sin_port = htons(9999);

    if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
		printf("bind failed: %s", strerror(errno));
		return -1;
    }

    listen(sockfd, 10); 
	
    /*--------------------------------------------------------*/
    // 构建io_uring 相关参数
	struct io_uring_params params;
	memset(&params, 0, sizeof(params));
	
    // 初始化环形队列,mmap内存
	struct io_uring ring;
	io_uring_queue_init_params(ENTRIES_LENGTH, &ring, &params);
	
	struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

	struct sockaddr_in clientaddr;
	socklen_t clilen = sizeof(struct sockaddr);
	
    // 设置accept事件,监听sq队列是否有可读事件
	set_accept_event(&ring, sockfd, (struct sockaddr*)&clientaddr, &clilen, 0);

	char buffer[1024] = {0};

	while (1) {
		// 相当于 io_uring_enter
		io_uring_submit(&ring);
		struct io_uring_cqe *cqe;
		io_uring_wait_cqe(&ring, &cqe);
		struct io_uring_cqe *cqes[10];
        //一次从队列里获取一批数据,类似epoll_wait的maxevents
		int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10);

		int i = 0;
		for (i = 0;i < cqecount;i ++) {

			cqe = cqes[i];
			conninfo ci;
			memcpy(&ci, &cqe->user_data, sizeof(ci));

			if (ci.event == EVENT_ACCEPT) {  	//代表有新的连接上来
				if (cqe->res < 0) continue;

				int connfd = cqe->res;
                //设置accept事件,可读事件 
				set_accept_event(&ring, ci.connfd, (struct sockaddr*)&clientaddr, &clilen, 0);				
				set_recv_event(&ring, connfd, buffer, 1024, 0);				
			} else if (ci.event == EVENT_READ) {
                // 代表fd可读
				if (cqe->res < 0) continue;
				if (cqe->res == 0) {				
					close(ci.connfd);					
				} else {
                    //设置可写事件
					set_send_event(&ring, ci.connfd, buffer, cqe->res, 0);
				}

			} else if (ci.event == EVENT_WRITE) {
                //代表fd已经写成功,再次设置可读事件
				set_recv_event(&ring, ci.connfd, buffer, 1024, 0);
			}
		}
        // 这一步至关重要,它是将已经处理过的队列出队,避免重复处理队列中的事件
		io_uring_cq_advance(&ring, cqecount);
	}
    close(sockfd);
    
    return 0;
}

总结

io_uring通过队列将sendrecv的数据做到异步解耦,从而提升了性能,但是相比于epollio_uring过分依赖于内核版本(kerner5.10以上),且操作相对比较繁琐。因此,现阶段可以作为了解,主流使用应该还是以epoll为主。


本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对C/C++课程感兴趣的读者,可以点击链接,查看详细的服务:C/C++Linux服务器开发/高级架构师

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值