上一篇我们看到在Go1.13中一个空的for循环就让程序挂起了,这可真是个隐患。虽然我们不会在生产环境写出这种代码,但是对于调度器来讲,毕竟是个缺陷。所以在1.14版本中,这个问题被解决了,Go语言实现了真正的抢占式调度。

这种“真正”的抢占是如何实现的呢?在Unix系操作系统上是基于信号来实现的,所以也称为异步抢占。接下来就以Linux系统为例,实际研究一下。这次需要先从源码开始,对比一下1.14与1.13有哪些不同,了解了具体的细节之后再通过调试等手段来进行相关实践。

下面就是1.14版runtime.preemptone函数的源码,可以看到比之前的1.13版多出来了最后的那个if语句块:

func preemptone(_p_ *p) bool {
     mp:= _p_.m.ptr()
     if mp == nil || mp == getg().m {
         return false
     }
     gp:= mp.curg
     if gp == nil || gp == mp.g0 {
         return false
     }
     gp.preempt= true
     gp.stackguard0= stackPreempt
     if preemptMSupported && debug.asyncpreemptoff == 0 {
         _p_.preempt= true
         preemptM(mp)
     }
     returntrue
}

其中的preemptMSupported是个常量,因为受硬件特性的限制,在某些平台上是无法支持这种抢占实现的。debug.asyncpreemptoff则是让用户可以通过GODEBUG环境变量来禁用异步抢占,默认情况下是被启用的。在P的数据结构里也新增了一个preempt字段,这里会把它设置为true。实际的抢占操作是由preemptM函数完成的。

preemptM的主要逻辑,就是通过runtime.signalM函数向指定M发送sigPreempt信号。至于signalM函数,就是调用操作系统的信号相关系统调用,将指定信号发送给目标线程。至此,异步抢占工作的前一半就算完成了,信号已经发出去了。

后一半工作

异步抢占工作的后一半,就要由接收到信号的工作线程来完成了。还是先定位到相应的源码,runtime.sighandler函数就是负责处理接收到的信号的,其中有这样一个if语句:

if sig == sigPreempt {
     doSigPreempt(gp,c)
}

如果收到的信号是sigPreempt,就调用doSigPreempt函数。doSigPreempt的代码如下:

func doSigPreempt(gp *g, ctxt *sigctxt){
     if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(),ctxt.sigsp(), ctxt.siglr()) {
         ctxt.pushCall(funcPC(asyncPreempt))
     }
 
     atomic.Xadd(&gp.m.preemptGen,1)
     atomic.Store(&gp.m.signalPending,0)
 
     if GOOS == "darwin" {
         atomic.Xadd(&pendingPreemptSignals,-1)
     }
}

重点就在于第一个if语句块,它先通过wantAsyncPreempt确认runtime确实想要对指定的G实施异步抢占,再通过isAsyncSafePoint确认G当前执行上下文是能够安全的进行异步抢占的。实际看一下wantAsyncPreempt的源码:

func wantAsyncPreempt(gp *g) bool {
     return(gp.preempt || gp.m.p != 0 && gp.m.p.ptr().preempt) &&readgstatus(gp)&^_Gscan == _Grunning
}

它会同时检查G和P的preempt字段,并且G当前需要处于_Grunning状态。isAsyncSafePoint的代码比较复杂且涉及较多其他细节,这里就不展示源码了。它从以下几个方面来保证在当前位置进行异步抢占是安全的:

  1. 可以挂起g并安全的扫描它的栈和寄存器,没有潜在的隐藏指针,而且当前并没有打断一个写屏障;
  2. g还有足够的栈空间来注入一个对asyncPreempt的调用;
  3. 可以安全的和runtime进行交互,例如未持有runtime相关的锁,因此在尝试获得锁时不会造成死锁。

以上两个函数都确认无误,才通过pushCall向G的执行上下文中注入一个函数调用,要调用的目标函数是runtime.asyncPreempt。这是一个汇编函数,它会先把各个寄存器的值保存在栈上,也就是先保存现场到栈上,然后调用runtime.asyncPreempt2函数。asyncPreempt2的代码如下所示:

func asyncPreempt2() {
     gp:= getg()
     gp.asyncSafePoint= true
     if gp.preemptStop {
         mcall(preemptPark)
     }else {
         mcall(gopreempt_m)
     }
     gp.asyncSafePoint= false
}

其中preemptStop主要在GC标记时被用来挂起运行中的goroutine,preemptPark函数会把当前g切换至_Gpreempted状态,然后调用schedule函数。而通过preemptone发起的异步抢占会调用gopreempt_m函数,在前文中已经见过了,它最终也会调用schedule函数。至此,整个抢占过程就完整的实现了。

如何注入函数调用

关于如何在执行上下文中注入一个函数调用,我们在这里结合amd64架构做一下更细致的说明。runtime源码中,与amd64架构对应的pushCall的代码如下所示:

func (c *sigctxt) pushCall(targetPCuintptr) {
     pc:= uintptr(c.rip())
     sp:= uintptr(c.rsp())
     sp-= sys.PtrSize
     *(*uintptr)(unsafe.Pointer(sp))= pc
     c.set_rsp(uint64(sp))
     c.set_rip(uint64(targetPC))
}

先把SP向下移动一个指针大小的位置,把PC的值存入栈上SP指向的位置,然后再更新PC的值为target PC。

这样就模拟了一条CALL指令的效果,栈上存入的PC的旧值就相当于返回地址。此时整个执行上下文的状态,就像是goroutine在被信号打断的位置额外执行了一条CALL targetPC指令,然后执行流刚刚跳转到targetPC地址处,还没来得及执行目标地址处的指令。

当sighandler函数处理完信号并返回之后,被打断的goroutine得以继续执行,会立即调用被注入的asyncPreempt。经过一连串的函数调用,最终执行到schedule。

小实验

了解了整个流程之后,我们再来做一个很简单的实验。还是本小节最初的实验代码,用1.14版编译之后再运行,可以发现程序会一直输出,不在阻塞。这时,用dlv调试器attach到目标进程,并且在runtime.asyncPreempt2这个函数设置断点,然后让程序继续运行。等到命中断点后,查看调用栈的回溯:

(dlv)bt
0  0x00000000004302f0 in runtime.asyncPreempt2
   at /root/go1.14/src/runtime/preempt.go:302
1  0x000000000045d91b in runtime.asyncPreempt
   at /root/go1.14/src/runtime/preempt_amd64.s:50
2  0x0000000000491daf in main.main
   at ./main.go:12
3  0x00000000004318ea in runtime.main
   at /root/go1.14/src/runtime/proc.go:203
4  0x000000000045bff1 in runtime.goexit
   at /root/go1.14/src/runtime/asm_amd64.s:1373

从栈回溯来看是main函数调用了asyncPreempt,而main.go的12行正是那个空的for循环,它是没有调用任何函数的,这个调用就是被pushCall注入的。

还有一种方式,可以通过GODEBUG环境变量来禁用异步抢占,就会发现1.14版编译的程序运行一会儿之后也会阻塞:

$ GODEBUG='asyncpreemptoff=1' ./sched

另外还有一点,如果把协程中用来打印的fmt.Println换成println的话,就会发现运行很久都不会阻塞,即使是1.13版编译的程序也是如此。那是因为println不需要额外分配内存的原因,感兴趣的读者可以自行尝试。