2

Golang GMP调度模型

 2 years ago
source link: https://fenghaojiang.github.io/post/golang-gmp-diao-du-mo-xing/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

GMP调度

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

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

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

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

首先要明确的是每个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),多余的没事做线程会让他们休眠


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK