bupa不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序“死机”。我去年就遇到过,并且搞出了一起 P0 事故,还写了篇弱智的找 bug 文章。

识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:

demo-1
x++

接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。

你可以自己思考一下,输出会是什么?

如果你想出了答案,接着再看下面这个 demo:

demo-2
x++

和前一个 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 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。

preemptone
preemptone()
preemptone()
preemptone->preemptM->signalM->tgkill
SIGURG
注册 sighandler

每个 M 在初始化的时候都会设置信号处理函数:

initsig->setsig->sighandler
信号执行过程

我们从“宏观”层面看一下信号的执行过程:

信号执行过程
mm+1①
②③
m+1④

这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。

dosigPreempt
SIGURG
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
goschedImpldropg
schedule()
schedule

至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。

那被抢占的这个 goroutine 什么时候会再次得到执行呢?

因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。

还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。

总结

本文讲述了 Go 语言基于信号的异步抢占的全过程,一起来回顾下:

  1. M 注册一个 SIGURG 信号的处理函数:sighandler。
  2. sysmon 线程检测到执行时间过长的 goroutine、GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号。
  3. 收到信号后,内核执行 sighandler 函数,通过 pushCall 插入 asyncPreempt 函数调用。
  4. 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m。
  5. 将当前 goroutine 插入到全局可运行队列,M 则继续寻找其他 goroutine 来运行。
  6. 被抢占的 goroutine 再次调度过来执行时,会继续原来的执行流。

END