这一次来讲讲基于信号式抢占式调度。

介绍

在 Go 的 1.14 版本之前抢占试调度都是基于协作的,需要自己主动的让出执行,但是这样是无法处理一些无法被抢占的边缘情况。例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

下面我们通过一个例子来验证一下1.14 版本和 1.13 版本之间的抢占差异:

runtime.GOMAXPROCS(1)

下面我们编译程序分析 trace 输出:

然后我们获取到 trace.output 文件后进行可视化展示:

Go1.13 trace 分析

从上面的这个图可以看出:

go func() fmt.Println("total:", t)

从上面的 trace 分析可以知道,Go 的协作式调度对 calcSum 函数是毫无作用的,一旦执行开始,只能等执行结束。每个 goroutine 耗费了 0.23s 这么长的时间,也无法抢占它的执行权。

Go 1.14 以上 trace 分析

在 Go 1.14 之后引入了基于信号的抢占式调度,从上面的图可以看到 Proc0 这一栏中密密麻麻都是 goroutines 在切换时的调用情况,不会再出现 goroutines 一旦执行开始,只能等执行结束这种情况。

上面跑动的时间是 4s 左右这个情况可以忽略,因为我是在两台配置不同的机器上跑的(主要是我闲麻烦要找两台一样的机器)。

下面我们拉近了看一下明细情况:

通过这个明细可以看出:

  1. 这个 goroutine 运行了 0.025s 就让出执行了;
  2. 切入调用栈 Start Stack Trace 是 main.main.func1:21,和上面一样;
  3. 切走调用栈 End Stack Trace 是 runtime.asyncPreempt:50 ,这个函数是收到抢占信号时执行的函数,从这个地方也能明确的知道,被异步抢占了;

分析

抢占信号的安装

runtime/signal_unix.go

runtime.sighandlerSIGURGruntime.doSigPreempt

initsig

在 initsig 函数里面会遍历所有的信号量,然后调用 setsig 函数进行注册。我们可以查看 sigtable 这个全局变量看看有什么信息:

 _SigNotify + _SigIgn
runtime/os_linux.go

setsig

这里需要注意的是,当 fn 等于 sighandler 的时候,调用的函数会被替换成 sigtramp。sigaction 函数在 Linux 下会调用系统调用函数 sys_signal 以及 sys_rt_sigaction 实现安装信号。

执行抢占信号

到了这里是信号发生的时候进行信号的处理,原本应该是在发送抢占信号之后,但是这里我先顺着安装信号往下先讲了。大家可以跳到发送抢占信号后再回来。

上面分析可以看到当 fn 等于 sighandler 的时候,调用的函数会被替换成 sigtramp,sigtramp是汇编实现,下面我们看看。

src/runtime/sys_linux_amd64.s
runtime·sigtrampruntime·sigtrampruntime·sigtrampgo
 runtime/signal_unix.go

sigtrampgo&sighandler

sighandler 方法里面做了很多其他信号的处理工作,我们只关心抢占部分的代码,这里最终会通过 doSigPreempt 方法执行抢占。

 runtime/signal_unix.go

doSigPreempt

ctxt.pushCallruntime/preempt.go
 src/runtime/preempt_amd64.sruntime/preempt.go

asyncPreempt2

runtime/preempt.go_Grunninggp.preemptStop = true
runtime/proc.go

preemptPark

_Gpreempted

gopreempt_m

gopreempt_m 方法比起抢占更像是主动让权,然后重新加入到执行队列中等待调度。

抢占信号发送

抢占信号的发送是由 preemptM 进行的。

runtime/signal_unix.go

preemptM

_SIGURG

使用 preemptM 发送抢占信号的地方主要有下面几个:

  1. Go 后台监控 runtime.sysmon 检测超时发送抢占信号;
  2. Go GC 栈扫描发送抢占信号;
  3. Go GC STW 的时候调用 preemptall 抢占所有 P,让其暂停;

Go 后台监控执行抢占

runtime.sysmonruntime.retake

系统监控通过在循环中抢占主要是为了避免 G 占用 M 的时间过长造成饥饿。

runtime.retake
  1. 调用 preemptone 抢占当前处理器;
  2. 调用 handoffp 让出处理器的使用权;

抢占当前处理器

_Prunning_Psyscall

调用 handoffp 让出处理器的使用权

_Psyscall
runqempty(_p_)atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle)pd.syscallwhen+10*1000*1000 > now

Go GC 栈扫描发送抢占信号

GC 相关的内容可以看这篇:《Go语言GC实现原理及源码分析 https://www.luozhiyun.com/archives/475》。Go 在 GC 时对 GC Root 进行标记的时候会扫描 G 的栈,扫描之前会调用 suspendG 挂起 G 的执行才进行扫描,扫描完毕之后再次调用 resumeG 恢复执行。

runtime/mgcmark.go

markroot

_Grunning
runtime/preempt.go

suspendG

_Grunning

Go GC StopTheWorld 抢占所有 P

runtime/proc.go

stopTheWorldWithSema

stopTheWorldWithSema 函数会调用 preemptall 对所有的 P 发送抢占信号。

runtime/proc.go

preemptall

preemptall 调用的 preemptone 会将 P 对应的 M 中正在执行的 G 并标记为正在执行抢占;最后会调用 preemptM 向 M 发送抢占信号。

runtime/proc.go

preemptone

总结

到这里,我们完整的看了一下基于信号的抢占调度过程。总结一下具体的逻辑:

_SIGURGruntime.doSigPreempt_SIGURGruntime.doSigPreemptruntime.asyncPreempt

Reference

详解Go语言调度循环源码实现 https://www.luozhiyun.com/archives/448