epoll与io_uring服务器编程实践及对比
项目地址:TinyWebServer-with-liburing(github.com)
epoll原理及API使用方式
epoll是一种IO多路复用的机制,一般搭配非阻塞IO实现,是一种同步IO。工作逻辑上体现为向一个epoll实例注册一批需要监听的套接字和期望获得通知的事件,然后等待内核对到来的事件进行通知,通过收割到来的不同事件来执行具体的操作。
系统提供了三个API以供使用,分别是(以下来自库头文件):
以TinyWebServer为蓝本,在实际的服务器编程中,需要对epoll_ctl函数进行封装,将需要监听的事件提前设定好,例如以下函数:
如果需要删除一个描述符,封装的函数类似:
正式开始工作前,先是使用epoll_create函数创建一个epoll实例,然后使用封装的函数对需要监听的文件描述符添加到实例中,然后在一个事件循环中不断调用epoll_wait函数对到来的事件进行收割和处理,例如:
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结构描述。
系统提供了三个系统调用以供使用,以下将用liburing库的源码和io_uring的man page进行分析:
用户态在使用前需要先使用io_uring_setup初始化一个实例,然后对sqring、cqring和sqes三块区域进行内存映射,并自行维护用户态的sq/cq数据结构,总体操作流程较为复杂,而liburing中提供了一系列的封装函数可以直接使用:
在完成了以上步骤后,还需要对期望提交的系统调用进行进一步的封装,基本流程为先获取一个空闲的sqe指针,然后填充其中的opcode、fd、off、user_data等必要信息,其数据结构如下:
liburing提供了多个函数对以上流程进行操作,以io_uring-echo-server为蓝本,一次任务提交的操作如下:
在设置完需要进行的任务后,则需要根据设置时的选项对任务进行提交和收割,如果设置时的选项是IORING_SETUP_IOPOLL,则需要通过io_uring_enter对任务进行提交和收割;如果是IORING_SETUP_SQPOLL,则只需等待并对完成队列进行收割;如果都没设置,则只需要通过io_uring_enter对任务进行提交并手动收割任务。提交任务时,用户将提交的任务sqe放置到sq_ring的尾部,内核从头部获取提交的任务;完成任务时,内核将完成的cqe放置到cq_ring的尾部,用户从头部收割已完成的任务。实际使用中这些步骤会更加复杂,因为用户需自行调整其用户态数据结构的头尾指针。在liburing中提供了提交和收割的封装函数:
负责收割完成任务的函数将返回完成的个数,并填充用户自行创建的cqe数组:
以TinyWebServer-with-liburing为蓝本,完成准备工作后,在进入具体事件循环前需要先行提交一个对监听套接字的accept任务,然后不断阻塞等待任务完成、收割任务然后根据返回的任务状态进行下一步处理后提交新的任务:
io_uring_register在liburing的封装接口代码如下:
该系统调用通过注册文件或用户缓冲区,内核可以对内部数据结构进行长期引用,或创建对应用程序内存的长期映射,从而大大减少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

epoll

从火焰图可以看出,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模式,比较每分钟发送页面数,结果如下:

可见,虽然该服务器还有着一些问题,但使用io_uring在高并发的场景下相对epoll仍然有着巨大的性能优势。
已知问题
关闭服务器中的io_uring_register
相关功能会解决压力测试下出现大量failed的问题,但也会导致性能断崖式下跌,同样测试条件下只有开启该功能的50%左右的性能;
在源码中开启该调用的功能对IO性能的提升是显著的,但会导致被注册的文件描述符无法被关闭,长期处于close_wait状态,导致系统资源被大量占用;
accept系统调用似乎不支持polling模式,当在设置iouring时开启了IQ或是SQ模式时都会返回invalid argument
的错误;
在部分系统例如WSL2上需要使用sudo提权才能启用更大的队列深度。
作者:greatmfc
链接:https://juejin.cn/post/7074212680071905311
(服务器开发)磁盘io的主流:io_uring是什么?比epoll更强?会成为未来io的主流吗?
LinuxC/C++后台服务器开发/架构师面试题、学习资料、教学视频和学习路线图(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享有需要的可以自行添加学习交流 群739729163 领取