GMP调度
Go语言GMP介绍
Go语言相比Java,有更好的并发能力(GMP模型),同时其占用的服务器资源也较少,了解一下GMP的理念。从操作系统层面来看,线程是指内核级线程,是操作系统最小调度单元,创建、销毁、调度交由内核完成,可充分利用多核。协程(用户线程)与线程存在M:1的映射关系,从属于同一个内存级线程,无法并行,并且,一个协程阻塞会导致从属同一线程的所有协程无法执行。
Goroutine
经Golang优化后的协程,其有如下特点:1)与线程存在映射关系,为M:N;2)创建、销毁、调度在用户态完成,对内核透明,足够轻便;3)可利用多个线程,实现并行;4)通过调度器的斡旋,实现和线程间的动态绑定和灵活调度;5)栈空间大小可动态扩缩,因地制宜;
在/runtime/proc.go的代码注释中,有对GMP的解释,其核心数据结构在/runtime/runtime2.go:
- 其中
g是Golang中对协程的抽象,g有自己的运行栈,状态及执行的任务函数,g需要绑定到p上才能执行,p就是g的cpu; m即machine,是golang中对线程的抽象,m不直接执行g,而是先和p绑定,由其代理执行;借由p的存在,m无需和g绑死,也无需记录g的状态信息,因此g在全生命周期中可以跨m执行。p也即processor,是golang中的调度器。对于g而言,p是其调度器,g只有被p调度,才得以执行;对m而言,p是其执行代理,为其提供必要信息的同时,隐藏了繁杂的调度细节;// Goroutine scheduler, Design doc at https://golang.org/s/go11sched. // The scheduler's job is to distribute ready-to-run goroutines over worker threads. // // The main concepts are: // G - goroutine. // M - worker thread, or machine. // P - processor, a resource that is required to execute Go code. // M must have an associated P to execute Go code, however it can be // blocked or in a syscall w/o an associated P.p的数量可以由GOMAXPROCS这个变量来指定,每个p有一个自己的本地队列,此外,所有的p还有一个全局队列,存放等待运行的g。优先将新创建的p放在本地队列中,如果满了会放在全局队列中。gmp模型其要点和调度规则如下:M是线程的抽象;G是goroutine;P是承上启下的调度器;M调度G前,需要和P绑定;- 全局有多个
M和多个P,但同时并行的G的最大数量等于P的数量; G的存放队列有三类:P的本地队列、全局队列和wait队列(图中未展示,为io阻塞就绪态goroutine队列);M调度G时,优先取P本地队列,其次取全局队列,最后取wait队列;这样的好处是,取本地队列时,可以接近于无锁化,减少全局锁竞争;- 为防止不同P的闲忙差异过大,设立
work-stealing机制,本地队列为空的P可以尝试从其他P本地队列偷取一半的G补充到自身队列;
调度器设计策略
- 复用线程,避免频繁的创建、销毁线程,而是对线程的复用。
work-stealing机制,当本线程没有可运行的G时,尝试从其它线程绑定的P中偷取G,而不是销毁线程。hand off机制,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其它空闲的线程执行。 - 利用并行,
GOMAXPROCS设置p的数量,最多有GOMAXPROCS个线程分布在多个CPU上同时运行。 - 抢占,在
coroutine中要等待一个协程主动让出CPU才执行下一个协程(传统方式)。在Go中,一个goroutine最多占用cpu 10ms,防止其他goroutine被饿死。 - 全局
G队列,当M执行work stealing从其它p偷不到g时,它可以从全局G队列中获取G。
go func()执行经历了什么?
- 我们通过一个
go func()来创建一个goroutine; - 有两个存储
G的队列,一个是局部调用P的本地队列,一个是全局G队列。新创建的G会先保存在P的本地队列中,如果P的本地队列已经满了,就会保存在全局的队列中。 G只能运行在M中,一个M必须持有一个P,M与P的关系是1:1的关系。M会从P的本地队列弹出一个可执行状态的G来执行,如果P本地队列为空,就会想从其他的MP组合偷取一个可执行的G来执行。- 一个
M调度G执行的过程是一个循环机制; - 当
M执行某一个G时候如果发生了syscall或其它阻塞行为,M会阻塞,如果当前有一些G在执行,runtime就会把这个线程M从P中摘除(detach),然后再创建一个新的操作系统的线程(如果有空闲的线程可用就复用空闲线程)来服务于这个P。 - 当
M系统调用结束时,这个G会尝试获取一个空闲的P执行,并放入到这个P的本地队列。如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入全局队列中。
调度器的声明周期:
M0:M0是启动程序后编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,m0负责执行初始化操作和启动第一个G,在之后M0就和其它的M一样了。G0:G0每次启动一个M都会创建一个goroutine,Go仅用于负责调度G,Go不指向任何可执行的函数,每个M都会有一个自己的G0。在调度或者系统调用时会使用Go的栈空间,全局变量G0是M0的G0。
gmp调度场景过程全分析
- 场景一:
P拥有G1,M1获取P后开始运行G1,G1使用go func()创建G2,为了局部性G2优先加入到P1的本地队列。
- 场景二:
G1运行完成后(函数:goexit),M上运行的goroutine切换为G0,G0负责调度时协程的切换(函数:schedule)。从P的本地队列取G2,从G0切换到G2,并开始运行G2(函数:execute),实现了线程M1的复用。
- 场景三、四、五,描述,如果
G2连续创建6个G,则优先会将本地队列4个任务填满。当G2创建G7,则会将本地队列的一半的G打乱,和新创建的一块放在全局队列中。如果此时G2本地队列未满,则直接将G8加入本地队列。
- 场景六,唤醒正在休眠的
M,规定:在创建G时,运行的G会尝试唤醒其他空闲的P和M组合去执行。假定G2唤醒了M2,M2绑定了P2,并运行了G0。但P2本地队列没有G,M2此时为自旋线程(没有G但为运行状态的线程,不断寻找G)。
- 场景七,被唤醒的
M2从全局队列取批量G,M2尝试从全局队列(简称”GQ”)取一批G放到P2本地队列(函数:findrunnable()),M2从全局队列取的G数量符合下面的公式。
- 场景八,偷取
G的情况,全局队列中已经没有G,那么m就要执行work stealing(偷取):从其他有G的P那里偷取一半G过来,放到自己的P本地队列。P2从P1的本地队列尾部取一半的G,放到本地队列中执行。
-
场景九,最多有
GOMAXPROCS个自旋的线程(当前例子中的GOMAXPROCS=4),多余的没事做的线程会让他们休眠。 -
场景十,
G发生系统调用/阻塞,假定当前除了M3和M4为自旋线程,还有M5和M6为空闲的线程(没有得到P的绑定,注意我们这里最多只能够存在4个P,所以P的数量应该永远是M>=P),G8创建了G9,G8进行了阻塞的系统调用,M2和P2立即解绑,P2会执行如下判断:- 如果
P2本地队列有G,全局队列有G或有空闲的M,P2都会立马唤醒1个M和它绑定; - 否则
P2则会加入到空闲P列表,等待M来获取可用的p,本场景中,P2本地队列有G9,还可以和其它空闲的线程M5绑定。
- 如果
- 场景十一,
G发生系统调用/非阻塞,描述,M2和P2会解绑,但M2会记住P2,然后G8和M2进入系统调用状态。当G8和M2退出系统调用时,会尝试获取P2,如果无法获取,则获取空闲的P,如果依然没有,G8会被记为可运行状态,并加入到全局队列。M2因为没有P的绑定而变成休眠状态(长时间休眠等待GC回收销毁)。
GMP调度,源码分析
g0是一种特殊的调度协程,不执行用户函数,负责执行g之间的切换调度,与m的关系为1:1。goroutine的类型可分为两类:
- 1)负责调度普通
g的g0,与m的关系为一对一; - 2)负责执行用户函数的普通
g,被调度执行的g永远在g和g0的状态间切换;
当g0找到可执行g时,会调用gogo方法,调度g执行用户定义的任务。当g需要主动让渡时,会触发mcall方法,将执行权限重新交给g0;
广义”调度”可分为几种类型:
- 主动调度:一种用户主动执行让渡过程,主要方式是在代码中执行
runtime.Gosched方法(runtime/proc.go),此时当前g会当让出执行权,主动进行队列等待下次被调度执行。 - 被动调度:因不满足某执行条件,
g可能陷入阻塞态无法被调度,直到关注的条件达成后,g才从阻塞中被唤醒(对应runtime/proc.go#gopark方法,恢复则是goready方法)。 - 正常调度:
g中的执行任务已完成,g0会将当前g置为死亡状态,发起新一轮调度; - 抢占调度:倘若
g执行系统调用超过指定的时长,且全局p资源比较短缺,此时将p和g接绑,用解绑的p用于其他g的调度;
值得一提的是,前3种调度方式都由m下的g0完成。而抢占调用则是由一个全局监控协程monitor g来监控,倘若发现满足抢占调度的条件,则会从第三方的角度出手干预,主动发起该动作。从宏观上:
- 以
g0->g->g0的一轮循环为例进行串联; g0执行schedule()函数,寻找到用于执行的g;g0执行execute()方法,更新当前g、p的状态信息,并调用gogo()方法,将执行权交给g;g因主动让渡(gosche_m())、被动调度(park_m())、正常结束(goexit0())等原因,调用m_call函数,执行权重新回到g0手中;g0执行schedule()函数,开启新一轮循环.
p每执行61次,会从全局队列中获取一个goroutine进行执行,同时会额外将全局队列中的一个goroutine放到本地队列中。若本地队列已满,则会返回来将本地队列中一半的g放回全局队列中,帮助当前p缓解执行压力;