io_uring 用法分析 III :liburing 接口及高性能 Polling 机制
Interrupt 方案是节约 CPU ,但是延迟高,因为用户态正在休眠,调度到他需要花费一定时间(Linux 调度算法是用红黑树的完全公平调度 CFS,和以前看 APUE 的函数调用有关,然后主要优先级依据是通过 virtual runtime 归一化比较的,同等物理时间下高优先级的虚拟时间过得慢,所以能调度多一些)。鉴于 NAPI 已经要求网卡驱动实现本身提供 poll 接口给 trap ha
续上一篇,太长了分开讲。上一篇:io_uring 用法分析 II :io_uring 原理和系统调用初步介绍_我说我谁呢 --CSDN博客zz
可能下面的文章内容不够聚焦,这里先概述一下本文主要内容再看就好了:
普通的 io_uring
通过 enter 系统调用提交请求后,用户可以做别的事情之后再无锁检查 Completion Queue 获取完成事件,或者通过 min_complete 来睡在 enter 上,等待完成事件到达。kernel 每次接收到 interrupt ,就会填充之前说的无锁队列(单消费单生产),从而可以获取完成事件。
Polling 的 io_uring
由于有些硬件读写太快了,比如对应网卡的 NAPI 驱动,提供了 Polling 的方式(根据 Axboe:It's on the todo to add polling support for sockets, it just hasn't been done yet.),启用 Polling 方式后,应用程序(线程)需要显式阻塞在 enter 函数调用上,然后陷入内核就不返回了,此时内核会持续地 polling,等到一个 min_complete 到达。但是注意的是此时 polling 关注的是完成事件。
kernel side SQPOLL
由于每次提交都要调用 enter,然后引发 context switch,io_uring 也提供了 kernel side SQPOLL 选项来强制内核的线程关注 Submission Queue 的变化事件(基本是 busy loop polling),这样避免了每次的提交造成 context switch(虽然的确可以 batch submit)。注意 SQPOLL polling 关注的是提交事件。
本文主要重点其实是 io_uring 提供的高性能 polling 的分析。liburing 的 api 只是总结一下 Pdf 的内容提一下线索,实际用的时候肯定要对着 manual 和 头文件来看的。
下面先看 library 再看高性能 poll。
library 的东西封装好了上面说的各种东西,然后会好用一些。接口函数签名贴一个链接:liburing/liburing.h at master · axboe/liburing (github.com) 这里 static inline 的太多了所以只挑重点讲。
涉及的结构体
首先 liburing 做的第一件事是定义用户态的结构体以及两个头尾函数:
但是 sqe 和 cqe 都要用前面说的那个结构体(定义在 io_uring 头文件中)。
这个 lib 在头文件用了很多 static inline 函数,顺便复习一下 static 在头文件会导致所有引用的 cpp 和 c 文件都会在文件里面定义一个 static 函数吧,如果不是 inline 就不要这样做。
最简单的 API
然后继续看具体的函数调用吧,这里 sqe 和 cqe 分别是 io_uring_sqe 结构体指针和 cqe 对应的。
这里原来的 pdf 这里写错了,sqe 和 cqe应该都是一个指针。然后那个 wait_cqe 的参数的确是结构体 cqe 的指针的指针。
If the application merely wishes to peek at the completion and not wait for an event to become available, io_uring_peek_cqe(3) does that. For both use cases, the application must call io_uring_cqe_seen(3) once it is done
with this completion event.
然后是这里解释这个为什么要分开做两个调用(wait cqe + seen),看了源码就知道了他这个实现是直接在队列上进行处理的,这样比较高效一点,就是之后推进这个 head 指针需要另外调用 seen。
中断/信号 IO 的缺点
然后到达 POLLED IO 接口了。看这个之前先复习一下 livelock 即这个以前的网卡驱动中断的问题(
Eliminating Receive Livelock in an Interrupt-driven Kernel Jeffrey C. Mogul K. K. Ramakrishnan 1995 ),想起之前那个开源 100Gbps 的 NIC(Corundum),现在的网卡性能已经这么高了,网卡内部搞 IO 的 DMA 都用上流水线了。对于这种设备,中断的驱动模型基本是不能用的了。首先复习一下中断驱动下 livelock 的产生原因:
途中的黑色点就是纯 interrupt 的旧版 kernel(圆圈是修改后的 configure 成仿佛他 unmodified,但是为什么更差原因论文也不知道),对于正常的网卡驱动而言(如下图),来一个 packet 就 DMA(从寄存器/NIC片上缓存到 DRAM PCI 预设缓冲区,circular buffer) ,具体是 PCI 驱动进行 DMA(NIC 把片上地址发给 PCI,并且请求 DMA,控制器完成复制) 完了之后产生一个中断给内核,不管他在干什么(或者关中断暂时积压) trap 进 handler 然后把环形缓冲的东西拿到 socket buffer 队列,然后进入 ip 层等待。 这个过程进入 IP 层由于不能长时间关中断处理,所以方法是分离为 bottom half 和 top half ,top half 的实现是软中断,而软中断的原理很简单就是一些内核线程,他们一般 SLEEP 状态,softirq 之后就变成可调度等待调度。
问题在于高速网卡中断来的特别快,bottom half 弄完之后马上(重新开中断之后)来一个新的中断,于是 CPU 马上又复制到 skb,循环往复。理论上这个应该会 saturated 而不是下降,在单核上就不是了,由于 CPU 全部不断地中断在处理 bottom half,导致其他的低优先级中断无法响应,而且不断引发中断导致 top half 完全无法运作,结果就是整个输出都会下降。
基本的方案是减少中断的次数,尽可能一次完成更多的包复制到 skb,所以 polling 是一种方案,但是 polling 本身是 all in 的,所以效果反而会更差,基本方法是进行配额的 polling,即每次中断之后在特定时间内进行 polling。这个比中断效率高是因为原先中断的方案可能是在 handler 里面的确只有一些包,然后关中断 context switch 回去(meltdown + spectre 补丁之后这里又有一堆流水线+TLB的问题)之后马上又来一个中断,这个时间间隔可能的确就几百个 cycles,但是反而引发了开销。polling 就是在这个中断来了之后不立即返回(指结束 trap handler),而是在这里等待 polling 一会儿。至于接着吞吐率会 saturated 而不是继续增加是因为这里涉及缓冲区满了的丢弃问题,top half 处理了一点点马上又充满了缓冲区(图片应该是 single CPU 的结果,论文后面分析了 multiprocessor (这个主要是 SMP)的情况)。
NAPI 和 DPDK 的网卡驱动模型
Linux 的 NAPI (newAPI jamal.pdf (usenix.org) 这里引用了 livelock 的论文)新网卡驱动就是这样实现的,不过如果 top half 不够快,这样做会消耗大量的内存(socket buffer 是链表队列可以动态分配)。当然这里还是有很多这些复制的方案,即 kernel 要调用 put_user 把东西复制到用户态内存。
Intel 的 DPDK 组件的实现方法是通过加载内核模块,直接用户态 polling,实现用户态的网络栈,既没有 context switch 又不用跨页表拷贝从而提高性能(具体还有大页内存,无锁队列,轮询和流水线机制不深入了)。不过这样基本是 dedicated 设备了。
根据前面的分析,结论是 Interrupt 方案(基本用户应用程序配备的是阻塞休眠)是节约 CPU ,但是延迟高,因为用户态正在休眠,调度到他需要花费一定时间(Linux 调度算法是用红黑树的完全公平调度 CFS,和前面说的 nice 函数调用有关,然后主要优先级依据是通过 vruntime 归一化比较的,同等物理时间下高优先级的虚拟时间过得慢,所以能调度多一些)。鉴于 NAPI 已经要求网卡驱动实现本身提供 poll 接口给 trap handler,这样应用层也可以用这个(而 poll 接口也用在 select poll 上面,之前分析 epoll 讲的 file 结构体里面的 f_op 里面有一个函数指针就是 poll 接口)。
io_uring 中的 POLL 模式
如果要用 polling 的方法,就不能期待队列会被自动添加了,因为 interrupt 和 signal 的机制被关掉了。此时应用层必须通过阻塞调用 io_uring_enter 来实现。需要给 io_uring fd 初始化的时候指定 IORING_SETUP_IOPOLL。
遗憾的是: Currently, this feature is usable only on a file descriptor opened using the O_DIRECT flag.
前面一篇笔记里面说到:
enter 调用,enter 这个 system call 做的是告诉 kernel 提交了一些请求。(这个是可以批处理的)。
unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig
这些参数的内容分别是 uring fd,提交的请求数量,要求唤醒的时候至少要完成这么多才通知上层。 flags 有一个必须提交的如果想要阻塞:IORING_ENTER_GETEVENTS 如果要使用 min_complete 并且进行等待的话。理论上 min 设置 0 不就是 non-blocking 了吗? pdf 说有例外情况所以必须这样设置。
这个例外情况是这样的:
It is legal to have IORING_ENTER_GETEVENTS set and min_complete set to 0. For polled IO, this asks the kernel to simply check for completion events on the driver side and not continually loop doing so.
就是为了 poll 模式下也能 poll 一次而不是直接返回等 interrupt(因为 poll 模式下不会有 interrupt,用户必须显示调用 enter 调用,如果 poll 成功了 kernel 自然会修改 tail 指针生产一个 completion queue entry)。
高性能内核 SQ Polling 连续处理 IO 请求
然后是高性能的 SQ Polling。这个是 kernel side polling,就是用户不再需要调用 enter 来提交请求了: When the application updates the SQ ring and fills in a new sqe, the kernel side will automatically notice the new entry (or entries) and submit them. This is done through a kernel thread, specific to that io_uring.
然后要注意一个要点是为了避免 kernel thread 引发 CPU 空转,实际这个 SQ Polling 是会 sleep 的,所以还要在某个应用态长时间空闲没有提交过任务的空窗期结束时候显式唤醒内核线程。如果kernel thread 睡觉了,那么 sq ring 的 IORING_SQ_NEED_WAKEUP 会被设置。
更多推荐
所有评论(0)