这一次来讲讲基于信号式抢占式调度。
介绍
在 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 左右这个情况可以忽略,因为我是在两台配置不同的机器上跑的(主要是我闲麻烦要找两台一样的机器)。
下面我们拉近了看一下明细情况:
通过这个明细可以看出:
- 这个 goroutine 运行了 0.025s 就让出执行了;
- 切入调用栈 Start Stack Trace 是 main.main.func1:21,和上面一样;
- 切走调用栈 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 发送抢占信号的地方主要有下面几个:
- Go 后台监控 runtime.sysmon 检测超时发送抢占信号;
- Go GC 栈扫描发送抢占信号;
- Go GC STW 的时候调用 preemptall 抢占所有 P,让其暂停;
Go 后台监控执行抢占
runtime.sysmonruntime.retake
系统监控通过在循环中抢占主要是为了避免 G 占用 M 的时间过长造成饥饿。
runtime.retake
- 调用 preemptone 抢占当前处理器;
- 调用 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