epoll与io_uring服务器编程实践及对比

3,867 阅读10分钟

项目地址:TinyWebServer-with-liburing(github.com)

epoll原理及API使用方式

epoll是一种IO多路复用的机制,一般搭配非阻塞IO实现,是一种同步IO。工作逻辑上体现为向一个epoll实例注册一批需要监听的套接字和期望获得通知的事件,然后等待内核对到来的事件进行通知,通过收割到来的不同事件来执行具体的操作。

系统提供了三个API以供使用,分别是(以下来自库头文件):

extern int epoll_create (int __size) __THROW
//创建一个epoll实例,后续的添加事件与描述符和收割事件都是通过该实例与内核进行交互。
//现该参数已被弃用,但size参数值必须大于0。返回一个与实例关联的文件描述符。
extern int epoll_ctl (int __epfd, int __op, int __fd, struct epoll_event *__event) __THROW;
//向目标epoll实例添加期望监听的事件、标识以及文件描述符。
//参数从左至右分别为:epoll实例描述符、需要执行的命令、需要修改的文件描述符、epoll事件集合。
//op参数有EPOLL_CTL_ADD、EPOLL_CTL_DEL、EPOLL_CTL_MOD三种,分别是添加、删除和修改。
//成功返回0,否则为-1并置errno。
extern int epoll_wait (int __epfd, struct epoll_event *__events, int __maxevents, int __timeout);
//阻塞(或者不)等待内核返回响应的事件及描述符,将信息填充到一个epoll_event类型的数组中
//maxevents参数通常是events数组的大小,\
//timeout参数为-1时代表阻塞等待,0代表非阻塞返回,其他正整数代表等待的超时时间。
//成功则返回响应的事件数量,否则返回-1并置errno。
extern int epoll_pwait (int __epfd, struct epoll_event *__events,\
             int __maxevents, int __timeout,\
			const __sigset_t *__ss);
//与epoll_wait相比,其最后一个参与允许将线程的信号掩码暂时且原子地由参数替换。
typedef union epoll_data
{
  void *ptr;
  int fd;
  uint32_t u32;
  uint64_t u64;
} epoll_data_t;

struct epoll_event
{
  uint32_t events;	/* Epoll events */
  epoll_data_t data;	/* User data variable */
} __EPOLL_PACKED;

//将主要使用的epoll事件结构体

TinyWebServer为蓝本,在实际的服务器编程中,需要对epoll_ctl函数进行封装,将需要监听的事件提前设定好,例如以下函数:

void addfd(int epollfd, int fd, bool one_shot, int TRIGMode)
{
    epoll_event event;
    event.data.fd = fd;

    if (1 == TRIGMode) //根据不同的触发模式选择性添加ET标识
        event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
    else
        event.events = EPOLLIN | EPOLLRDHUP;

    if (one_shot)
        event.events |= EPOLLONESHOT; //单次触发,收割过此事件后必须重新添加
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
}

如果需要删除一个描述符,封装的函数类似:

void removefd(int epollfd, int fd)
{
    epoll_ctl(epollfd, EPOLL_CTL_DEL, fd, 0); //从兴趣列表删除该描述符
    close(fd);
}

正式开始工作前,先是使用epoll_create函数创建一个epoll实例,然后使用封装的函数对需要监听的文件描述符添加到实例中,然后在一个事件循环中不断调用epoll_wait函数对到来的事件进行收割和处理,例如:

void eventLoop()
{
    while (1)
    {
        int number = epoll_wait(m_epollfd, events, MAX_EVENT_NUMBER, -1);
        //阻塞等待事件的发生返回事件的数目,并将触发的事件写入events。
        if (number < 0 && errno != EINTR) //EINTR指系统调用被中断
        {
            LOG_ERROR("%s", "epoll failure");
            break;
        }

        for (int i = 0; i < number; i++) //逐个处理事件
        {
            int sockfd = events[i].data.fd;

            //处理新到的客户连接
            if (sockfd == m_listenfd) //如果事件来自监听端
            {
                //处理逻辑
            }
            else if (events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) //如果是这类事件之一
            {
                //处理逻辑
            }
            else if (events[i].events & EPOLLIN) //可读事件
            {
                //处理逻辑
            }
            else if (events[i].events & EPOLLOUT) //可写事件
            {
                //处理逻辑
            }
        }
    }
}

io_uring原理及liburing库使用方式

