深入理解GMP模型

前言

此篇博文为总结,想要深入理解GMP原理点击下面博文
GolangGMP模型 GMP(一):HelloWorld程序的执行过程
GolangGMP模型 GMP(二):goroutine的创建,运行与恢复
GolangGMP模型 GMP(三):协程让出,抢占,监控与调度

源码剖析

数据段上重要的全局变量

  • m0: M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,M0负责执⾏初始化操作和启动第⼀个G, 在之后M0就和其他的M⼀样了。
  • g0: G0是每次启动⼀个M都会第⼀个创建的gourtine,G0仅⽤于负责调度的G,G0不指向任何可执⾏的函数,每个M都会有⼀个⾃⼰的G0。在调度或系统调⽤时会使⽤G0的栈空间, 全局变量的G0是M0的G0
  • allgs: 记录所有的G
  • allm: 记录所有的M
  • allp: 记录所有的P
  • sched: sched是调度器,这里记录着所有空闲的m,空闲的p,全局队列runq等等

newproc创建协程G

go关键字创建协程,会被编译器转换为newproc函数调用。

  1. newproc函数这里主要做的就是切换到g0栈去调用newproc1函数
  2. newproc1创建一个新G
  3. 调用runqput把这个G放到P的本地队列中,如满则放全局队列
  4. 如果当前有空闲的p,而且没有处于spinning状态(线程自旋)的M,也就是说所有M都在忙,同时主协程以及开始执行了,那么就调用wakep函数,启动一个m并把它置为spinning状态。spinning状态的M启动后,忙不迭的执行调度循环寻找任务,从本地runq,到全局runq,再到其他p的runq,只为找到一个待执行的G。

runtime.gopark挂起G

timer,channel,IO等都会调用runtime.gopark()函数挂起G,让出CPU时间片(主动让出)

  1. 状态从_Grunning修改为_Gwaiting
  2. 调用mcall(park_m),切换到g0,执行park_m,它主要负责保存当前协程的执行现场
  3. park_m会根据g0找到当前M,把m.curg置为nil
  4. 调用schedult()寻找下一个待执行的G

runtime.goready唤醒G

  1. 切换到g0栈,并执行runtime.ready函数
  2. 把_Gwaiting修改为_Grunnable
  3. 放到当前P的本地队列,如满则放全局队列
  4. 同协程创建时一样,接下来也会检查是否有空闲的P,并且没有spinning状态的M,是的话也会调用wakep()函数启动新的M

sysmon监控线程

程序刚开始执行时,M0切换到main goroutine,执行入口是runtime.main,它会做很多事,包括创建监控线程,进行包初始化等等

  1. 监控线程是由main goroutine创建的,这个监控线程与GMP中的工作线程不同。并不需要依赖P,也不由GMP模型调度,它会重复执行一系列任务,只不过会视情况调整自己的休眠时间
  2. 监控线程检测到接下来有timer要执行时,不仅会按需调整休眠时间,还会在空不出M时创建新的工作线程,以保障timer可以顺利执行
  3. 获取就绪的IO时间需要主动轮询,所以为了降低IO延迟,需要时不时的那么轮询一下,也就是执行netpoll
  4. 强制执行GC
  5. 其实为了充分利用CPU,监控线程还会抢占处在系统调用中的P,因为一个协程要执行系统调用,就要切换到g0栈,在系统调用没执行完之前,这个M和这个G算是抱团了,不能被分开,也就用不到P,所以在陷入系统调用之前,当前M会让出P,解除m.p与当前p的强关联,只在m.oldp中记录这个p,P的数目毕竟有限,如果有其他协程在等待执行,那么放任P如此闲置就着实浪费了,还是把它关联到其他M继续工作比较划算,不过如果当前M从系统调用中恢复,会先检测之前的P是否被占用,没有的话就继续使用,否则就再去申请一个,没申请到的话,就把当前G放到全局runq中去,然后当前线程m就睡眠了。

