bupa不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序“死机”。我去年就遇到过,并且搞出了一起 P0 事故,还写了篇弱智的找 bug 文章。
识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:
demo-1x++
接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。
你可以自己思考一下,输出会是什么?
如果你想出了答案,接着再看下面这个 demo:
demo-2x++
和前一个 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 停下来,进程对外表现出“死机”。
demo-1 示意图由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。
理解了这个 demo,第二个 demo 其实是类似的道理:
demo-2 示意图当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。
Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:
能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。
如果没有调用函数,肯定不会被抢占。
有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。
像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。
preemptonepreemptone()
preemptone()
preemptone->preemptM->signalM->tgkill
SIGURG
注册 sighandler
每个 M 在初始化的时候都会设置信号处理函数:
initsig->setsig->sighandler
信号执行过程
我们从“宏观”层面看一下信号的执行过程:
信号执行过程mm+1①
②③
m+1④
这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。
dosigPreemptSIGURG
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
...
if sig == sigPreempt && debug.asyncpreemptoff == 0 {
doSigPreempt(gp, c)
}
...
}
doSigPreempt
func doSigPreempt(gp *g, ctxt *sigctxt) {
...
if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
// Adjust the PC and inject a call to asyncPreempt.
ctxt.pushCall(funcPC(asyncPreempt), newpc)
}
...
}
isAsyncSafePoint
pushCallm+1pushCall
pushCall
在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。
call 和 ret 指令callpush ipJMPpush ip
retcall
callretpushCall
func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
// Make it look like we called target at resumePC.
sp := uintptr(c.rsp())
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = resumePC
c.set_rsp(uint64(sp))
c.set_rip(uint64(targetPC))
}
注意看这行注释:
// Make it look like we called target at resumePC.
它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。
m+1funcPC(asyncPreempt)
于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。
异步抢占当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:
asyncPreempt 调用链路mcall(fn)fnfnmcall(gopreempt_m)
gopreempt_mgoschedImpl
goschedImpldropgschedule()
schedule
至此,这个线程就转而去执行其他的 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 再次调度过来执行时,会继续原来的执行流。
END