io_uring是linux于2019年引入内核的异步IO,支持普通的任务提交模式和轮询模式,用户向其一次性提交多个需要完成的系统调用任务,然后内核会对任务进行收割并返回任务完成的结果,用户只需获取任务完成的结果并进行相应的处理,而无需一直等待系统调用的完成。

在实际交互上,用户和内核将在内存中共享一块环形队列,用户需要提交的任务和收割完成的任务都是在这一区域中进行,内核亦然,然后用户根据设置时的选项决定是否使用系统调用提交和收割任务,这就避免了内核态和用户态之间的数据拷贝,降低了开销。

部分缩写:complete queue(cq)、submission queue(sq)和submission queue entries(sqes)。sq中储存的是对sqe中的索引指针,由io_uring_sqe结构描述;cq中储存的是完成的队列项,由io_uring_cqe结构描述。

struct io_uring_cqe {
	__u64	user_data;	/* sqe->data submission passed back */
	__s32	res;		/* result code for this event */
	__u32	flags;
};

系统提供了三个系统调用以供使用,以下将用liburing库的源码和io_uring的man page进行分析:

#  define __NR_io_uring_setup		425
#  define __NR_io_uring_enter		426
#  define __NR_io_uring_register	427

//现阶段对三个接口的使用似乎都要通过对系统调用的封装进行

int __sys_io_uring_setup(unsigned entries, struct io_uring_params *p)
{
	return syscall(__NR_io_uring_setup, entries, p);
}
//获得一个io_uring的实例,根据entries参数io_uring_params初始化队列深度和该实例工作方式

int __sys_io_uring_enter2(int fd, unsigned to_submit, unsigned min_complete,
			  unsigned flags, sigset_t *sig, int sz)
{
	return syscall(__NR_io_uring_enter, fd, to_submit, min_complete, flags,
		       sig, sz);
}
int __sys_io_uring_enter(int fd, unsigned to_submit, unsigned min_complete,
			 unsigned flags, sigset_t *sig)
{
	return __sys_io_uring_enter2(fd, to_submit, min_complete, flags, sig,
				     _NSIG / 8);
}
//参数fd为iouring实例的文件描述符,to_submit为要提交的请求数量,min_complete是
//等待返回的最小完成数量,flags是若干选项的掩码,sig是信号掩码指针,sz参数一般不被使用

int __sys_io_uring_register(int fd, unsigned opcode, const void *arg,
			    unsigned nr_args)
{
	return syscall(__NR_io_uring_register, fd, opcode, arg, nr_args);
}
//根据opcode对一组资源向内核注册,以减少内核对资源的反复IO操作。

用户态在使用前需要先使用io_uring_setup初始化一个实例,然后对sqring、cqring和sqes三块区域进行内存映射,并自行维护用户态的sq/cq数据结构,总体操作流程较为复杂,而liburing中提供了一系列的封装函数可以直接使用:

int io_uring_queue_init_params(unsigned entries, struct io_uring *ring,
			       struct io_uring_params *p)
{
	int fd, ret;

	fd = __sys_io_uring_setup(entries, p);
	if (fd < 0)
		return -errno;

	ret = io_uring_queue_mmap(fd, p, ring);
	if (ret) {
		close(fd);
		return ret;
	}

	ring->features = p->features;
	return 0;
}
struct io_uring {
	struct io_uring_sq sq; //liburing中封装的数据结构,下同
	struct io_uring_cq cq;
	unsigned flags;
	int ring_fd;

	unsigned features;
	unsigned pad[3];
};
struct io_uring_params {
	__u32 sq_entries;
	__u32 cq_entries;
	__u32 flags;
	__u32 sq_thread_cpu;
	__u32 sq_thread_idle;
	__u32 features;
	__u32 wq_fd;
	__u32 resv[3];
	struct io_sqring_offsets sq_off; //需要进行映射的数据结构,下同
	struct io_cqring_offsets cq_off;
};
//该函数将根据参数初始化传入的io_uring结构并完成对内存区域的映射
//如果需要开启其他选项则通过设置io_uring_params中的flags进行

/*
 * io_uring_setup() flags
 */
#define IORING_SETUP_IOPOLL	(1U << 0)	/* io_context is polled */
#define IORING_SETUP_SQPOLL	(1U << 1)	/* SQ poll thread */
#define IORING_SETUP_SQ_AFF	(1U << 2)	/* sq_thread_cpu is valid */
#define IORING_SETUP_CQSIZE	(1U << 3)	/* app defines CQ size */
#define IORING_SETUP_CLAMP	(1U << 4)	/* clamp SQ/CQ ring sizes */
#define IORING_SETUP_ATTACH_WQ	(1U << 5)	/* attach to existing wq */
#define IORING_SETUP_R_DISABLED	(1U << 6)	/* start with ring disabled */
//详情可以翻阅官方手册,此处不作展开

