在初始化完成之后,应用程序就可以使用这些队列来添加IO请求,即填充SQE。当请求都加入SQ后,应用程序还需要某种方式告诉内核,生产的请求待消费,这就是提交IO请求,可以通过io_uring_enter系统调用。
内核将SQ中的请求提交给Block层。这个系统调用既能提交,也能等待。
具体的实现是找到一个空闲的SQE,根据请求设置SQE,并将这个SQE的索引放到SQ中。SQ是一个典型的ringbuffer,有head,tail两个成员,如果head==tail,意味着队列为空。SQE设置完成后,需要修改SQ的tail,以表示向ringbuffer中插入了一个请求。
先从参数上来解析
2.如果flags中设置了IORING_ENTER_GETEVENTS,并且min_complete>0,这个系统调用会一直block,直到min_complete个IO已经完成才返回。这个系统调用会同时处理IO收割。
3.另外的,IORING_SQ_NEED_WAKEUP可以表示在一些时候唤醒休眠中的轮询线程。
staticintio_sq_thread(void*data)即内核轮询线程。
同样地,可以用这个系统调用等待完成。除非应用程序,内核会直接修改CQ,因此调用io_uring_enter系统调用时不必使用IORING_ENTER_GETEVENTS,完成就可以被应用程序消费。
io_uring提供了submissionoffload模式,使得提交过程完全不需要进行系统调用。当程序在用户态设置完SQE,并通过修改SQ的tail完成一次插入时,如果此时SQ线程处于唤醒状态,那么可以立刻捕获到这次提交,这样就避免了用户程序调用io_uring_enter。如上所说,如果SQ线程处于休眠状态,则需要通过使用IORING_SQ_NEED_WAKEUP标志位调用io_uring_enter来唤醒SQ线程。
以io_iopoll_check为例,正常情况下执行路线是io_iopoll_check->io_iopoll_getevents->io_do_iopoll->(kiocb->ki_filp->f_op->iopoll).在完成请求的操作之后,会调用下面这个函数提交结果到cqe数组中,这样应用就能看到结果了。这里的io_cqring_fill_event就是获取一个目前可以写入到cqe,写入数据。这里最终调用的会是io_get_cqring,可以见就是返回目前tail的后面的一个。
更详细的内容可以直接参考io_uring_enter(2)的manpage。
io_iopoll_complete实现
io_get_cqring实现
IO收割
来都来了,搞点事情吧,在我们提交IO的同时,使用同一个io_uring_enter系统调用就可以回收完成状态,这样的好处就是一次系统调用接口就完成了原本需要两次系统调用的工作,大大的减少了系统调用的次数,也就是减少了内核核外的切换,这是一个很明显的优化,内核与核外的切换极其耗时。
当IO完成时,内核负责将完成IO在SQEs中的index放到CQ中。由于IO在提交的时候可以顺便返回完成的IO,所以收割IO不需要额外系统调用。
如果使用了IORING_SETUP_SQPOLL参数,IO收割也不需要系统调用的参与。由于内核和用户态共享内存,所以收割的时候,用户态遍历[cring->head,cring->tail)区间,即已经完成的IO队列,然后找到相应的CQE并进行处理,最后移动head指针到tail,IO收割至此而终。
所以,在最理想的情况下,IO提交和收割都不需要使用系统调用。
高级特性
此外,我们可以使用一些优化思想,进行更进一步的优化,这些优化,以一种可选的方式成为io_uring的其它一些高级特性。
优化思想
非关键逻辑上提至循环外,简化关键路径。
优化实现
fixed_file_data结构
io_sqe_files_register实现FixedFiles操作
优化思想也是将非关键逻辑上提至循环外,简化关键路径。
如果应用提交到内核的虚拟内存地址是固定的,那么可以提前完成虚拟地址到物理pages的映射,将这个并不是每次都要做的非关键路径从关键的IO路径中剥离,避免每次I/O都进行转换,从而优化性能。可以在io_uring_setup之后,调用io_uring_register,使用IORING_REGISTER_BUFFERS操作码,将一组buffer注册到内核(参数是一个指向iovec的数组,表示这些地址需要map到内核),最终调用io_sqe_buffer_register,这样内核在注册阶段就批量完成buffer的一些基本操作(减小get_user_pages、put_page开销,提前使用get_user_pages来获得userspace虚拟地址对应的物理pages,初始化在io_ring_ctx上下文中用于管理用户态buffer的io_mapped_ubuf数据结构,map/unmap,传递IOV的地址和长度等),之后的再次批量IO时就不需要重复地进行此类内存拷贝和基础信息检测。
io_mapped_ubuf结构
io_sqe_buffer_register实现FixedBuffers操作
状态从未完成变成已完成,就需要对完成状态进行探测,很多时候,可以使用中断模型,也就是等待后端数据处理完毕之后,内核会发起一个SIGIO或eventfd的EPOLLIN状态提醒核外有数据已经完成了,可以开始处理。但是,中断其实是比较耗时的,如果是高IOPS的场景,就会不停地中断,中断开销就得不偿失。
我们可以更激进一些,让内核采用PolledIO模式收割块设备层请求。这在一定的程度上加速了IO,这在追求低延时和高IOPS的应用场景非常有用。
io_uring_enter通过正确设置IORING_ENTER_GETEVENTS,IORING_SETUP_IOPOLL等flag(如下代码设置IORING_SETUP_IOPOLL并且不设置IORING_SETUP_SQPOLL,即没有使用SQ线程)调用io_iopoll_check。
io_iopoll_check开始poll核外程序可以不停的轮询需要的完成事件数量min_complete,循环内主要调用io_iopoll_getevents。
io_iopoll_getevents调用io_do_iopoll。
io_do_iopoll中的kiocb->ki_filp->f_op->iopoll,即blkdev_iopoll,不断地轮询探测确认提交给Block层的请求的完成状态,直到足够数量的IO完成。
KernelSidePolling
IORING_SETUP_SQPOLL,当前应用更新SQ并填充一个新的SQE,内核线程sq_thread会自动完成提交,这样应用无需每次调用io_uring_enter系统调用来提交IO。应用可通过IORING_SETUP_SQ_AFF和sq_thread_cpu绑定特定的CPU。
小结
如上可见,内核提供了足够多的选择,不同的方案有着不同角度的优化方向,这些优化方案可以自行组合。通过合理地使用,可以使io_uring全速运转。
正如前文所说,简单并不一定意味着易用——io_uring的接口足够简单,但是相对于这种简单,操作上需要手动mmap来映射内存,稍显复杂。为了更方便地使用io_uring,原作者JensAxboe还开发了一套liburing库。liburing库提供了一组辅助函数实现设置和内存映射,应用不必了解诸多io_uring的细节就可以简单地使用起来。例如,无需担心memorybarrier,或者是ringbuffer管理之类等。上文所提的一些高级特性,在liburing中也有封装。
核心数据结构
liburing中,核心的结构有io_uring、io_uring_sq、io_uring_cq
核心接口
主要流程
核心实现
io_uring_queue_init的实现,前文已略有提及。其中的操作主要就是io_uring_setup和io_uring_queue_mmap,io_uring_setup前文已解析过,这里主要看io_uring_queue_mmap。
io_uring_queue_mmap初始化io_uring结构,然后主要调用io_uring_mmap。
io_uring_mmap初始化io_uring_sq结构和io_uring_cq结构的内存,另外还会分配一个io_uring_sqe结构的数组。
具体例程
如下是一个基于liburing的helloworld示例。
更多的示例可参考:
如上,推演过了设计与实现,回归到存储的需求上来,io_uring子系统是否能满足我们对高性能的极致需求呢?这一切还是需要profile。
测试方法
io_uring原作者JensAxboe在fio中提供了ioengine=io_uring的支持,可以使用fio进行测试,使用ioengine选项指定异步IO引擎。
可以基于不同的IO栈:
可以基于一些硬件之上:
测试过程中主要4k数据的顺序读、顺序写、随机读、随机写,对比几种IO引擎的性能及QoS等指标
io_uringpollingmode测试实例:
测试结果
网上可以找到一些关于iouring的性能测试,这里列出部分供参考:
主要有以下几个测试结果
从测试中,我们可以得出结论,在存储中使用io_uring,相比使用libaio,应用的性能会有显著的提升。
在同样的硬件平台上,仅仅更换IO引擎,就可以带来较大的提升,是很难得的,对于存储这种延时敏感的应用而言十分宝贵。
io_uring的优势
事物的发展是一个哲学话题。前文阐述了io_uring作为一个新事物,发展的根本动力、内因和外因,谨此简述一些可预见的未来的发展方向。
普及
应用层多使用。目前主要应用在存储的场景中,这是一个不仅需要高性能,也需要稳定的场景,而一般来说,新事物并不具备“稳定”的属性。但是io_uring同样也是稳定的,因为虽然io_uring使用到了若干新概念,但是这些新的东西已经有了实践的检验,如eventfd通知机制,SIGIO信号机制,与AIO基本相似。它是一个质变的新事物。
就我们腾讯而言,内核使用tlinux,tlinux3基于4.14.99主线;tlinux4基于5.4.23主线。
所以,tlinux3可以用nativeaio,tlinux4之后已经可以用nativeio_uring。
相信通过大家的努力,正如前文所说的PostgreSQL使用彼时新接口pread,Nginx使用彼时的新接口AIO一样,通过使用新街口,我们的工程也能获得巨大收益。
优化方向
降低本身的工作负载
持续降低系统调用开销、拷贝开销、框架本身的负载。
重构
追求真理的人不可避免地追求永恒。“政治只是一时,方程却是永恒。”——爱因斯坦如是说,时值以色列的第一任总统魏兹曼于1952年逝世,继任首相古理安建议邀请爱因斯坦担任第二任总统。
难免纰漏,欢迎交流,可以通过以下网址找到本文。
ComputerSystems:AProgrammer'sPerspective,ThirdEdition
AdvancedProgrammingintheUNIXEnvironment,ThirdEdition
TheLinuxProgrammingInterface:ALinuxandUNIXSystemProgrammingHandbook
UnderstandingNginxModulesDevelopmentandArchitectureResolving(SecondEdition)