Go语言协程调度器GPM


开坑,准备做一个深入理解Go语言系列,适合系统学过C/C++,学过Go语言基础用法,想深入理解Go语言的读者,至于为什么最近在用Go,主要是C++太难用了,缺少各种常用库,还没有好的模块管理,跨平台也麻烦,而Go应该算是解决了这些问题、而且性能也不弱的一门语言。本系列主要参考书籍:《深入理解Go语言》(刘丹冰 著)。对于Go语言基础:Go语言编程快速入门(Golang)(完结),这个视频比较适合系统学过C/C++的同学快速了解Go语言的用法,最好还要对协程有一定了解,不然看起来可能比较吃力。

Go语言的一大优势就是对于协程的原生支持,若想要了解这其中的原理,就需要去了解Go语言协程调度器GPM。

Go语言调度器的由来

我们知道,早期的操作系统是单进程的,这样的弊端很明显,有一个进程阻塞,后面的进程都无法执行。

后来出现了多进程的操作系统,通过调度程序调度执行各个进程,当一个进程阻塞时(请求读磁盘、网络IO等)会转换为阻塞状态,调度程序按既定规则选一个就绪进程来执行,当阻塞的进程完成相关任务后会转为就绪态。

但是进程的切换开销比较大,进程所相关的内存数据都要切换。后来就出现了多线程,同一个进程的多个线程都在该进程的内存空间内,多线程的切换也需要调度器,线程切换时开销就小了很多,但是多线程的锁、资源竞争、同步问题让多线程设计变得复杂。(线程是CPU调度的基本单位,因此会有这些问题)

而协程就是一种轻量级线程,创建、切换的开销都更小,但还是需要调度器的。而Go语言在语言层面上支持协程,就一定需要一个调度器。

Go语言目前使用的调度器是2012年设计的。而最初的调度器使用了4年就被废弃了,早期的调度器基于M:N实现(M个协程绑定N个线程),使用了一个全局的Go协程队列,每个线程需要调度执行协程时,都需要加锁访问全局协程队列,而在某些情况下,线程$T$执行协程$G$时会创建一个新协程$G’$,为了继续执行协程$G$,$T$会把$G’$放到全局队列,这个新协程很可能被调度到别的线程上执行,这就导致程序的局部性较差($G’$肯定是在线程$T$上运行局部性会好一些)。同时,CPU在线程之间频繁切换导致了系统开销的增加。

GPM模型设计思想

2012年,Go语言设计了全新的调度器,在这个新调度器中,除了线程(用符号M表示)、协程(用符号G表示)外,又引入了处理器(用符号P表示),因此称为GPM模型。

GPM模型

GPM模型中主要包括以下内容:

  1. 全局队列:存放等待运行的G,全局队列可以被任意的P获取,因此访问时还是需要加锁的。
  2. P的本地队列:每个P关联了一个队列,存放协程,也是等待运行的G,不过存放的数有限,不超过256个,新建G’时,会优先将其放到P的本地队列,增加了局部性,如果本地队列满了,就会将其中的一半移动到全局队列。
  3. P列表:所有的P都在程序启动时创建,保存在数组中,最多有GOMAXPROCS个(可通过runtime模块的GOMAXPROCS()来设置),如果设为0,则代表让Go去根据CPU核心数来设置并发运行的线程数。
  4. M:M代表着操作系统中的线程,线程要想运行任务就需要获取一个P,然后从P的本地队列中获取任务来运行,P队列为空时,M会尝试从全局队列获取一批G放到P的队列,或者从其他P的队列中获取一半。M执行完一个G,就会再尝试获取一个G,这样重复运行下去。

关于M的数量,Go程序启动时会自动设置,默认为10000个,但内核很难支持这么多线程,因此可以忽略。runtime/deBug中的SetMaxThreads()函数可设置M的最大数量,当一个M阻塞的时候,P会选择另一个空闲的M来运行,若无空闲的M,则会创建一个新的M(如果可以的话)。

调度器的设计策略

策略一:复用线程

当本线程无可执行的G时,就会尝试从别的P队列中“偷取”G而不是销毁线程,注意这个“偷取”动作的发起者应该是P,毕竟M只有绑定P时才能运行任务。

当本线程执行了一个阻塞的任务时,会自动释放P,P会去寻找一个空闲的M或者新建一个M来绑定,这样P就可以继续工作而无需等待阻塞的任务了。

策略二:利用并行

通过设置P的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行,也可以通过GOMAXPROCS限制并发程度,如GOMAXPROCS=核数/2表示最多利用一半的CPU核进行并行。

