Golang GMP调度模型

GMP调度

runtime调度器的三个重要组成部分:线程M、Goroutine G和处理器P:

  1. G-Goroutine协程,在运行时调度器中跟线程在操作系统差不多,但是用了更小的内存空间。
  2. M-操作系统的线程,由操作系统调度器调度管理。
  3. P-表示处理器,可以被看成在线程上的本地调度器。

G

Goroutine是Golang中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。
Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。
可以简单地把Goroutine状态分为3种:等待中、可运行、运行中。

M

Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数(多少个CPU就有多少个M),我们也可以在程序中使用 runtime.GOMAXPROCS 来改变最大的活跃线程数。

type m struct {
    g0 *g
    curg *g
    ...
}

g0为持有调度栈的Goroutine,curg是在当前线程上运行的用户Goroutine,这也是操作系统线程唯一关心的两个 Goroutine。
g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。在后面的小节中,我们会经常看到 g0 的身影。

P

调度器种的处理器P是线程M和Goroutine的中间层,它提供线程需要的上下文环境,负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时让出计算资源,提高线程的利用率。
因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上。
P拥有各种G对象队列、链表、cache和状态。
数据结构:

type p struct {
	m           muintptr
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	runnext guintptr
	...
}

反向存储的线程维护着线程与处理器之间的关系,而 runqheadrunqtailrunq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。

为什么不是GM?

Go目前使用的GMP是大约在2012年重新设计的,之前的话只有GM。M想要执行、放回必须访问全局的G队列,而M有多个,有互斥锁进行保护。
造成了以下的缺点:

  1. 创建、销毁、调度G都需要每个M获取锁,形成了激烈的锁竞争。
  2. M转移G会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
  3. 系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

GMP模型组成

  1. 全局队列:存放等待运行的G
  2. P的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  3. P列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
  4. M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。

P和M的个数

  1. P的数量:

    • 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
  2. M的数量:

    • go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000. 但是内核很难支持这么多线程数,所以这个限制可以忽略。
    • runtime/debug中的SetMaxThreads函数,设置M的最大数量。
    • 一个M阻塞了,会创建新的M。

M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能创建出很多个M出来。

调度器的设计策略

复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing 机制

​ 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。

2)hand off 机制

​ 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

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

抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

为什么要设置 GOMAXPROCS 个P ?

线程在运行却没有执行G,就浪费了CPU,而创建跟销毁CPU也会浪费时间,希望有新的goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延。如果过多的自选线程是浪费CPU,所以系统最多有GOMAXPROCS个自旋线程(如果GOMAXPROCS=4,所以一共 4 个 P),多余的没事做的线程会让他们休眠。

g0和m0

g0和m0在runtime中比较重要

m0

m0是进程启动的第一个进程,也称为主线程。这个M对应的实例在全局变量runtime.m0中,不需要再heap上分配,M0负责执行初始化操作和启动第一个G,再之后M0跟M没区别。m0是全局变量,而其他的m都是runtime自己创建的。一个go进程只有一个m0。

g0

首先要明确的是每个m都有一个g0,G0仅用于负责调度的G,不知想任何可执行的函数。每个线程有一个系统堆栈,g0 虽然也是g的结构,但和普通的g还是有差别的,最重要的差别就是栈的差别。g0 上的栈是系统分配的栈,在linux上栈大小默认固定8MB,不能扩展,也不能缩小。 而普通g一开始只有2KB大小,可扩展。在 g0 上也没有任何任务函数,也没有任何状态,并且它不能被调度程序抢占。因为调度就是在g0上跑的。

proc.go中的全局变量m0和g0

var (
	m0           m
	g0           g
	raceprocctx0 uintptr
)

在 runtime/proc.go 的文件中声明了两个全局变量,m0表示主线程,这里的g0表示和m0绑定的g0,也可以理解为m0线程的堆栈,这两个变量的赋值是汇编实现的。

到这里我们应该知道了g0和m0是什么了? m0代表主线程、g0代表了线程的堆栈。调度都是在系统堆栈上跑的,也就是一定要跑在 g0 上,所以 mstart1 函数才检查是不是在g0上, 因为接下来就要执行调度程了

什么是M的自旋状态

M的自旋状态是指没有 G 但为运行状态的线程,不断寻找 G。

为什么让M自旋?

自旋的本质是在运行,线程在运行却没有执行G,就浪费了CPU,那么为什么不销毁节省CPU,因为创建和销毁CPU也会浪费时间,我们希望当有新的goroutine创建时,立刻能有M运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程 (当前例子中的 GOMAXPROCS=4,所以一共 4 个 P),多余的没事做线程会让他们休眠

参考: