io_uring 触发内核线程的问题 iou-wrk 线程 io_uring 原理 io_uring SQPOLL 原理
2022.04.06记录一下 io_uring 用于 socket 触发大量 wrq 线程的问题昨天我写了一个 sq poll 的版本。好不容易编译了内核(教训是内存和 swap 一定要充足)。然后编译了 perf(原来这些 linux kernel 源码就自带了,为什么不直接编译呢?)kernel 源码1,200M,编译完之后 15 m。然后进行了 perf 测试,大跌眼镜。我本来以为在 ECS
(这里归档到胡思乱想就是不是作为容易查看的技术博客文章存在,给自己归档一下)
2022.04.07
io_uring 内部的实现是 poll(feat_fast_poll),线程池(iou-wrk-xxx 内核线程,iou-sqp-xxx 线程等)以及中断驱动程序支持类似 select poll 使用的 fd->f_op->poll 接口,对于 polled io (如 NVME 硬盘)还用到 polling 的驱动(NAPI 也有,不过 NAPI 是混合)。
典中典的驱动应该是 interrupt driven,就是所有的 event (比如一个 socket )都会有一个 wait queue 链表,里面会挂所有的订阅者(task),一个节点里面会有一个 func 作为回调函数。源码剖析之wait queue_别整没用的的博客-CSDN博客_wait_queue。
复习一下中断驱动,这个图是 xv6 的流程,linux 只不过(应该)是多几层东西(驱动内核模块加载、中断处理分为 top half 和 bottom half)而已,大致应该是一样的套路。
复习一下网卡驱动,驱动在 linux 中是通过内核模块实现的。系统启动时候配置好外围硬件(DMA),初始化内核模块(网卡驱动的 DMA 地址映射配置好了),网卡收到包之后给 DMA controller 投递请求,等DMA复制完之后,网卡的就绪 INTR 到达之后,进入 trap handler,然后 trap handler (top half,尽快结束)会设置中断位已处理,然后添加一个任务到内核的 tasklet 。
一个任务是一个 tasklet,The tasklet code is explained in /usr/src/linux/include/linux/interrupt.h, 其实就是一个链表上面挂着任务函数 callback里面(bottom half,由软中断等处理),tasklet 有类似 user process 的调度函数,另外一个是 wrokqueue。Workqueue functions run in the context of a kernel process, but tasklet functions run in the software interrupt context. workqueue 其实就是在进入系统调用的时候可以进行的。
软中断其实就是一类进程(但是他们的上下文是在中断上下文中,讲 sleeplock 的时候讲过了为什么中断上下文不能 sleep),他们的触发方式是“软中断”到达后设置为 runnable,不能 sleep,只能被硬中断打断,tasklet 是小任务,基于 softirq 实现的,一种类型的 tasklet 保证只会在一个 CPU上运行,避免的并发的控制(就不用写可重入函数了)。
work queue 执行在 kernel process 的意思是他确实是执行在一系列的内核线程中(worker),他是在 tasklet 之后退出的,避免了在中断上下文上运行东西。
关于为什么中断不能 sleep,和有没有 PCB/context 没有关系,主要是和这个 sleep 必须关中断有关。我之前的笔记写得一塌糊涂:
alow interrupt -> sleeplock (yield must supports interrupt). => interrupt handler cannot use it.
Because sleep-locks leave interrupts enabled, they cannot be used in interrupt handlers.
sleeplock 是不能在 trap handler 里面用的, 这是因为我们知道 PCB 具备 context, contex 要么是其 kernel space 的要么是 user space 的, 而 trap 的 vector 是在 trampoline 里面 map 来跳转的, handler 要实现调用 sched 还原 context, 其本身是 kernel 的 temp code, 并不具备 context, 更不用说支援多次中断了. 所以也不能使用 sleeplock, 因为 wakeup 一个 process 并不能返回到 handler 的某个 context 里.
但是上面我也不知道我写的是什么了
但是其实 linux 以前的 trap handler 是借用进程的 stack 的,和 context 无关,中断处理本身如果被 sleep 的(意味着 yield 出去),此时进程不会继续运行,如果他本身在 critical section 里面,那么锁的持有情况就无法被释放,必须等很久(sleep 重新恢复)。。不说了,乱七八糟的。
类似的 top half 代码是类似这样的(书本,英语的 linux-device-drivers):
现在一般用 work queue,不过是换一个 schedule 函数而已:
然后回到 socket 上来,经历多个环形缓冲队列一路到 TCP 之后,此时的是 events 了,比如一个 socket 的结构体是一个 event,里面挂着一个 wait queue 链表,里面全是回调函数。当内核负责 tcp 收发包的软中断触发某些,比如缓冲区可写之类的,就会去找挂在 wait queue 的链表,触发一个回调。对于阻塞的 read write 调用,其实就是挂一个唤醒进程的 callback 就行了。
然后走到 select poll 这些来吧。
复习之前做的 select poll epoll 源码分析,
网络栈搞定了拆包检查和丢掉垃圾之后,一路到 tcp udp 包里,然后进行路由,根据 ip port 四元组用 hashmap 或者 红黑树把他们导到对应 descriptor 的内核对应的 buffer 里面,这个时候应该更新他们的 file 结构体!
因为 read 和 write 的时候是要阻塞的,所以一般这种描述符会提供一个接口方便直接判断缓冲区有没有变化,不然每次去查缓冲区也不靠谱。首先复习一下 struct file 先,以前学的时候都是看简化的,书也是直接保留几个重要成员而已,实际要实现 poll 还是要做这些 meta 的管理的。在 struct file 里面除了记录哪些 offset 和 打开的 mode 文件锁之外,还会有一个 file_operator 结构体指针,这个结构体是由 vfs 定义支持的各种操作的,具体实现会绑定到驱动里面,像 aio,sendfile,splice,read,write 这些,这个其实很好理解的,就是个虚函数表嘛!由于新的 linux 的代码面目全非,可能还是看 2.x 版本的源码好看点。为了简化笔记,放有这个源码的链接供参考:Linux中的file,inode,file_operations三大结构体
所以总之 poll 本身就是个 vfs 提供的接口可以用来查询是否有数据(which 是网络栈接收到包后会更新的信息)。
是 epoll 睡死的时候如果有网络数据来了或者发生各种事件了怎么实现唤醒功能呢?最简单的方法是回调!所以这个回调放到哪里注册呢?这个要涉及 kernel 是怎么实现 sleep 的 chan 的,这里会有一个 wait queue 管理所有会阻塞的调用的,这个东西实际是在 struct file 的一个 void * 里面的!void *private_data 。看着不起眼,但是他会是驱动访问的一个东西。为什么是 void * 呢?我们知道 vfs 搞了一大套东西只是为了抽象通用的而已,比如我们用的阻塞,有的文件 vfs 过来他不会阻塞的啊,所以这部分数据就不好抽象了。那么既然我们能知道 fd 实际是什么类型(或者说 fd 被特定的驱动管理着),那么我们只需要设置一个 void* 域就行了,谁要写什么自己分配内存定制结构体就行了!!!所以这个字段就可以用来实现我们的 wake up chan 了,套接字文件里,这里会是一个 struct socket 来 reinterpret_cast 的。这个 socket 结构体里面会有一个 wait_queue_head_t wait 字段,他就是到时候会进行唤醒的所有回调设置,这个 epoll 回调函数就是注册到这里的!
然后还有一个事情就是当回调被激发的时候 epoll 实际还是要查询其他的 fd 的,因为要实现把所有的 fd 都给了解然后传上来。实际上这里的方法是如果被触发了,上面说这个回调函数会把这个 fd 链到我们的一个返回链表上!double linked list 的好处就是方便插入删除(O1). (当然链的时候肯定要判断一下是不是我们感兴趣的,不过不是我们感兴趣的我们也不会注册他)
select 和 poll 都会注册回调函数,不过这个回调函数做的是简单的唤醒而已。唤醒了之后,select 和 poll 就会去遍历整个 table,然后调用那个fop poll 函数判断有没有。而且每遍历 n 个文件会尝试 yield 避免占用太多时间。处理一下超时的事情,之后再返回用户。用户还要再遍历检查一下 fdset。每次调用select,都需要从0bit一直遍历到最大的fd,并且每隔32个fd还有调度一次(2次上下文切换)
这里其实说的不是很明白,只知道 poll 接口是回调触发之后去进行试探的一个接口。接下来补充这个点。
poll 接口是这样子的:
unsigned int (*poll) (struct file *, poll_table *);
驱动要做的事情就是,调用内核提供的 poll_wait 函数在这个 fd 的 wait queue 上,
void poll_wait (struct file *, wait_queue_head_t *, poll_table *);
这个函数,做的事情是把当前的文件 file*(驱动硬件),运行 poll 的当前进程添加到wait参数指定的等待列表(poll_table)中,poll table 是内核传过来的。
在讲清楚一点,首先声明一个 wait_queue, 这个 wait queue 会被这个驱动可用的时候被运行。
然后 poll table 是一个内核关注的事件表,poll wait 就是把这个表挂一个 callback 到驱动自己的 wait queue 里。这个表被挂的时机是 select 、poll 调用 poll 来查询的时候(注册关心事件)还能直接返回当前状态,这样避免了睡大觉。(注意这里省去了 tcp 层,为了避免太混乱)。
字符驱动程序之——poll机制 - Crystal_Guang - 博客园 (cnblogs.com)
网上的代码都太老了,这个是比较新的带注释:
-/Linux内核中-Poll的实现.md at master · IMCG/- (github.com)
最好的自己去看 linux 的源代码,bootlin 就可以看或者 github。
poll 接口基本明白了,然后再来看 io_uring 是怎么处理 socket 的
Missing Manuals - io_uring worker pool (cloudflare.com)
FEAT_FAST_POLL explanation · Issue #487 · axboe/liburing (github.com)
Question about how liburing works · Issue #512 · axboe/liburing (github.com)
首先是 iou-wrk-xxxx 线程,以及 iou-sqp-xxxx 线程,这些在 perf 里面都可以看到,下面的图里面,关闭 SQPOLL 但是还有 wrk,(原因是我的程序对比 1 io_uring, SQPOLL=off 的情况,多了多个 accept 投递以及 io_uring_prep_shutdown 和 io_uring_prep_close 的调用)。
sqpoll,多个 accept 投递、调用了 shutdown:
多个 accept 投递, 调用了 shutdown。
单次 accept 投递,无 shutdown 调用,close 采用原生接口(一次系统调用)。
4个 iocontext,sqpoll attach,(这里没看到 sqpoll 线程,感觉是并发量不大,sqpoll 直接睡死了,于是还是在 submit 系统调用的 context 上执行了)。
总之开启 io_uring 之后,可能会有多个线程在运行的,至于线程什么时候启动就看情况了。manual 对于 IOSQE_ASYNC 的说法是这样的:
Normal operation for io_uring is to try and issue an
sqe as non-blocking first, and if that fails, execute
it in an async manner
回到上面这幅图上来,iou-wrk (iouring-worker)线程出现的原因应该是引发了 async punt,通过 ps -o thcount 了解到 单 io_uring + sqpoll 最多有 6个线程在运行. 我人麻了,这些都要向 rlimit 收钱的啊。。。
然后是 io uring 运行的方法,首先,以 recv/read 为例子。(源码可以分析 io_uring.c)(5.7以后,feat_fast_poll)
(recv/read 对于 socket 这种 unbounded 的)io_uring 获取一个 sqe (submit 的时候,进入 kernel space),
- 尝试使用 non-blocking 测试,发现可以读写(不能已经设置了 IOSQE_ASYNC),直接 inline 完成,投递结果。
- non-blocking 失败(EAGAIN),采用 poll 的驱动的那种做法,注册一个回调,如果有事件来了,就把 sqe 加入 io_uring_submit_sqe 的流程(并不是回到 sqe 队列)
- 这个 sqe 在下次的 non-blocking 进行的时候就会 inline 成功。(task context 上)
- 如果下次的 non-blocking 也失败了(EAGAIN again,这种情况一般不会发生,就类似如果你在 user space 做 poll/select/epoll 返回 ready 之后再 read/write 结果是 EAGAIN,那么问题一定在于出现了 thundering herd 的问题,即另一个线程什么的已经把他读走了),进入 async punt,即进入 iou-wrk 线程运行 blocking IO。
对于 bounded 的(硬盘读写 etc),直接进入 worker pool 执行(有限个 worker 内核线程),当然也可能直接 inline 进行(只要 user space 有显式调用了 wait cqe,这部分可能性要参考最新代码 fs/io_uring.c 里面怎么做)
注意这个 blocking io,对于 regular file,走 worker pool 是可行的,因为读写最终会结束而且是必要的,对于 socket,有多少个读写调用请求 pending(say sq 一直满载的时候,那最多会有 sq 的大小个线程,或者由配置限制或者由 rlimit 限制),就有多少个线程,所以建议一般的 sqe 都要 link 一个 timeout 避免超时。
但是这个 poll 返回的结果是 EAGAIN 的情况是很少见的,只有比如说提交了多个 recv 请求,都进入到 poll 里面,然后类似于惊群的效果?然后 axboe 说这种情况很少出现,所以目前没有直接做 rearm poll 然后继续 poll。
但是我的 echo server 出现了 iou-wrk。我一开始认为是因为投递了多个 accept 请求,导致 accept 可用时 callback retry 的时候出现 -EAGAIN (??backlog 队列虚设了吗)但是我保留一个 accept 的时候,问题仍然存在。。。于是我找到了提交 shutdown then close 的地方,只要有 shutdown (io_uring 版本)被调用,就会引发 30 几个 async punt(iou-wrk 线程)。
不过之后不用 shutdown 之后发现 accept 也会引发多达 1000+ 的 iou-wrk。。。和这里的情况差不多了:Missing Manuals - io_uring worker pool (cloudflare.com) 这里使用 IOSQE_ASYNC 出现最多 4096 个 iou-wrk。属实绷不住了。
然后这个开销也是有的,同样用 ab 压测,有多个 iou-wrk 引发了并发 QPS 下降 30% + 吞吐量我还没测因为只是写了 hello http 请求而已。但是听说(issue区)目前 io_uring 对 splice 的支持也引发性能 tank,所以我感觉 sendfile 也不用实现了,还是暂时用内存缓冲有拷贝先吧。。(或者 register buffer 之类的减少开销)。
结论是,不要用 shutdown,直接用 close (然后采用 solinger 选项。。。,特殊的需要用到最后的 FIN_WAIT 的话,就用 shutdown 吧,那也是必须的,但是会引发 )
2022.04.06
记录一下 io_uring 用于 socket 触发大量 wrq 线程的问题
昨天我写了一个 sq poll 的版本。好不容易编译了内核(教训是内存和 swap 一定要充足)。然后编译了 perf(原来这些 linux kernel 源码就自带了,为什么不直接编译呢?)kernel 源码1,200M,编译完之后 15 m。
然后进行了 perf 测试,大跌眼镜。我本来以为在 ECS 上是 CPU 瓶颈 lo 跑的是 4000 /min 的并发(2核资源受限云资源虚拟机),结果本机 wsl cpu100% 也是跑出这个结果。只能上 perf 了。
以下都是在本机跑的 (perf stat)
可以看到,对于没有 sq 和多线程的,context switch 爆了,大部分在睡觉感觉,3秒 user 态就是用来做快速的 dispatch 和 coroutine 的resume 的,但是并发量很高。
而开了 sq 和多线程之后,CPU 100%,基本没有 context switch,也就是符合的主要时间都在 user space,70秒 sys 应该是内核 SQ 线程忙。
所以为什么并发量这么垃圾呢?我感觉是不是出在 event loop 里面了,我得写一个不用 sq 的 多线程来对比。在这之前我需要先检查一下,并且把 logger 的 level 设置调好先。
然后,结果出来了,准备要上log 了,在这之前,先 perf 一下吧。
正图是无 sq poll 的,附图是有 sq poll 的,可以看到,context switches 的数量差别巨大!??为什么并发量差别缺这么大(倒过来)呢?
火焰图啥也看不清楚
SQ Poll 的版本全部是 vmlinux ,没有函数名字(直接进内核了,不知道是不是 perf 权限没调好,我明明用了 sudo。)
无 SQ poll
SQ poll
然后乱改了一通,还是采用回原来的 wait for completion 版本,之后正常了很多。但是我再和 epoll 的 proactor (CppNet github) 比较的时候,1 conncurency (apache bench),跑出输了,。然后再和 raw 的 c+liburing echo 魔改的,一样的流程:
会不会是 exception 的问题呢?双了,再比较一下线程有没有优势,卧槽,毫无优势(感觉是并发量太小了,然后 user space 本来也不用做什么,反而很快)
然后 2048 concurrency ,感觉输麻了??
之后得跑一下 perf 看看 c++ 的问题比 raw 的多在哪里。。。。我(去掉 log 也一样,,,)
确实符合预期,根本不需要两个 context,跑 single server + sqpoll 和 threaded server + sqpoll 是一样的。
更多推荐
所有评论(0)