由上一篇文章我们知道,基于协作的抢占调度器原理以及弊端,基于这些弊端,go团队后续又改进到了基于信号的抢占调度器。
在弄清楚这个之前,先介绍一下抢占调度的时期(主要就是GC和sysmon期间)
抢占调度时期
-
STW期间(stop the world)
首先是STW期间,这个在java垃圾回收过程中也有此过程,STW 是指在某些情况下,Go 运行时暂停所有 Goroutine 的执行,以便执行一些全局操作。通常情况下,STW 发生在进行垃圾回收(GC)或执行某些全局操作时。在 STW 期间,所有 Goroutine 都会被暂停,不再执行任何指令,以确保在执行全局操作时数据的一致性和准确性。 -
P上执行safe point函数期间
Safe Point 其实就是一个代码的特殊位置,在这个位置时线程可以暂停下来。而当我们进行 GC 的时候,所有线程都要进入到 Safe Point 处,才可以进行内存的分析及垃圾回收。根据这个过程,其实我们可以看到:Safe Point 其实就是栅栏的作用,让所有线程停下来,否则如果所有线程都在运行的话,无法进行对象引用的分析,那么也无法进行垃圾回收了。在 P 上执行 safe point 函数期间,通常会暂停当前 P 上的 Goroutine,确保在进行全局操作时 Goroutine 的状态是安全的,并且可以进行必要的操作,如栈扫描或栈回收。 -
sysmon后台监控期间
这里的几种阻塞和时间太长情况在上章都有讲过 -
gc pacer分配新的dedicated worker期间
GC Pacer 是 Go 语言中垃圾回收器的一部分,负责控制和调整垃圾回收的速率,以确保垃圾回收对程序的影响最小化。在进行垃圾回收时,GC Pacer 可能会分配新的 dedicated worker(专用工作线程),用于执行垃圾回收所需的工作。这些 dedicated worker 负责扫描栈、标记对象、清理不再使用的对象等操作,以帮助完成垃圾回收的任务。 -
panic崩溃期间
Panic 是 Go 语言中的一种异常情况,通常在程序出现严重错误时触发。当程序遇到无法处理的错误或异常时,会引发 panic,导致程序崩溃。在 panic 发生时,Go 运行时会进行一系列的清理操作,包括释放资源、打印错误信息等,并最终终止程序的执行。在 panic 发生时,程序的状态可能会变得不稳定或不一致,因此通常需要谨慎处理 panic,以确保程序的可靠性和稳定性。例如:
当我们尝试对一个 nil 指针进行解引用时,就会触发 panic -
栈扫描期间(GC时)
goroutine 的栈是 GC 扫描期间的根,所有 markroot 中需要将用户的 goroutine 停下来
package main
func main() {
var ptr *int
*ptr = 10 // 尝试对 nil 指针进行解引用
}
除了栈扫描,所有触发抢占最终都会去执行 preemptone 函数。栈扫描流程比较特殊:
而preemptone如下所示,执行力signalM发出来sigPreempt信号
func preemptM(mp *m) {
if atomic.Cas(&mp.signalPending, 0, 1) {
signalM(mp, sigPreempt)
}
}
在深入探讨信号式抢占的原理之前,我们需要了解一些基础知识。在操作系统中,信号是一种用于通知进程发生了某种事件的机制。当一个进程收到信号时,它可以执行预定义的函数或操作来处理该信号。在Go语言中,通过使用操作系统提供的信号机制,实现了对线程的抢占。
调度触发时期
因为调度器的 runtime.schedule 会重新选择 Goroutine 在线程上执行,所以我们只要找到该函数的调用方就能找到所有触发调度的时间点,经过分析和整理,我们能得到如下的树形结构:
除了上图中可能触发调度的时间点,运行时还会在线程启动 runtime.mstart 和 Goroutine 执行结束 runtime.goexit0 触发调度。我们在这里会重点介绍运行时触发调度的几个路径:
- 主动挂起 — runtime.gopark -> runtime.park_m
- 系统调用 — runtime.exitsyscall -> runtime.exitsyscall0
- 协作式调度 — runtime.Gosched -> runtime.gosched_m -> runtime.goschedImpl
- 系统监控 — runtime.sysmon -> runtime.retake -> runtime.preemptone
抢占具体分类
上一节,我们介绍了上一个版本的调度器,而到了现在这个版本,又是对哪种情况使用哪种调度器呢,我大致进行了以下总结:
在上一个版本中,针对边缘情况导致的执行时间过长无法垃圾回收问题,引入了信号机制的抢占调度器,那1.14之后的版本是怎么样的呢?
Go 的运行时并不具备操作系统内核级的硬件中断能力,基于工作窃取的调度器实现,本质上属于 先来先服务的协作式调度,为了解决响应时间可能较高的问题,目前运行时实现了两种不同的调度策略、 每种策略各两个形式。保证在大部分情况下,不同的 G 能够获得均匀的时间片:(同步抢占指的是在进行垃圾回收时,GC 在安全点上暂停当前正在执行的 Goroutine,并等待其执行完毕或到达一个安全点。异步指的就是在进行垃圾回收时,GC 在安全点上强制暂停当前正在执行的 Goroutine)
- 同步协作式调度
- 主动用户让权:通过 runtime.Gosched 调用主动让出执行机会;
- 主动调度弃权:当发生执行栈分段时,检查自身的抢占标记,决定是否继续执行; (使用的就是协作式调度)
- 异步抢占式调度
- 被动监控抢占:当 G 阻塞在 M上时(系统调用、channel 等),系统监控会将 P 从 M 上抢夺并分配给其他的 M 来执行其他的 G,而位于被抢夺 P 的 M本地调度队列中 的 G 则可能会被偷取到其他 M 中。 (上一节的IO阻塞,网络阻塞都属于此情况,实际就是sysmon识别到了阻塞,而把P抢走)
- 被动 GC抢占:当需要进行垃圾回收时,为了保证不具备主动抢占处理的函数执行时间过长,导致 导致垃圾回收迟迟不得执行而导致的高延迟,而强制停止 GC并转为执行垃圾回收。(这种就是抢占M,基于信号的抢占调度)
基于信号的抢占调度大致流程:
首先,调度器会时刻监控当前正在执行的线程的状态,一旦满足抢占的条件,就会向该线程发送SIGURG信号。这个条件可能包括STW期间、P上执行safe point函数期间、sysmon后台监控期间、gc pacer分配新的dedicated worker期间以及panic崩溃期间等。通过在这些时机发送信号,调度器能够有效地控制线程的执行流程。
当线程收到SIGURG信号后,它会进入SIGURG的处理流程。在这个流程中,Go语言的runtime库会强制调用asyncPreempt函数。这个函数的作用是将asyncPreempt的调用强制插入到用户当前执行的代码位置,从而实现真正的抢占。通过这种方式,Go语言能够在不改变原有代码的基础上实现对线程的抢占,使得并发编程更加灵活和高效。
详细分析
具体流程如下所示:
- 程序启动时,在 runtime.sighandler 中注册 SIGURG 信号的处理函数 runtime.doSigPreempt;
- 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 挂起 Goroutine,该函数会执行下面的逻辑: 将
_Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true; 调用 runtime.preemptM 触发抢占; - runtime.preemptM 会调用 runtime.signalM 向线程发送信号
SIGURG; - 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt;(进入了内核态从操作系统层面的中断)
- runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用runtime.sigctxt.pushCall;
- runtime.sigctxt.pushCall会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt;
- 汇编指令 runtime.asyncPreempt会调用运行时函数 runtime.asyncPreempt2;
- runtime.asyncPreempt2 会调用 runtime.preemptPark;
- runtime.preemptPark 会修改当前 Goroutine 的状态到_Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;
相信大部分初学者看到这么多未知的函数都会和我一样一头雾水,我们对其中几个核心步骤的代码做一下分析
第一步:
//go:nowritebarrierrec
func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
...
c := &sigctxt{info, ctxt}
...
if sig == sigPreempt {
// 可能是一个抢占信号
doSigPreempt(gp, c)
// 即便这是一个抢占信号,它也可能与其他信号进行混合,因此我们
// 继续进行处理。
}
...
}
// doSigPreempt 处理了 gp 上的抢占信号
func doSigPreempt(gp *g, ctxt *sigctxt) {
// 检查 G 是否需要被抢占、抢占是否安全
if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
// 插入抢占调用
ctxt.pushCall(funcPC(asyncPreempt))
}
// 记录抢占
atomic.Xadd(&gp.m.preemptGen, 1)
第二步
主要是栈扫描的时候触发
第三步
preemptM 就是发送信号量的核心函数
func preemptM(mp *m) {
if atomic.Cas(&mp.signalPending, 0, 1) {
signalM(mp, sigPreempt)
}
}
preemptM 这个函数会调用 signalM 将在初始化的安装的 _SIGURG 信号发送到指定的 M 上。
使用 preemptM 发送抢占信号的地方主要有下面几个:(这里也就是相对于上个版本的改进)
- Go 后台监控 runtime.sysmon 检测超时发送抢占信号;
- Go GC 栈扫描发送抢占信号;
- Go GC STW 的时候调用
preemptall 抢占所有 P,让其暂停;
第七步
func asyncPreempt2() {
gp := getg()
gp.asyncSafePoint = true
// 该 G 是否可以被抢占
if gp.preemptStop {
mcall(preemptPark)
} else {
// 让 G 放弃当前在 M 上的执行权利,将 G 放入全局队列等待后续调度
mcall(gopreempt_m)
}
gp.asyncSafePoint = false
}
小结
Go 语言的调度器在 1.2 版本中引入基于协作的抢占式调度解决下面的问题:
- 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
- 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;
1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。
当运行时需要执行垃圾回收时,
协作式调度能够保证具备函数调用的用户 Goroutine 正常停止;
基于信号的抢占式调度则能避免由于死循环导致的任意时间的垃圾回收延迟。
有了这两种不同的调度策略, Go 语言的用户可以放心的写出各种形式的代码逻辑,即使运行时垃圾回收也能够在适当的时候及时中断用户代码, 不至于导致整个系统进入不可预测的停顿。