前言
Golang的调度也是有迭代历程的。在最初,调度模型采用的是GM模型,但是由于性能存在缺陷,在1.1之后的版本中,官方优化调整为GMP的模型,并且一直沿用至今。
GM调度模式
GM模式中:G指的是协程,是goroutine的缩写;M指的是内核级线程(os的thread),是machine的缩写,可以理解是对CPU一个核core的抽象。GM的调度模式图大体如下:
需要注意两个点:
全局队列:全局队列是存放G的一个队列,用来管理和调度G。
锁:全局队列的访问是上锁的,在新生成G放入全局队列或M来取出全局队列中Goroutine的时候,需要经过锁,以此来保证全局队列的有序性。
由此可见,GM模式存在缺陷:
1. 频繁访问锁:每一次调度的过程需要经过锁,降低了调度的性能。
2. 资源利用率低:当一个G被一个M0调用的时候,会把所需要的资源加载到M中。当出现异常情况或者切换G的时候,下一次这个G不一定被M0调用,那么寄存器中保存的信息可能会丢失。需要重新载入,切换上下文成本高。(最好保证G和一个M保持着联系,提高CPU的亲和性)
GMP调度模式
在GM被使用者广泛吐槽之后,官方对此进行了优化,改用了GMP的模型,并且沿用之今,让我们来看一下GMP真面目,其大体模式图如下:
G、M的含义与上述表达相同(G指协程,M指内核级线程)。P是对G的一层调度管理,是processor的缩写,那下面深入讲解一下P。
P是为了优化GM中所存在的问题,而提出来的一个抽象层,他与G和M的关系如下:
1.与M的关系:它与M进行绑定,并且拥有一个本地队列。优势有两个:
一、降低锁的力度:M通过P来获取G消费的时候采用了CAS的方式,这样降低了锁的力度,提高了性能。
二、提高资源利用率:M所需要消费的G则来自于P的本地队列,若出现阻塞等异常情况,也会放回P的本地队列,能够保证这个G所需要的资源都在对应的M中,减少了上下文切换的额外消耗。
2.与G的关系:它通过自己的本地队列来存放G。异常情况有两种:如果当P满了的话则会把多余的G放到全局队列中;如果当P空了,他会去全局队列中获取G,如果全局队列也没有,那么他会通过抢占式的方式去获取其他P中的G。
在GMP当中,M值得一提的是它采用了队列的方式来管理。当P需要找一个M进行消费G的话,则会通过M的资源队列来获取一个游离(空闲)的M进行绑定,如果没有的话会生成一个M。
抢占式调度
上面再对GMP的介绍中稍微提到了一下抢占式的内容。现在我们具体来看一下抢占式调度的实现。
抢占式调度是GMP模型中的一个特殊方式,分为以下三种,并且有严格的先后顺序。
1.从P的本地队列获取G进行处理,无需加锁。
2.在P本地队列没有G的情况下,去全局队列中获取G,需要加锁。
3.在P和全局队列都没有G的情况下,P回去其他P中去窃取G,来进行自我消费。
对比GM与GMP
GMP的P有自己的本地队列,减少了锁访问的频率与竞争。
GMP中,G绑定于P,P绑定于M。在正常情况下,G和M是一个间接绑定的情况,所以保存了运行环境,提高了CPU的亲和性,减少了上下文的切换的额外花费。
GMP实现了work-stealing算法,尽可能让每一个M都能有G可以处理,减少了CPU的空转时间,提高了资源利用率。
总而言之,GMP在GM的基础上,将P额外的引入了runtime中,并且实现了抢占式调度,从而优化了GM模型中的劣势。
特殊情况
G可以直接挂在M下面跑,例如:磁盘IO等。
源码分析
下面的截图是针对GMP三个抽象类型的底层实现,只截取了部分我认为比较重要内容,通过注释的方式进行介绍,有兴趣的可以深入看一下源码文件:src/runtime/runtime2.go。(如果代码中出现标红的字段,是因为我把源码搬到了新的文件进行整理,无视即可)
首先是G的结构体,以及其上下文内容的gobuf结构体:
其次是M:
其次是P:
还有schedt(调度器):
对比P.localQueue和GlobalQueue
1. P的本地队列runq是通过数组+双指针来实现的环形队列
2. 全局队列的runq是单纯的链表结构(gQueue结构)
小结
当前golang采用的是GMP模型,是在runtime层抽象出,G(groutine)、M(machine)、P(process)、Schedt(scheduler)四个结构体类型来进行协作调度与处理的。
- 欢迎评论区或公众号留言~
- 您的意见是我进步前行的力量~