调度器 GMP 模型
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。
全局队列(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,不断重复下去。
调度器的设计策略
golang调度器的设计策略思想主要有以下几点:
复用线程
利用并行
抢占
全局G队列
2.2.1 复用线程
golang在复用线程上主要体现在work stealing机制
和hand off机制
(偷别人的去执行,和自己扔掉执行)
首先我们看work stealing
,我们在学习java的时候学过fork/join,其中也是通过工作窃取方式来提升效率,充分利用线程进行并行计算,并减少了线程间的竞争
干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列
,被窃取任务
线程永远从双端队列的头部
拿任务执行,而窃取任务
的线程永远从双端队列的尾部
拿任务执行
hand off机制
当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行,此时M1
如果长时间阻塞,可能会执行睡眠或销毁
2.2.2 利用并行
我们可以使用GOMAXPROCS
设置P的数量,这样的话最多有GOMAXPROCS
个线程分布在多个CPU上同时运行。GOMAXPROCS
也限制了并发的程度,比如GOMAXPROCS = 核数/2
,则最多利用了一半的CPU核进行并行
2.2.3 抢占策略
1对1模型的调度器,需要等待一个
co-routine
主动释放后才能轮到下一个进行使用golang中,如果一个
goroutine
使用10ms还没执行完,CPU资源就会被其他goroutine所抢占
2.2.4 全局G队列
全局G队列其实是复用线程的补充,当工作窃取时,优先从全局队列去取,取不到才从别的p本地队列取(1.17版本)
在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G
开启一个 Goroutine 的调度流程
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 会被放入全局队列中。
2.4 调度器的生命周期
在了解调度器生命周期之前,我们需要了解两个新的角色
M0
和G0
M0(跟进程数量绑定,一比一):
启动程序后
编号为0
的主线程在全局变量
runtime.m0
中,不需要在heap
上分配负责执行初始化操作和
启动第一个G
启动第一个G之后,
M0就和其他的M一样了
G0(每个M都会有一个G0):
每次
启动一个M
,都会第一个创建的gourtine
,就是G0
G0仅用于
负责调度G
G0不指向任何
可执行的函数
每个M都会有一个自己的G0
在调度或系统调用时会使用M切换到G0,再通过G0进行调度
M0和G0都是放在全局空间的
我们来分析一段代码:
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
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
退出程序。
调度器的生命周期几乎占满了一个Go程序的一生,
runtime.main
的goroutine执行之前都是为调度器做准备工作,runtime.main
的goroutine运行,才是调度器的真正开始,直到runtime.main
结束而结束
Go 协程调度器各场景调度过程解析
场景一:G1创建G2
P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
场景二:G1执行完毕
G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。
场景三:G2创建过多的 Goroutine
假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了
场景四:G2本地队列满再创建 Goroutine
G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)
(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)
3.4 唤醒正在休眠的M
规定:在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行
假定G2唤醒了M2,M2绑定了P2,并运行G0,但P2本地队列没有G,M2此时为
自旋线程
(没有G但为运行状态的线程,不断寻找G)
先从全局队列中获取,没有再从其他线程中偷取(先全后偷)