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

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

demo-1

我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++

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

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

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

demo-2

我也来解释一下,在主 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 停下来,进程对外表现出“死机”。

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

一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone() 函数。

preemptone() 函数会沿着下面这条路径:

preemptone->preemptM->signalM->tgkill

向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG 信号。

注册 sighandler

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

initsig->setsig->sighandler

信号执行过程

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

信号执行过程

主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m,接着就要执行指令 m+1 了……不幸在这个时候发生了,线程收到了一个信号,对应图中的

接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的

最后,执行流又交到线程手上,继续执行指令 m+1,对应图中的

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

dosigPreempt

当线程收到 SIGURG 信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。

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 函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。

接着,pushCall 调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1 了,但那还怎么实现抢占呢?其实魔法都在 pushCall 这个函数里。

pushCall

在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。

call 和 ret 指令

call 指令可以简单地理解为 push ip + JMP。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip 就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。

ret 指令和 call 指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。

理解了 callret,我们再来分析 pushCall 函数:

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 的指令地址。

前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt),也就是抢占函数。

于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。

异步抢占

当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:

asyncPreempt 调用链路

mcall(fn) 的作用是切到 g0 栈去执行函数 fn, fn 永不返回。在 mcall(gopreempt_m) 这里,fn 就是 gopreempt_m。

gopreempt_m 直接调用 goschedImpl

goschedImpl

dropg

最精彩的部分就在 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 语言基于信号的异步抢占的全过程,一起来回顾下:

  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 再次调度过来执行时,会继续原来的执行流。