深度解密 Go 语言之基于信号的抢占式调度
不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序“死机”。我去年就遇到过,并且搞出了一起 P0 事故,还写了篇弱智的找 bug 文章。
识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:
我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++
。
接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。
你可以自己思考一下,输出会是什么?
如果你想出了答案,接着再看下面这个 demo:
我也来解释一下,在主 goroutine 里,只启动了一个 goroutine(虽然程序里用了一个 for 循环,但其实只循环了一次,完全是为了和前面的 demo 看起来更协调一些),同样执行了一个 x++
的无限 for 循环。
和前一个 demo 的不同点在于,在主 goroutine 里,我们手动执行了一次 GC;最后,打印 x 的值。
如果你能答对第一题,大概率也能答对第二题。
下面我就来揭晓答案。
其实我留了一个坑,我没说用哪个版本的 Go 来运行代码。所以,正确的答案是:
Go 版本 | demo-1 | demo-2 |
---|---|---|
1.13 | 卡死 | 卡死 |
1.14 | 0 | 0 |
这个其实就是 Go 调度器的坑了。
假设在 demo-1 中,共有 4 个 P,于是创建了 4 个 goroutine。当主 goroutine 执行 sleep 的时候,刚刚创建的 4 个 goroutine 马上就把 4 个 P 霸占了,执行死循环,而且竟然没有进行函数调用,就只有一个简单的赋值语句。Go 1.13 对这种情况是无能为力的,没有任何办法让这些 goroutine 停下来,进程对外表现出“死机”。
由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。
理解了这个 demo,第二个 demo 其实是类似的道理:
当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。
Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:
能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。
如果没有调用函数,肯定不会被抢占。
有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。
像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。
preemptone
一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone()
函数。
preemptone()
函数会沿着下面这条路径:
1preemptone->preemptM->signalM->tgkill
向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG
信号。
注册 sighandler
每个 M 在初始化的时候都会设置信号处理函数:
1initsig->setsig->sighandler
信号执行过程
我们从“宏观”层面看一下信号的执行过程:
主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m
,接着就要执行指令 m+1
了……不幸在这个时候发生了,线程收到了一个信号,对应图中的 ①
。
接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的 ②
和 ③
。
最后,执行流又交到线程手上,继续执行指令 m+1
,对应图中的 ④
。
这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。
dosigPreempt
当线程收到 SIGURG
信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。
1func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
2 ...
3
4 if sig == sigPreempt && debug.asyncpreemptoff == 0 {
5 doSigPreempt(gp, c)
6 }
7
8 ...
9}
doSigPreempt
这个函数其实很短,一会儿就执行完了。
1func doSigPreempt(gp *g, ctxt *sigctxt) {
2 ...
3 if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
4 // Adjust the PC and inject a call to asyncPreempt.
5 ctxt.pushCall(funcPC(asyncPreempt), newpc)
6 }
7 ...
8}
isAsyncSafePoint
函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。
接着,pushCall
调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1
了,但那还怎么实现抢占呢?其实魔法都在 pushCall
这个函数里。
pushCall
在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。
call
指令可以简单地理解为 push ip
+ JMP
。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip
就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。
ret
指令和 call
指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。
理解了 call
和 ret
,我们再来分析 pushCall
函数:
1func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
2 // Make it look like we called target at resumePC.
3 sp := uintptr(c.rsp())
4 sp -= sys.PtrSize
5 *(*uintptr)(unsafe.Pointer(sp)) = resumePC
6 c.set_rsp(uint64(sp))
7 c.set_rip(uint64(targetPC))
8}
注意看这行注释:
1// Make it look like we called target at resumePC.
它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。
前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1
,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt)
,也就是抢占函数。
于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。
异步抢占
当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:
mcall(fn)
的作用是切到 g0 栈去执行函数 fn
, fn
永不返回。在 mcall(gopreempt_m)
这里,fn 就是 gopreempt_m。
gopreempt_m
直接调用 goschedImpl
:
最精彩的部分就在 goschedImpl 函数。它首先将 goroutine 的状态从 running 改成 runnable;接着调 dropg 将 g 和 m 解绑;然后调用 globrunqput 将 goroutine 丢到全局可运行队列,由于是全局可运行队列,所以需要加锁。最后,调用 schedule()
函数进入调度循环。关于调度循环,可以看这篇文章。
运行 schedule
函数用的是 g0 栈,它会去寻找其他可运行的 goroutine,包括从当前 P 本地可运行队列获取、从全局可运行队列获取、从其他 P 偷等方式找到下一个可运行的 goroutine 并执行。
至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。
那被抢占的这个 goroutine 什么时候会再次得到执行呢?
因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。
还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。
总结
本文讲述了 Go 语言基于信号的异步抢占的全过程,一起来回顾下:
- M 注册一个 SIGURG 信号的处理函数:sighandler。
- sysmon 线程检测到执行时间过长的 goroutine、GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号。
- 收到信号后,内核执行 sighandler 函数,通过 pushCall 插入 asyncPreempt 函数调用。
- 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m。
- 将当前 goroutine 插入到全局可运行队列,M 则继续寻找其他 goroutine 来运行。
- 被抢占的 goroutine 再次调度过来执行时,会继续原来的执行流。
- 原文作者:饶全成
- 原文链接:https://qcrao.com/post/diving-into-preempt-by-signal/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。