在完成了以上步骤后,还需要对期望提交的系统调用进行进一步的封装,基本流程为先获取一个空闲的sqe指针,然后填充其中的opcode、fd、off、user_data等必要信息,其数据结构如下:

struct io_uring_sqe {
	__u8	opcode;		/* type of operation for this sqe */
	__u8	flags;		/* IOSQE_ flags */
	__u16	ioprio;		/* ioprio for the request */
	__s32	fd;		/* file descriptor to do IO on */
	union {
		__u64	off;	/* offset into file */
		__u64	addr2;
	};
	union {
		__u64	addr;	/* pointer to buffer or iovecs */
		__u64	splice_off_in;
	};
	__u32	len;		/* buffer size or number of iovecs */
	union {
		__kernel_rwf_t	rw_flags;
		__u32		fsync_flags;
		__u16		poll_events;	/* compatibility */
		__u32		poll32_events;	/* word-reversed for BE */
		__u32		sync_range_flags;
		__u32		msg_flags;
		__u32		timeout_flags;
		__u32		accept_flags;
		__u32		cancel_flags;
		__u32		open_flags;
		__u32		statx_flags;
		__u32		fadvise_advice;
		__u32		splice_flags;
		__u32		rename_flags;
		__u32		unlink_flags;
		__u32		hardlink_flags;
	};
	__u64	user_data;	/* data to be passed back at completion time */
	/* pack this to avoid bogus arm OABI complaints */
	union {
		/* index into fixed buffers, if used */
		__u16	buf_index;
		/* for grouped buffer selection */
		__u16	buf_group;
	} __attribute__((packed));
	/* personality to use, if used */
	__u16	personality;
	union {
		__s32	splice_fd_in;
		__u32	file_index;
	};
	__u64	__pad2[2];
};

liburing提供了多个函数对以上流程进行操作,以io_uring-echo-server为蓝本,一次任务提交的操作如下:

static void add_accept(struct io_uring *ring, int fd,
		struct sockaddr *client_addr, socklen_t *client_len,
		unsigned flags)
{
	struct io_uring_sqe *sqe = io_uring_get_sqe(ring); //从映射的sqe区域中取出一个可用的sqe

	io_uring_prep_accept(sqe, fd, client_addr, client_len, 0); 
    //对sqe填充信息,该函数将提交队列的执行命令设置为accpet命令,相当于调用accpet系统调用
	io_uring_sqe_set_flags(sqe, flags); 
    //设置标识,当为0时设置为IOSQE_FIXED_FILE_BIT

	io_uring_sqe_set_data(sqe, conn_i); //设置提交队列的用户数据
}
//其他的系统调用任务如readv、writev等类似

在设置完需要进行的任务后,则需要根据设置时的选项对任务进行提交和收割,如果设置时的选项是IORING_SETUP_IOPOLL,则需要通过io_uring_enter对任务进行提交和收割;如果是IORING_SETUP_SQPOLL,则只需等待并对完成队列进行收割;如果都没设置,则只需要通过io_uring_enter对任务进行提交并手动收割任务。提交任务时,用户将提交的任务sqe放置到sq_ring的尾部,内核从头部获取提交的任务;完成任务时,内核将完成的cqe放置到cq_ring的尾部,用户从头部收割已完成的任务。实际使用中这些步骤会更加复杂,因为用户需自行调整其用户态数据结构的头尾指针。在liburing中提供了提交和收割的封装函数:

int io_uring_submit_and_wait(struct io_uring *ring, unsigned wait_nr)
{
	return __io_uring_submit_and_wait(ring, wait_nr);
}
//用户态一般直接接触以上函数,第二个参数为期望等待完成的任务个数
static int __io_uring_submit_and_wait(struct io_uring *ring, unsigned wait_nr)
{
	return __io_uring_submit(ring, __io_uring_flush_sq(ring), wait_nr);
}
static int __io_uring_submit(struct io_uring *ring, unsigned submitted,
			     unsigned wait_nr)
{
	unsigned flags;
	int ret;

	flags = 0;
	if (sq_ring_needs_enter(ring, &flags) || wait_nr) {
		if (wait_nr || (ring->flags & IORING_SETUP_IOPOLL))
			flags |= IORING_ENTER_GETEVENTS; //该标识阻塞等待任务完成

		ret = __sys_io_uring_enter(ring->ring_fd, submitted, wait_nr,
						flags, NULL);
		if (ret < 0)
			return -errno;
	} else
		ret = submitted;

	return ret;
}

