Go语言的调度器经过几个大版本的迭代才有今天的优异性能,历史上几个不同版本的调度器引入了不同的改进。大致如下:
- 单线程调度器 0.x
- 只包含40多行代码
- 程序中只能存在一个活跃线程由G-M模型组成
- 多线程调度器1.0
- 允许运行多线程的程序
- 全局锁导致竞争严重
- 任务窃取调度器1.1
- 引入了处理器P,构成了GMP模型
- 在处理器P的基础上,实现了基于工作窃取的调度器
- 在某些情况下,Goroutine不会让出线程,进而造成饥饿问题
- 时间多长的垃圾回收(STW)会导致程序长时间无法工作
- 抢占式调度器1.2-至今
- 基于协作的抢占式调度器-1.2~1.13
- 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
- Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
- 基于信号的抢占式调度器-1.14~至今
- 实现基于信号的真抢占式调度;
- 垃圾回收在扫描栈时会触发抢占调度
- 抢占的时间点不够多,还不能覆盖全部的边缘情况
- 基于协作的抢占式调度器-1.2~1.13
- 非均匀存储访问调度器 · 提案
- 对运行时的各种资源进行分区;
- 实现非常复杂,到今天还没有提上日程;
此处先介绍一个基于协作的抢占式调度器。
基于协作的抢占式调度器
与操作系统按时间片调度线程不同,Go中并没有时间片的概念,如果某个G没有进行系统调用(syscall)、没有进行I/O操作、没有阻塞在一个channel操作上,那么M是如何让G停下来并调度下一个可运行的G的呢?答案是:G是被抢占调度的。除非极端的无限循环或死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。在Go程序启动时,运行时会启动一个名为sysmon的M(一般称为监控线程),该M的特殊之处在于它无须绑定P即可运行(以g0这个G的形式),Go程序启动时不止创建普通的调度线程,还存在辅助线程,辅助线程的主函数是runtime.sysmon,每10ms轮询一次,检测是否有协程执行时间过长,如果有,则通知该协程让出CPU。
我们可以在 pkg/runtime/proc.c 文件中找到引入基于协作的抢占式调度后的调度器。Go 语言会在分段栈的机制上实现抢占调度,利用编译器在分段栈上插入的函数,所有 Goroutine 在函数调用时都有机会进入运行时检查是否需要执行抢占。
对于长时间占用P的几种情况,我们逐个分析(1.14版本之前)
-
系统调用(syscall调用内核导致的阻塞,需要等待内核处理结果)
- 如果G被阻塞在某个系统调用上,那么不仅G会阻塞,执行该G的M也会解绑P(实质是被sysmon抢走了),与G一起进入阻塞状态。如果此时有空闲的M,则P会与其绑定并继续执行其他G;如果没有空闲的M,但仍然有其他G要执行,那么就会创建一个新M(线程)。当系统调用返回后,阻塞在该系统调用上的G会尝试获取一个可用的P,如果有可用P,之前运行该G的M将绑定P继续运行G;如果没有可用的P,那么G与M之间的关联将解除,同时G会被标记为runnable,放入全局的运行队列中,等待调度器的再次调度。
-
网络I/O操作或者channel操作
- 如果G被阻塞在某个channel操作或网络I/O操作上,那么G会被放置到某个等待队列中,而M会尝试运行P的下一个可运行的G。如果此时P没有可运行的G供M运行,那么M将解绑P,并进入挂起状态。当I/O操作完成或channel操作完成,在等待队列中的G会被唤醒,标记为runnable(可运行),并被放入某个P的队列中,绑定一个M后继续执行。
-
函数调用且需要扩充栈
- 除非极端的无限循环或死循环,否则只要G调用函数,Go运行时就有了抢占G的机会。在Go程序启动时,运行时会启动一个名为sysmon的M(一般称为监控线程),该M的特殊之处在于它无须绑定P即可运行(以g0这个G的形式)
-
// $GOROOT/src/runtime/proc.go // forcePreemptNS是在一个G被抢占之前给它的时间片 const forcePreemptNS = 10 * 1000 * 1000 // 10ms func retake(now int64) uint32 { ... // 抢占运行时间过长的G t := int64(_p_.schedtick) if int64(pd.schedtick) != t { pd.schedtick = uint32(t) pd.schedwhen = now continue } if pd.schedwhen+forcePreemptNS > now { continue } preemptone(_p_) ... }
可以看出,如果一个G任务运行超过10ms,sysmon就会认为其运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么在这个G下一次调用函数或方法时,运行时便可以将G抢占并移出运行状态,放入P的本地运行队列中(如果P的本地运行队列已满,那么将放在全局运行队列中),等待下一次被调度。
-
死循环等边缘情况
- 死循环等边缘情况例子比较复杂,这里来个具体例子(注意版本1.14之前)
-
// chapter6/sources/go-scheduler-model-case1.go func deadloop() { for { } } func main() { go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") } }
在上面的实例中,我们启动了两个goroutine,一个是main goroutine,另一个是运行deadloop函数(顾名思义,一个死循环)的goroutine。main goroutine为了展示方便,也用了一个死循环,并每隔一秒钟打印一条信息。下面是笔者运行这个例子的结果(笔者的机器是四核八线程的,runtime的NumCPU函数返回8
-
$go run go-scheduler-model-case1.go I got scheduled! I got scheduled! I got scheduled! ...
从运行结果输出的日志来看,尽管存在运行着死循环的deadloop goroutine,main goroutine仍然得到了调度。其根本原因在于机器是多核多线程的(这里指硬件线程,不是操作系统线程)。Go从1.5版本开始将P的默认数量由1改为CPU核的数量(实际上还乘了每个核上硬线程数量)。这样上述例子在启动时创建了不止一个P,我们用图来直观诠释一下。假设deadloop goroutine被调度到P1上,P1在M1(对应一个操作系统线程)上运行;而main goroutine被调度到P2上,P2在M2上运行,M2对应另一个操作系统线程,而线程在操作系统调度层面被调度到物理的CPU核上运行。我们有多个CPU核,因此即便deadloop占满一个核,我们还可以在另一个CPU核上运行P2上的main goroutine,这也是main goroutine得到调度的原因。下面略做修改
-
// chapter6/sources/go-scheduler-model-case2.go func deadloop() { for { } } func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") } }
运行这个程序后,你会发现main goroutine的"I got scheduled"再也无法输出了。deadloop goroutine在P1上被调度,由于deadloop内部逻辑没有给调度器任何抢占的机会,比如进入runtime.morestack_noctxt,所以即便是sysmon这样的监控goroutine,也仅仅是能将deadloop goroutine的抢占标志位设为true而已。由于deadloop内部没有任何进入调度器代码的机会,始终无法重新调度goroutine。main goroutine只能躺在P1的本地队列中等待。那么如何在死循环这种情况下,让goroutine有被抢占的机会:我们可以尝试进行函数调用:
-
// chapter6/sources/go-scheduler-model-case3.go func add(a, b int) int { return a + b } func deadloop() { for { add(3, 5) } } func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") } }
是不是以为进行了这个函数调用就可以正常输出回到main了,实际上并不是,"I got scheduled!"字样依旧没有出现!也就是说main goroutine没有得到调度!为什么呢?其实所谓的“有函数调用,就有了进入调度器代码的机会”,实际上是Go编译器在函数的入口处插入了一个运行时的函数调用:runtime.morestack_noctxt。这个函数会检查是否需要扩容连续栈,并进入抢占调度的逻辑中。一旦所在goroutine被置为可被抢占的,那么抢占调度代码就会剥夺该goroutine的执行权,将其让给其他goroutine。但是上面代码为什么没有实现这一点呢?因为在main.add代码中,我们没有发现morestack函数的踪迹,也就是说即便调用了add函数,deadloop也没有机会进入Go运行时的goroutine调度逻辑中。为什么Go编译器没有在main.add函数中插入morestack的调用呢?那是因为add函数位于调用树的leaf(叶子)位置,编译器可以确保其不再有新栈帧生成,不会导致栈分裂或超出现有栈边界,于是就不再插入morestack。这样位于morestack中的调度器的抢占式检查也就无法执行。既然明白了原理,我们就在deadloop和add函数之间再加入一个dummy函数
-
// chapter6/sources/go-scheduler-model-case4.go func add(a, b int) int { return a + b } func dummy() { add(3, 5) } func deadloop() { for { dummy() } } func main() { runtime.GOMAXPROCS(1) go deadloop() for { time.Sleep(time.Second * 1) fmt.Println("I got scheduled!") } }
执行该代码,最后发现调用成功了。所以说这就是基于协作的抢占式调用,想要抢占式调度,除了要sysmon识别到之外,还需要函数本身协作,也就是开启morestack检查,举一个汇编代码如下所示(这段汇编是简单main函数的汇编,不是上术代码,此处只是举例):
-
0x0000 00000 (morestack.go:5) TEXT "".main(SB), $64-0 0x0000 00000 (morestack.go:5) MOVQ TLS, CX 0x0009 00009 (morestack.go:5) MOVQ (CX)(TLS*2), CX // 获取当前g // 比较当前SP和g.stackguard0,如果小于则需要触发morestarck 0x0010 00016 (morestack.go:5) CMPQ SP, 16(CX) 0x0014 00020 (morestack.go:5) JLS 110 0x0016 00022 (morestack.go:5) SUBQ $64, SP // SP-64,相当于设置栈帧大小64字节 0x001a 00026 (morestack.go:5) MOVQ BP, 56(SP) // 保存caller的BP,可以看到BP是保存到当前函数的栈帧中的,如果一个函数栈帧大小为0,则不需要保存BP 0x001f 00031 (morestack.go:5) LEAQ 56(SP), BP // 设置当前BP ... 0x0064 00100 (morestack.go:7) MOVQ 56(SP), BP // 还原BP 0x0069 00105 (morestack.go:7) ADDQ $64, SP // SP+64,相当于销毁栈帧 0x006d 00109 (morestack.go:7) RET 0x006e 00110 (morestack.go:7) NOP 0x006e 00110 (morestack.go:5) CALL runtime.morestack_noctxt(SB) 0x0073 00115 (morestack.go:5) JMP 0
介绍一下stackguard0,在上一章有提过,它是g结构体的一个元素,stackguard0 是对比 Go 栈增长的 prologue 的栈指针 ,如果 sp 寄存器比 stackguard0 小(由于栈往低地址方向增长),会触发栈拷贝和调度 ,通常情况下:stackguard0 = stack.lo + StackGuard,但被抢占时会变为 StackPreempt(此处的逻辑就是我sysmon发现你占用很长时间了,我就把你的stackguard0调到很大,你就肯定会小于它,然后触发调度)
-
进入函数之后,首先会检查当前函数的
SP
寄存器是否已经达到g.stackguard0
,如果是的话,则需要先调用runtime.morestack_noctxt
方法扩张当前函数栈(现在的实现是重新分配一个更大的函数栈,然后把旧的函数栈内容拷贝过去),然后再根据栈帧大小设置SP
和BP
指针,而在函数返回前需要先恢复BP
和SP
指针。上面的BP和SP寄存器的相关设置是在morestack之后,也就是在执行morestack的时候,0(SP)为函数返回地址 -
总结
-
得出结论:这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口(并且是非叶子节点的函数)才能触发抢占,所以这是一种协作式的抢占式调度。理一理整体的逻辑,就是首先后台线程sysmon对于长时间占用的goroutine会进行可抢占标记,gp.preempt = true(把抢占标记位置true),然后在函数进行调用时,如果没有栈扩容需求,则不会执行morestack,如果有栈扩容需求,则会执行morestack,此时才会检查preempt,然后放开p,给其他G使用。
后续基于信号的抢占式调度器请看第三章