Golang的协程调度器原理及GMP设计思想
# GMP设计思想
# 调度器的由来
# 单进程
一个进程只能处理一个任务,故不需要调度器
# 多进程/线程
进程阻塞时,可以切换其他进程来执行其他任务
- 优点
- 可以并发执行任务
- 缺点
- 高内存占用
- 调度的高消耗CPU
- 锁&竞争冲突
# 协程
- 解决多进程&线程的缺点而被设计
- 线程和协程关系
- N:1
- 缺点:无法使用硬件多核能力、协程阻塞则无法并行
- 1:1
- 缺点:协程创建、删除和切换的代价都依赖CPU线程完成,代价昂贵
- M:N
- 克服了以上缺点
- N:1
- 线程&协程区别
- 占用空间
- 调度:抢占式vs协作式(协程让出CPU后才执行下一个协程)
# Goroutine调度器的GMP模型设计思想
# Go语言的协程goroutine
- goroutine&channel
- 特点
- 占用内存更小
- 调度更灵活
# 旧版goroutine调度器
Go目前使用的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以使用4年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?
- 缺点
- 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
- M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M'。
- 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
# GMP模型
- 模型相关
- 全局队列(Global Queue):存放等待运行的G。
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。
- M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。
- P和M的个数问题
- P的数量
- 由启动时环境变量
$GOMAXPROCS
或者是由runtime
的方法GOMAXPROCS()
决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine
在同时运行。 - M的数量
- go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug
中的SetMaxThreads
函数,设置M的最大数量- 一个M阻塞了,会创建新的M。
- P和M的关系
- M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以,即使P的默认数量是1,也有可能会创建很多个M出来。
- P和M何时被创建
- P:在确定P的最大数量n后,运行时系统会根据这个数量创建n个P
- M:没有足够的M来关联P并运行其中可运行的G。比如所有的M此时都阻塞住了,而P中还有很多就绪任务,就会去寻找空闲的M,而没有空闲的,就会去创建新的M。
- 由启动时环境变量
- P的数量
# 调度器的设计策略
- 复用线程
- work stealing机制:当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
- 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。
# go func()
调度流程
1、我们通过 go func()来创建一个goroutine; 2、有两个存储G的队列,一个是局部调度器P的本地队列、一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了就会保存在全局的队列中; 3、G只能运行在M中,一个M必须持有一个P,M与P是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P的本地队列为空,就会想其他的MP组合偷取一个可执行的G来执行; 4、一个M调度G执行的过程是一个循环机制; 5、当M执行某一个G时候如果发生了syscall或则其余阻塞操作,M会阻塞,如果当前有一些G在执行,runtime会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P; 6、当M系统调用结束时候,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态, 加入到空闲线程中,然后这个G会被放入全局队列中。
# 调度器的生命周期
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
1
2
3
4
5
6
7
2
3
4
5
6
7
- M0&G0
- M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。
- G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。在调度或系统调用时会使用G0的栈空间, 全局变量的G0是M0的G0。
- 生命周期流程
- runtime创建最初的线程m0和goroutine g0,并把2者关联。
- 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
- 示例代码中的main函数是main.main,runtime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
- 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
- G拥有栈,M根据G中的栈信息和调度信息设置运行环境
- M运行G
- G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。
# 可视化GMP编程
go tool trace
trace记录了运行时的信息,能提供可视化的Web页面。
package main
import (
"os"
"fmt"
"runtime/trace"
)
func main() {
//创建trace文件
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()
//启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()
//main
fmt.Println("Hello World")
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Debug trace
编译二进制包后使用以下方式执行
$ GODEBUG=schedtrace=1000 ./trace2
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
- 执行方式:
GODEBUG=schedtrace=1000 ./trace2
- 说明
- SCHED:调试信息输出标志字符串,代表本行是goroutine调度器的输出;
- 0ms:即从程序启动到输出这行日志的时间;
- gomaxprocs: P的数量,本例有2个P, 因为默认的P的属性是和cpu核心数量默认一致,当然也可以通过GOMAXPROCS来设置;
- idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
- threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
- spinningthreads: 处于自旋状态的os thread数量;
- idlethread: 处于idle状态的os thread的数量;
- runqueue=0: Scheduler全局队列中G的数量;
- [0 0]: 分别为2个P的local queue中的G的数量。
# Go调度器调度场景过程全解析
具体参考下列文章,重点如下:
- 新建G会优先放到当前的P本地队列
- M执行完G时,会先切换G0负责协程的调度切换,从而执行下一个G
- 当开辟过多G,P本地队列装不下的时候,则会执行负载均衡(把P本地队列前一半的G和新建的G打乱顺序转移到全局队列【新建的G不一定会转移,需视是否需立即执行决定】)
- 创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行
- 空闲M从全局队列GQ获取G的数量符合公式:
n = min(len(GQ) / GOMAXPROCS + 1, cap(LQ) / 2 )
- 如全局队列已经没有G,则m需要自行
work stealing
: 从其他P的本地队列中偷取一半的G,放到自己的P队列 - 空闲线程如没有G执行,则会让其处于自旋状态【由于创建销毁本身也需消耗资源,故保存自旋当有新的G时可立马执行】,最多有GOMAXPROCS个自旋线程
- 当G进行了系统调用时,则M和P会立即解绑,此时如P本地队列存在G、全局队列有G或有空闲的M,P都会立马唤醒一个M和它绑定,否则P会加入空闲P列表,等待M来获取【即当前M用于执行阻塞系统调用的G,此时M无P绑定】
- 假设G进行的系统调用并非阻塞,则M执行完毕系统后,G会重新投入G全局队列中并标记为可运行状态,同时M会重新尝试获取之前绑定的P,如该P已被其他M绑定,则从空闲队列中获取P,如仍获取失败则M因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)。
# 总结
Go调度本质是把大量的goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。
# 参考
编辑 (opens new window)
上次更新: 2023/02/17, 16:53:03