G: goroutine
M: thread 线程
P: Processor 包含运行goroutine的资源
GM
在GM模型中,M想要执行、返回G都必须访问全局G队列。这会导致以下缺点
- 创建、销毁、调度G都需要每个M获取锁,会导致激烈的锁竞争
- M转移G会造成延迟和额外的系统负担。比如当G1包含创建新goroutine G2时,M1为了继续执行G1,就将G2交给M2执行,造成了很差的局部性。因为G1、G2是相关的
- 系统调用导致频繁的线程堵塞和取消堵塞操作增加了系统开销
GMP
为了应对上述问题,又引入了P(Processor),包含运行goroutine的资源以及可运行的G队列。如果线程想运行goroutine,必须先获取P
- 全局队列:存放等待运行的G
- P的本地队列:存放的也是等待运行的 G。存的数量有限,不超过 256 个。P中GxG_xGx新建$ G_y时,时,时, G_y$优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中。最多有GOMAXPROCS个
- M:线程M需要通过P执行任务。首先尝试从P的本地队列获取G,若本地P空,则从全局队列获取一批G到本地P队列,在全局队列空的情况下,还可以从其他P队列偷一半到本地P
P、M相关问题
数量
p数量:由GOMAXPROCS决定
m数量:go设置的默认最大数量为10000,runtime/debug中SetMaxThreads也可以设置m的最大数量
m、p数量没有绝对关系,一个m堵塞p就会去创建或切换另一个m
创建时间
p:运行时会根据最大数量创建
m:不够就创建
调度流程
- 创建goroutine G1
- 将G1保存到本地P队列。若本地P队列满,则放(前一半G和新创建)至全局G队列
- M从本地P队列弹出可执行状态的G执行。若P队列为空,则会从全局G队列取,若全局队列空,就会从其他MP组合偷取一半可执行的G
- 当M执行某个G时,突然发生syscall或者堵塞操作,M就会发生堵塞,将M从P中detach,然后复用或者创建新的线程来服务该P
- 如果M没有goroutine可执行,就会一直处于自旋找寻goroutine(最多GOMAXPROCS自旋线程,多余休眠);P没有M绑定,则加入空闲P列表,等待M唤醒(M会优先绑定之前绑定的P)。
M0、G0
M0是启动程序后编号为0的主线程,这个M对应实例在全局变量runtime.m0,不住堆分配。M0负责初始化操作和启动第一个G,之后M0便泯然众M矣
G0是每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。
堵塞
用户态堵塞
当goroutine因为goroutine操作或者network IO(golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G)堵塞时,对应的G会放置到某个wait队列(如channel的waitq),该G状态由_Grunning变为_Gwaiting;而M会跳过该G尝试执行下一个G
内核态堵塞
堵塞在系统调用时,G处于_Gsyscall状态,M也处在block on syscall状态,此时M可悲抢占调度;执行该G的M会与P解绑,P会尝试与其他idle M绑定
抢占
为了不使goruntine长期霸占运行资源,需要有抢占。
- signals:通过外界信号中断原来的线程执行
- cooperative checks:通过线程间歇性轮询check自己的运行时间来主动暂停
对于go来说cooperative checks更为合理,平台无关、非抢占式,并且代码编译也是golang编译器控制的
死循环就无法check啦
系统调用堵塞时,让出P执行权,交接给idle M;执行结束后,进行idle状态等待唤醒
反思
- 线程池:既能拥有多线程提供的强大的并发能力,同时避免了线程过多带来的过大开销
- 资源池:线程池就是一种资源池。资源池可以对一定规模约束的资源进行池化管理
- 计算储存分离:相比于GM模型,新来的P就是将G队列和相关储存资源移到了P,那么当M堵塞时,与M绑定的P便可以去找别的M利用P中已有的储存资源以及G
Ref
- https://learnku.com/articles/41728
- https://go.cyub.vip/gmp/gmp-model.html
- https://mp.weixin.qq.com/s/N1ColaDQtm7-LM5IqrJWqw