本着公平调度的原则,对运行时间过长(阈值10ms)的G,实行”抢占“操作,两种方式通知G。

  • stackPreempt => 监控线程会插入stackpreempt标识,通过判断是否要栈增长的方式通知G让出CPU
  • asyncPreempt => stackPreempt抢占方式的缺陷,就是过于依赖栈增长代码,如果来个空的for循环,因为与栈增长无关,监控线程等也无法通过设置,这一问题在1.14版本中得到了解决,因为它实现了异步抢占。在Unix平台中,会向协程关联的M发送信号(sigPreempt),接下来目标线程会被信号中断,转去执行runtime.sighandler,在sighandler函数中检测到函数信号为sigPreempt后,就会调用runtime.doSigPreempt函数,它会向当前被打断的协程上下文中,注入一个异步抢占函数调用,处理完信号后sighandler返回,被中断的协程得以恢复,立刻执行被注入的异步抢占函数, 该函数最终会调用runtime中的调度逻辑。

schedult调度

那让出了,抢占了之后,M也不能闲着,得找到下一个待执行的G来运行,这就是schedule()的职责了

  1. schedul这里要给这个M找到一个待执行的G,首先要确定当前M是否和当前G绑定了,如果绑定了,那当前M就不能执行其他G,所以需要阻塞当前M,等到当前G再次得到调度执行时,自会把当前M唤醒。
  2. 如果没有绑定,就先看看GC是不是在等待执行
  3. 全局变量sched这里,有一个gcwaiting标识,如果GC在等待执行,就去执行GC,回来再继续执行调度程序
  4. 接下来还会检查一下有没有要执行的timer。调度程序还有一定几率会去全局runq中获取一部分G到本地runq中。

接着调用findrunnable函数
5. 也会判断是否要执行GC
6. 先尝试从本地runq中获取,没有的话就从全局runq获取一部分,如果还没有,就先尝试执行netpoll,恢复那些IO事件已经就绪了的G,它们会被放回到全局runq中,然后才会尝试从其他P那里steal一些G 。
7. 当调度程序终于获得一个待执行的G以后,还要看看人家有没有绑定的M,如果有的话还得乖乖的把G还给对应的M。而当前M就不得不再次进行调度了。
excute

  1. 如果没有绑定的M,就调用excute函数在当前M上执行这个G。excute函数这里会建立当前M和这个G的关联关系,并把G的状态从_Grunnable修改为_Grunning,如果不继承上一个执行中协程的时间片,就把P这里的调度计数加一,最后会调用gogo函数,从g.sched这里恢复协程栈指针,指令指针等,接着继续协程的执行。

图解调度

Goroutine调度器的GMP模型的设计思想

(1)GMP模型简介

在这里插入图片描述

(2)调度器的设计策略

在这里插入图片描述

(3) “go func()”经历了什么过程

在这里插入图片描述

(4)调度器的生命周期

在这里插入图片描述

Go调度器GMP调度场景的全过程分析

场景1:G1创建G2

在这里插入图片描述

场景2:G1执行完毕

在这里插入图片描述

场景3,4,5:G2开辟过多的G => G2的P本地队列满了再创建G7 => G2本地队列未满创建G8

在这里插入图片描述
本地队列已满,想要再创建G7放入,这个时候将本地队列的前一半G和新创建的G7放在一起,打乱顺序后放入全局队列中。
在这里插入图片描述
在这里插入图片描述

场景6:唤醒正在休眠的M

在这里插入图片描述

场景7:被唤醒的M2从全局队列取批量G

在这里插入图片描述

场景8:M2从M1中偷取G

偷取时,偷尾部的一半
在这里插入图片描述

场景9:自旋线程的最大限制

在这里插入图片描述

场景10:G发生系统调用/阻塞

在这里插入图片描述

场景11:G的系统调用结束/非阻塞

在这里插入图片描述

特殊点需关注

  1. work stealing时,从偷取的P那里,取尾部的一半放到自己的P中
  2. 从全局队列中取时,取n个,len(GQ)为全局队列G的个数,公式: n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
  3. 本地队列满了,又创建新的G,此时把本地队列的前一半与新创建的G,打乱后,一起放到全局队列中
  4. 自旋线程的最大限制不能超过GOMAXPROCS,多出的线程休眠(⻓时间休眠等待GC回收销毁)
posted @ 2021-11-09 21:13  cheems~  阅读(562)  评论(0编辑  收藏  举报