负责收割完成任务的函数将返回完成的个数,并填充用户自行创建的cqe数组:

unsigned io_uring_peek_batch_cqe(struct io_uring *ring,
				 struct io_uring_cqe **cqes, unsigned count)
{
	unsigned ready;
	bool overflow_checked = false;

again:
	ready = io_uring_cq_ready(ring);
	if (ready) {
		unsigned head = *ring->cq.khead;
		unsigned mask = *ring->cq.kring_mask;
		unsigned last;
		int i = 0;

		count = count > ready ? ready : count;
		last = head + count;
		for (;head != last; head++, i++)
			cqes[i] = &ring->cq.cqes[head & mask];

		return count;
	}

	if (overflow_checked)
		goto done;

	if (cq_ring_needs_flush(ring)) {
		__sys_io_uring_enter(ring->ring_fd, 0, 0,
				     IORING_ENTER_GETEVENTS, NULL);
		overflow_checked = true;
		goto again;
	}

done:
	return 0;
}

TinyWebServer-with-liburing为蓝本,完成准备工作后,在进入具体事件循环前需要先行提交一个对监听套接字的accept任务,然后不断阻塞等待任务完成、收割任务然后根据返回的任务状态进行下一步处理后提交新的任务:

void iorws::IO_eventLoop()
{
	int ret = 0;
	struct sockaddr_in client_addr;
	socklen_t client_len = sizeof(client_addr);

	add_accept(&ring, server->m_listenfd, (struct sockaddr*)&client_addr, &client_len, 0);
	while (1)
	{
		int cqe_count;
		struct io_uring_cqe *cqes[BACKLOG];
        
		ret = io_uring_submit_and_wait(&ring, 1); //提交sq的entry,阻塞等到其完成,最小完成1个时返回
		if (ret < 0) {
			printf("Returned from io is %d\n", errno);
			perror("Error io_uring_submit_and_wait\n");
			LOG_ERROR("%s", "io_uring failure");
			exit(1);
		}

		//将准备好的队列填充到cqes中,并返回已准备好的数目,收割cqe
		cqe_count = io_uring_peek_batch_cqe(&ring, cqes, sizeof(cqes) / sizeof(cqes[0]));
		assert(cqe_count >= 0);

		for (int i = 0; i < cqe_count; ++i) {
			struct io_uring_cqe* cqe = cqes[i];
			conn_info* user_data = (conn_info*)io_uring_cqe_get_data(cqe);
			int type = user_data->type; //返回完成任务的类型

			if (type == ACCEPT) {
				int sock_conn_fd = cqe->res;
				io_uring_cqe_seen(&ring, cqe); //cqe向前移动避免当前请求避免被二次处理
				//处理逻辑
				add_socket_recv(&ring, sock_conn_fd, 0); //对该连接套接字添加读取
				add_accept(&ring, server->m_listenfd,&client_addr, &client_len, 0); 
				//再继续对监听套接字添加监听
			}
			else if (type == READ) {
				int bytes_have_read = cqe->res;
				io_uring_cqe_seen(&ring, cqe);
				//处理逻辑
				add_socket_writev(&ring, user_data->fd, 0); //添加写回任务
			}
			else if (type == WRITE) {
				int	ret = cqe->res;
				io_uring_cqe_seen(&ring, cqe);
				//处理逻辑
				add_accept(&ring, server->m_listenfd, &client_addr, &client_len, 0);
            }
		}
	}
}

io_uring_register在liburing的封装接口代码如下:

int io_uring_register_files(struct io_uring *ring, const int *files,
			      unsigned nr_files)
{
	int ret;

	ret = __sys_io_uring_register(ring->ring_fd, IORING_REGISTER_FILES,
					files, nr_files);
	if (ret < 0)
		return -errno;

	return 0;
}

该系统调用通过注册文件或用户缓冲区,内核可以对内部数据结构进行长期引用,或创建对应用程序内存的长期映射,从而大大减少I/O开销。但其在网络编程领域似乎不太完善(也可能是个人水平原因),暂此按下不表,关于该系统调用的更多信息可以查阅官方文档。

系统调用开销对比

系统调用的开销

