参考视频:Golang深入理解GPM模型
调度器由来
早期通过CPU调度器调度进程/线程的切换,将一个时间切为若干时间片,每个时间片跑不同的进程,实现并发问题,解决阻塞问题
但是CPU切换进程/线程的成本开销较大,需要保存当前进程数据,再从内存中把另一个进程的数据载入CPU等操作,进程/线程的数量越多,切换成本越大,越浪费
而且多线程还会出现资源竞争之类的问题,设计越来越复杂
改进
将一个线程分为内核空间与用户空间,将用户部分称为协程(co-routine),CPU只会看到内核空间,因此还认为是同一个线程,而用户空间可以通过协程调度器来让线程绑定多个协程,轮询调度(1:n关系),这样就避免了CPU切换线程的高消耗操作
但若有一个协程阻塞,因为轮询关系,会影响其他协程运行
继续改进可以让多个内核线程通过调度器来绑定多个协程(n:m)关系,可以分给多个CPU核来控制多个线程,这样只要协程调度器开发的好,CPU利用率高,效率就会高
Golang对协程的处理
Golang有自己的协程Goroutine
早期Goroutine存在一个全局协程队列中,当一个线程M想要执行Gorotine,就会拿队头的G执行,剩余的往队头移动
注意,为了并发安全,创建、销毁和调度队列时会需要每个M获取锁,形成激烈的锁竞争,而且M转移G也会造成延迟或额外的开销
GMP模型
简介
- G:Goroutine协程
- M:thread线程
- P:processor处理器
P中包含了每个goroutine的资源,所以M要先获取P,才能运行G
- 全局队列中存放等待运行的G
- P的本地队列存放当前P等待运行的G,有数量限制,不超过256个G
- 优先将新的G放入P的本地队列,若本地队列满则放在全局队列
- 一个程序中有
GOMAXPROCS个P,称为P列表,程序启动时创建,可以在环境变量中设置 - 当前操作系统分配给当前go程序的内核线程,组成了M列表,M的数量是动态的,由Golang限定了最大量10000个,可以通过runtime/debug包中的
SetMaxThreads函数设置
调度器的设计策略
复用线程
- work stealing机制
若一个P的本地队列为空,M会向其他本地队列偷取一半G放入自己的本地队列
- hand off机制
当当前线程因为正在运行的G因为系统调用而阻塞时,CPU会创建或唤醒一个线程,并接管和之前的M绑定的P,当前G继续在原M阻塞,原M此时不工作,为睡眠状态,若G阻塞完毕还需要执行,则会加入其他本地P队列,若不执行,M会直接销毁
利用并行
使用GOMAXPROCS限定P的个数,可限定为CPU核数/2
有几个P表示最多有几个G同时运行
抢占
一个G最多占用CPU10ms,若超时新的G可以抢占旧的G,保证了并发,防止其他G被饿死
全局G队列
全局队列是加锁的,因此访问全局队列效率要慢一些
在work stealing中,若M无法从其他本地队列偷取G,它会向全局队列获取G运行
Go指令的调度过程
执行go func()后,会尝试创建一个G,会优先尝试放入执行当前函数的线程的P的本地队列中,若本地已满,则放入全局队列
接下来M通过P尝试获取G,若成功则放入M运行:
- 若P本地队列为空,则从其他P队列偷取G
- 若其他P队列为空,则从全局队列获取G
获取G后,执行调度并运行定义的逻辑代码,当时间片超时会返回放入本地队列
若执行时阻塞,为了提高CPU利用率,从休眠M队列中获取一个M或创建一个M,接管当前正在阻塞G的P,原M和G捆绑,当G阻塞结束后,M会被销毁或加入休眠队列,G会进入某个P本地队列或全局队列
调度器的生命周期
特殊的调度器
- M0:启动程序后编号为0的主线程
- 存在于全局变量
runtime.m0中,不需要在堆上分配 - 负责初始化操作和启动第一个G
- 启动第一个G后,M0和其他M一样
- 存在于全局变量
- G0:每次启动一个M后,第一个创建的Goroutine
- G0仅用于负责调度G
- G0不指向任何可执行的函数
可视化GMP编程
网页可视化
写一个简单的trace编程代码
go build并运行后,生成一个trace文件,通过go tool trace 文件名开启trace文件,可以在浏览器上查看GMP的分配
GO DEBUG
使用GODEBUG=schedtrace=1000 ./可执行文件来跟踪trace,这里只需要编译后生成的二进制文件即可,数字代表每多少ms打印一次
SCHED:调试的信息- 显示的时间:从程序启动到输出经历的时间
gomaxprocs:P的数量,默认和CPU核心数一致idleprocs:处于空闲状态的P的数量threads:全部的线程数,包括M0和GODEBUG调试线程spinningthreads:处于自旋状态的线程数量idlethread:处于空闲状态的线程数量runqueue:全局G队列中G的数量[0,0]:每个P的local queue本地队列中,目前存在G的数量
使用场景
场景1:G1创建G3
G1因为某些需求需要创建一个G3,由于需要保证局部性,最好放在同一个M下执行,此时G3优先加入G1所在的本地队列
场景2:G1执行完毕
当G1执行完毕,调用goexit()时,M会调度G0,加载后通过G0优先把本地队列中的G调过来,这里G0负责协程的切换
场景3:G2开辟过多的G
假设P1的本地队列最多存4个G,此时存满了,但G2开辟了多于4个G,为了满足局部性,多余的G会尝试取当前P队列的二分之一,将队头的二分之一打乱,放入全局队列,同时将新创建的G放入全局队列,将后二分之一前移
之后若未满情况下创建新G,会放入本地队列
场景4:唤醒正在休眠的M
当创建一个G时,它会尝试唤醒休眠M队列中的M(若有)
唤醒会将M从队列拿出来,M会寻找一个P将其绑定,绑定后调用G0,若该M正在运行G0,且队列中没有G,称该线程为自旋线程,它会试图抢夺其他队列中的线程
场景5:被唤醒的M从全局队列中获取批量G
一个自旋线程会首先尝试从全局队列中获取G,获取的数量满足如下公式:
从全局队列取G到P的过程叫从全局队列到P本地队列的负载均衡
场景6:M2从M1中偷取G
当M把本地以及全局G都消耗完了,且另一个M还在处理本地队列,此时该M继续变为自旋线程,全局队列空的情况下,该M会从其他本地队列偷取
偷取时,会将另一个本地队列中的G分为二分之一,偷取后二分之一段的G
场景7:自旋线程的最大限制
自旋线程是有限制的:自旋线程+执行线程<=GOMAXPROCS
场景8:G发生系统调用或阻塞
若此时某个M上的G发生系统调用或阻塞,为了不耽误其他G,该G会和对应的M绑定,这个M上的P首先寻找是否有空闲M,若没有会进入空闲P队列,等待空闲M,若有则会唤醒一个新M,该P和新M绑定
注意:P不会和自旋线程绑定,因为自旋线程是抢占G的,它们拥有P
场景9:G发生系统调用/非阻塞
若此时上面说的G系统调用结束或非阻塞,M需要执行G,此时M会记录之前绑定的P,并试图获取原来的P,若抢占失败,会去看空闲P队列有无,若没有,M会放弃绑定P,将G放入全局P队列,M放入休眠线程队列