策略三:抢占

一个Goroutine最多运行10ms,就可能被别的协程抢占CPU。

策略四:全局G队列

新的调度器中仍有全局G队列,当一个P的本地队列为空时,优先从全局队列获取,若没有再考虑“偷取”。

go func()调度流程

  1. 通过go func()创建一个协程。
  2. 若该P的本地队列未满,则放到本地队列,否则放到全局队列里。
  3. G只能在M中运行,一个M必须持有一个P才能运行任务,若P为空,会优先从全局队列获取G,若获取不到则从别的P那里偷取。
  4. 一个M会循环调度G。
  5. 当M执行一个G发生系统调用或其他阻塞操作时,M会阻塞,若此P中还有其他G要运行,runtime会把这个P绑定到另一个M上(空闲的或新建的)。
  6. M系统调用结束后,这个G会尝试加入一个P的本地队列中,都加入不了则会加入全局队列中。若获取不到P,则M会变为休眠状态,加入空闲线程中。

调度器的生命周期

GPM模型中还有两个特殊的角色:M0和G0。

对于M0:

  1. 启动程序后的编号为0的主线程就是M0。
  2. 这个M0对应的实例在全局变量runtime.m0中,不需要在heap堆上分配。
  3. 负责初始化操作和启动第一个G。
  4. 启动第一个G后,M0就和别的M一样了。

对于G0:

  1. 每次启动一个M,创建的第一个Goroutine就是G0。
  2. G0仅用于负责调度G。
  3. G0不指向任何可执行的函数。
  4. 每个M都有一个自己的G0。
  5. 在调度时,M先切换到G0再通过G0调度。
  6. M0的G0会放在全局空间。
package main
import "fmt"
func main() {
    fmt.Println("Hello world!")
}

对于如上这样一个简单的程序,我们分析一下他的运行过程:

  1. runtime创建最初的线程M0和协程G0,并把二者关联。
  2. 调度器初始化:初始化M0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  3. 示例代码中的main()函数是main.main()函数,runtime中有一个runtime.main()函数,编译之后,runtime中的主函数会调用main.main()函数,程序启动时会为runtime.main()创建一个协程,称为Main Goroutine,然后把Main Goroutine加入P的本地队列中。
  4. 启动M0,M0从绑定的P中获取G,即Main Goroutine。
  5. G拥有栈,M0根据G中栈信息和调度信息设置运行环境。
  6. M0运行G。
  7. G退出,M0再次获取可运行的G,重复下去,直到main.main()函数退出,runtime.main()执行Defer和Panic处理,或调用runtime.exit()退出程序。

调度器的生命周期几乎占满了Go程序的一生,runtime.main()执行之前都是在为调度器做准备工作,runtime.main的G运行才是调度器的真正开始,直到runtime.main()结束而结束。

可视化GPM编程

Go语言提供了两种查看程序GPM数据的方式。

go tool trace

我们来写一个简单的打印”Hello world”的程序,并利用runtime/trace包下的工具将GPM信息输出到trace.out文件中

package main

import (
	"fmt"
	"os"
	"runtime/trace"
)

func main() {
	f, err := os.Create("trace.out")
	if err != nil {
		panic(err)
	}
	defer f.Close()
	err = trace.Start(f) // 将信息输出到f文件中
	if err != nil {
		panic(err)
	}
	defer trace.Stop()
	fmt.Println("Hello world")
}

然后我们执行go run main.go,会发现只输出了一行”Hello world”,而trace.out文件里确实有内容,但我们看不明白。我们利用go的工具来查看该文件,执行go tool trace trace.out,得到如下输出:

image-20230731095917494

然后打开此网址,单击view trace,我们便能看到可视化的调度流程了:

image-20230731101536296

看这些英文名很容易就知道他们分别是什么了。

点击Goroutines可视化的数据条,我们就能看到详细信息:

image-20230731100418184

其中,value就是协程的编号,第一个协程就是G0,是每个M必须有的一个初始化的G,另一个就是G1,在选中的这段时间处于可运行和运行的状态。

我们再点击Threads可视化的数据条,我们就能看到详细信息:

image-20230731100636880

这两个M,其中一个是特殊的M0,另一个是处于运行状态的M1,M1是用于承载G1的线程。

我们再来查看P的信息,这里有两个处理器P0、P1。很明显我们可以看出,G1中调用了main.main函数,创建了G6协程,G1运行在P0上,G6运行在P1上,一个P只有绑定一个M才能运行,从图中我们可以看出,为了运行P0上的G6,新建了一个M:

image-20230731102000613