现代操作系统由于支持CPU级别的SYSCALL/SYSENTER指令,所以相较于过去开销巨大的INT指令而言系统调用的开销已经被尽可能的减少;但是和普通的函数调用相比,系统调用在CPU上下文、栈帧切换、TLB刷新等方面依然有着一定的成本。

传统INT软中断系统调用

在用户态,用户通过查阅系统调用编号表并将不同的值放入寄存器中来触发一个软中断;

而在内核态,内核在收到中断后将调用事先注册的系统调用回调函数对参数进行处理,而后执行中断处理器 entry_INT80_32 处理系统调用,将寄存器的值储存到内核栈上,然后检查系统调用的序号是否合法并系统调用表中查找对应的系统调用实现和传入寄存器值,在运行期间会在用户态和内核态内存直接传输数据,在退出时调用iret指令从栈上弹出先前保存的用户态地址和寄存器值。如果涉及到堆栈切换的话CPU的执行流程会更长。

现代syscall/sysret指令

该指令总体流程与INT软中断类似,内核会在初始化时将相应的回调函数地址写到MSR寄存器中,并且遵循调用约定(convention),用户空间程序将系统调用编号放到rax寄存器而参数放到通用寄存器,而后会通过同样的查表方式进入内核。内核在返回时调用sysret指令将执行过程返还给用户程序。两条指令在执行时便运行在最高权限级别,而且不必触发软中断。

普通的函数调用

基本通过call/jmp指令进行,call指令在CPU层面要做的只有压栈、出栈、加载、执行、返回、判断几件事,显然要比系统调用指令的开销要小得多。

系统调用数量对比

对比设置:服务器软件为TinyWebServer(epoll)和TinyWebServer-with-liburing(io_uring),两者均关闭日志,epoll开启双ET选项,使用strace和perf进行跟踪记录,使用webbench以相同参数进行测试。在测试过程中,需要使用sudo提权执行perf才能在io_uring收集到足够的样本和调用栈信息,epoll则不需要。

io_uring

iouring_perf_data_benchmark_process_iouring.svg.png

epoll

epoll_perf_data_benchmark_process_epoll.svg.png

从火焰图可以看出,epoll花费了大量操作在执行readv和writev两个系统调用上,而accept、epoll_wait、epoll_ctl等系统调用所执行的次数则少了很多;

而io_uring方面大部分的操作都是内核在执行,用户态所需要做的只是提交所需执行的任务,且一次可以提交多个。

strace返回的结果是,一次对访问epoll的网页访问需要经历epoll_wait-epoll_ctl-readv-writev多个系统调用,而io_uring只需要经过io_uring_enter和io_register(初次访问)两个系统调用,在高并发场景下对系统调用的减少是巨大的。

实际性能对比

测试环境:wsl2,内核版本5.10.60.1,发行版为Debian

硬件:I5-9400,16gDDR4

使用webbench进行简易测试,模拟10500、30500台客户端,持续时间为5s,分别在正常访问和不等待返回两种模式下进行测试,两个客户端均关闭日志记录,epoll开启双ET模式,比较每分钟发送页面数,结果如下:

graph.png

可见,虽然该服务器还有着一些问题,但使用io_uring在高并发的场景下相对epoll仍然有着巨大的性能优势。

已知问题

关闭服务器中的io_uring_register相关功能会解决压力测试下出现大量failed的问题,但也会导致性能断崖式下跌,同样测试条件下只有开启该功能的50%左右的性能;

在源码中开启该调用的功能对IO性能的提升是显著的,但会导致被注册的文件描述符无法被关闭,长期处于close_wait状态,导致系统资源被大量占用;

accept系统调用似乎不支持polling模式,当在设置iouring时开启了IQ或是SQ模式时都会返回invalid argument的错误;

在部分系统例如WSL2上需要使用sudo提权才能启用更大的队列深度。

参考文章

深入理解 Epoll - 知乎 (zhihu.com)

《操作系统与存储:解析Linux内核全新异步IO引擎——io_uring设计与实现》(一) - 知乎 (zhihu.com)

一篇文章带你读懂 io_uring 的接口与实现 - 知乎 (zhihu.com)

系统调用真正的效率瓶颈在哪里? - 知乎 (zhihu.com)

为什么系统调用会消耗较多资源呢 - 知乎 (zhihu.com)

[译] Linux 系统调用权威指南(2016) (arthurchiao.art)

Linux下用火焰图进行性能分析_OSKernelLAB(gatieme)-CSDN博客_linux 火焰图