本文是《Go语言设计与实现》6.5调度器的学习笔记,是对该节内容的注解和扩展。

1. Golang 调度器原理 总结

Go语言的协程模型为G-M-P,其中G代表goroutine,M代表内核线程,P代表处理器。三者的关系如下图所示:

G-M-P模型
G-M-P模型

P相当于是线程核goroutine的中间层,负责goroutine在线程上的调度。可以看到,P上有一个goroutine的队列(黑色部分),还有一个g0线程(绿色部分)。黑色的队列部分便是处理器P的调度队列;g0线程在后面注解中再做阐述,其与之前讲到的systemstack有着很密切的关系。

在编译过程中,golang代码中的go关键字被转化成对runtime.newproc的调用,该调用会初始化一个goroutine对象,加入到本地或者全局goroutine队列中。

1.1 任务窃取调度器

基于任务窃取的调度器使用了G-M-P模型,其调度策略如下:

  • 如果当前运行正在等待垃圾回收,那么调用runtime.gcstopm函数
  • 否则从本地队列获取待执行的goroutine;如果本地队列中没有可以执行的goroutine,那么从全局的运行队列中获取待执行的goroutine,或者窃取其他处理器队列中的goroutine

  • 调用runtime.execute在线程M上运行goroutine

1.2 基于协作的抢占式调度器

上述介绍的任务窃取调度器不支持抢占式调度,程序只能依靠goroutine主动让出CPU资源(如调用runtime.gopark)才能触发调度。go 1.2之后引入了基于协作的抢占式调度。

golang启动的后台线程sysmon在运行过程中,会遍历所有的P,如果P的状态为_Prunning或者_Psyscall,且运行超过了规定的时间(10ms),那么就会调用runtime.preemptone启动调度。原理如下:

  • 设置抢占标志gp.preempt为true
  • gp.stackguard0设置为stackPreempt

其中stackPreempt是一个非常大的值。任何一个goroutine在内部进行函数调用的时候,会首先将栈顶指针核stackguard0域进行比较(这部分比较的代码由编译器在编译阶段插入),来判断是否发生了溢出。由于stackPreempt 是一个很大的值,所以一定会溢出,从而跳转到runtime.morestack_noctxt,经过一系列操作之后使当前goroutine让出CPU。

1.3 基于信号的抢占式调度器

基于协作的抢占式调度器,实现抢占功能依赖于goroutine对函数的调用,这样的策略对for{}这样的死循环无效,goroutine无法让出CPU。go 1.14之后实现了基于信号的抢占式调度。

runtime.preemptone实际上会检查是否能调用异步抢占(也就是基于信号的抢占),如果能,那么调用runtime.preemptM向P发送SIGURG信号。

在go runtime初始化的时候,注册了SIGRUG信号的处理函数,当操作系统收到_SIGURG信号之后,会将当前运行代码终端,执行相应的处理函数runtime.sighandler,经过一系列检查后,最终使当前goroutine让出CPU,gp被调度出去的操作。

2. 补充与注解

2.1 sysmon与抢占式调度

来看下面这个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"runtime"
"time"
)

func main() {
runtime.GOMAXPROCS(1)
go func() {
for {

}
}()
time.Sleep(time.Second)
fmt.Println("finished.")
}

在go 1.14之前,该程序会一直死循环,永远无法打印出"finished."。在go 1.14之后,引入了基于信号的抢占式调度,才解决了这个问题。

但是新的问题又来了:在只有一个CPU的情况下,goroutine执行死循环,不让出CPU;这个时候没有其他的CPU可以运行sysmon这个goroutine,那么这个goroutine就没有办法被抢占,对吗?

当然不是。sysmon是运行在一个单独的内核线程上的,而不是运行在goroutine之上,和其他goroutine一起被调度。因为sysmon运行在一个单独的内核线程之上,所以操作系统会通过时钟中断抢占执行死循环goroutine的内核线程,来执行sysmon所在的内核线程,这个时候sysmon就可以在用户态抢占执行死循环的goroutine了。

2.2 g0

在上面的插图中可以看到,每一个P或者M除了对应一个goroutine的队列之外,还对应着一个单独的goroutine,这个goroutine我们成为g0协程。g0 虽然也是g的结构,但和普通的g还是有差别的,最重要的差别就是栈的差别。g0 上的栈是系统分配的栈,在linux上栈大小默认固定8MB,不能扩展,也不能缩小。 而普通g一开始只有2KB大小,可扩展。在 g0 上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在g0上跑的。