至于为什么G6这么靠后,与M创建的时间对不上,是因为G6画的是从运行开始到运行结束的时间,而创建G6后没有立即运行G6。

DeBug trace

还是用上面的程序,通过go build main.go生成可执行程序main,接下来,我们先设置环境变量export GODEBUG='schedtrace=1000'(注意export设置的环境变量只在当前终端有效),表示每1000ms输出一次信息,然后运行得到输出:

image-20230731104445751

  • SCHED:调试信息输出标志字符串,表示该行是调度器的输出。
  • 0ms:表示从程序启动到输出该行日志的时间
  • gomaxprocs:P的最大数量
  • idleprocs:处于idle状态P的数量,即空闲P的数量
  • threads:os threads/M的数量,包含scheduler使用的M和runtime自用的类似sysmon(系统监视器)这样的thread
  • spinningthreads:处于自旋状态的M的数量(自旋是一种计算机程序的等待技术,用于避免在等待某个事件完成时浪费CPU资源。在等待某个事件完成的同时,程序会在一个循环中反复检查事件是否已经完成,而不是直接进入睡眠状态或者阻塞状态。阻塞的线程需要被唤醒,这个恢复速度比自旋状态的恢复慢。因此,线程竞争激烈的时候用阻塞锁,不激烈的时候用自旋锁)
  • idlethreads:即空闲M数量
  • runqueue:全局队列中G的个数
  • [2 0 0 0]:4个P的本地队列的G的个数

Go调度器调度场景解析

场景一:G1创建G2

image-20230807211657296

G1创建G2后,为了局部性,优先将G2加入该P的本地队列,因为G1和G2所保存的内存和堆栈信息最为相似,切换成本小。

场景二:G1执行完毕

image-20230807212032406

场景三:G2开辟过多的G

image-20230807212308429

P1的本地队列装满了之后,再创建G7的情况请看场景四

场景四:G2本地满再创建G7

image-20230807212540204

G2在创建G7的时候,发现P1的本地队列已满,需要把P1中本地队列中前一半的G,还有新创建的G转移到全局队列,这些G被转移到全局队列时,会被打乱顺序。

场景五:G2本地未满创建G8

image-20230807212710924

G2创建G8时,P1的本地队列未满,所以G8会被加入到P1的本地队列。

场景六:唤醒正在休眠的M

image-20230807212831805

在创建一个G时,运行的G会尝试唤醒其他空闲的P和M组合去执行。假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为自旋线程(相当于一个while()循环,一直在等待G的到来,会引发场景七、八)。

场景七:被唤醒的M2从全局队列取批量G

M2尝试从全局队列(简称“GQ”)取一批G放到P2的本地队列(函数:findrunnable())。M2从全局队列取的G数量符合下面的公式:$n = min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )$

image-20230807213142571

场景八:M2从M1中偷取G

image-20230807213524965

全局队列已经没有G,那M2就要执行work stealing(偷取):从其他有G的P那里偷取一半G过来,放到自己的P本地队列。P2从P1的本地队列尾部取一半的G,本例中一半则只有1个G8,放到P2的本地队列并执行。

场景九:自旋线程的最大限制

当P本地队列为空时,为什么我们不是销毁线程而是让其进入自旋状态?我们希望当有新G创建时,立刻能有M运行它,如果销毁再新建就增加了开销。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程(当前例子中的GOMAXPROCS=4,所以一共4个P),多余的没事做的线程会休眠。

场景十:G发生系统调用/阻塞

假定当前除了M3和M4为自旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这里最多就只能够存在4个P,所以P和M的数量基本满足$M\ge P$, 大部分时间都是M在抢占需要运行的P),G8创建了G9,G8进行了阻塞的系统调用,M2和P2立即解绑,P2会执行以下判断:如果P2本地队列有G、全局队列有G或有空闲的M,P2都会立马唤醒1个M和它绑定,否则P2则会加入到空闲P列表,等待M来获取。本场景中,P2本地队列有G9,可以和其他空闲的线程M5绑定。

image-20230807214525383

场景十一:G发生系统调用/非阻塞

此时M2和P2会解绑,但M2会记住P2,然后G8和M2进入系统调用状态。当G8和M2退出系统调用时,会尝试获取P2,如果无法获取,则获取空闲的P,如果依然没有,G8会被记为可运行状态,并加入到全局队列,M2因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)。

image-20230807214721920

总结

Go调度器很轻量也很简单,足以撑起goroutine的调度工作,并且让Go具有了原生(强大)并发的能力。Go调度本质是把大量的Goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。


文章作者: verynewabie
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 verynewabie !
评论
  目录