前言
在聊 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
环形队列,内核返回的cq
和sq
的信息也通过该参数带回来
- 返回值:
- 返回一个文件描述符
fd
,应用随后可以将这个文件描述符传给mmap
进行调用,用来映射环形队列的内存,以及作为后续io_uring_register
和io_uring_enter
操作的句柄。
io_uring_register
- 函数原型:
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args)
- 函数作用:
- 注册用于异步
IO
的文件或用户缓冲区,使内核能长时间持有对该文件 在内核内部的数据结构引用,或创建应用内存的长期映射
- 这个操作只会在注册时执行一次,而不是每个
IO
请求都会处理,因此减少了IO
开销
- 参数说明:
fd
:io_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);
- 函数作用:
- 使用共享的
SQ
和CQ
初始化和完成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
那样用起来轻量级,sq
和cq
需要我们自己去构建,内存也需要我们自己去管理。这一套实现起来还是相当麻烦的。好在,网上早就有大神把这一套封装好了,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(¶ms, 0, sizeof(params)); // 初始化环形队列,mmap内存 struct io_uring ring; io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); 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
通过队列将send
和recv
的数据做到异步解耦,从而提升了性能,但是相比于epoll
,io_uring
过分依赖于内核版本(kerner5.10
以上),且操作相对比较繁琐。因此,现阶段可以作为了解,主流使用应该还是以epoll
为主。
本专栏知识点是通过<零声教育>的系统学习,进行梳理总结写下文章,对C/C++课程感兴趣的读者,可以点击链接,查看详细的服务:C/C++Linux服务器开发/高级架构师