GolangGMP模型 GMP(三):协程让出,抢占,监控与调度

GMP三

前言

GolangGMP模型 GMP(一):HelloWorld程序的执行过程
GolangGMP模型 GMP(二):goroutine的创建,运行与恢复
GolangGMP模型 GMP(三):协程让出,抢占,监控与调度
GMP总结—>深入理解GMP模型

理解

在这里插入图片描述

我们已经知道,协程执行time.Sleep时,状态会从_Grunning变为_Gwaiting ,并进入到对应timer中等待,而timer中持有一个回调函数,在指定时间到达后调用这个回调函数,把等在这里的协程恢复到_Grunnable状态,并放回到runq中。

在这里插入图片描述

那谁负责在定时器时间到达时,触发定时器注册的回调函数呢?其实每个P都持有一个最小堆,存储在P.timers中,用于管理自己的timer,堆顶timer就是接下来要触发的那一个。

在这里插入图片描述

而每次调度时,都会调用checkTimers函数,检查并执行已经到时间的那些timer,不过这还不够稳妥,万一所有M都在忙,不能及时触发调度的话,可能会导致timer执行时间发生较大的偏差。
在这里插入图片描述
所以还会通过监控线程来增加一层保障,在介绍HelloGoroutine(GMP一)的执行过程时,我们提过监控线程是由main goroutine创建的,这个监控线程与GMP中的工作线程不同。并不需要依赖P,也不由GMP模型调度,它会重复执行一系列任务,只不过会视情况调整自己的休眠时间。其中一项任务便是保障timer正常执行,监控线程检测到接下来有timer要执行时,不仅会按需调整休眠时间,还会在空不出M时创建新的工作线程,以保障timer可以顺利执行。

在这里插入图片描述

当协程等待一个channel时,其状态也会从_Grunnig变成_Gwaiting,并进入到对应的channel的读队列或写队列中等待。

在这里插入图片描述

如果协程需要等待IO事件,就也需要让出,以epoll为例,若IO事件尚未就绪,需要注册要等待的IO事件到监听队列中,而每个监听对象都可以关联一个event data。所以就在这里记录是哪个协程在等待,等到事件就绪时再把它恢复到runq中即可 。

在这里插入图片描述

不过timer计时器有设置好的触发时间 ,等待的channel可读可写或关闭了,也自会通知到相关协程,而获取就绪的IO时间需要主动轮询,所以为了降低IO延迟,需要时不时的那么轮询一下,也就是执行netpoll。实际上监控线程,调度器,GC等工作过程中都会按需执行netpoll。

在这里插入图片描述

全局变量sched中会记录上次netpoll执行的时间,监控线程检测到距离上次轮询已超过了10ms,就会再执行一次netpoll。

在这里插入图片描述

上面说的无一例外,都是协程会主动让出的情况,那要是一个协程不会等待timer,channel或者IO事件,就不让出了吗?那必须不能啊,否则调度器岂不成了摆设?那怎么让那些不用等待的协程”让出“呢,这就是监控线程的另一个工作任务了,那就是本着公平调度的原则,对运行时间过长的G,实行”抢占“操作。

在这里插入图片描述

就是告诉那些运行时间超过特定阈值(10ms)的G,该让一让了,怎么知道运行时间过长了呢,P里面有一个schedtick字段,每当调度执行一个新的G,并且不继承上个G的时间片时,就会把它自增1,而这个p.sysmontick中,schedwhen记录的是上一次调度的时间,监控线程如果检测到p.sysmontick.schedtick与p.schedtick不相等,说明这个P又发生了新的调度,就会同步这里的调度次数,并更新这个调度时间。
在这里插入图片描述

但是若2者相等,就说明自schedwhen这个时间点之后,这个P并未发生新的调度,或者即使发生了新的调度,也延用了之前G的时间片,所以可以通过当前时间与schedwhen的差值来判断当前G是否运行时间过长了。

在这里插入图片描述

那如果真的运行时间过长了,要怎么通知它让出呢?这就不得不提到栈增长了,除了对协程栈没什么消耗的函数调用,Go语言编译器都会在函数头部插入栈相关代码。实际上编译器插入的栈增长代码一共有三种。注意这里为什么是”<=“,栈是向下增长的,上面是高地址,下面是低地址

在这里插入图片描述

如果栈帧比较小,插入的代码就是这样的,这个SP表示当前协程栈使用到了什么位置,stackguard0是协程栈空间下界,所以当协程栈的消耗达到或超过这个位置时,就需要进行栈增长了。

在这里插入图片描述

如果栈帧大小处在_StackSmall和_StackBig之间,插入的代码是这样的,也就是说,当前协程栈使用到这里,若再使用framesize这么多,超出stackguard0的部分大于_StackSmall了,就要进行栈增长了

在这里插入图片描述

而对于栈帧大小超过_StackBig的函数,插入的代码就有所有不同了,判断是否要栈增长的方式,本质上同第二种情况相同,而我们要关注的,是这里的stackPreempt ,它是和协程调度相关的重要标识,当runtime希望某个协程让出CPU时,就会把它的stackguard0赋值为stackPreempt。这是一个非常大的值,真正的栈指针不可能指向这个位置,所以可以安全的用作特殊标识。

在这里插入图片描述

正因为stackPreempt这个值足够大,所以这两段代码种的判断结果也都会为true,进而跳转到morestack处。

在这里插入图片描述

而morestack‘这里,最终会调用runtime.newstack函数,它负责栈增长工作,不过它在进行栈增长之前,会先判断stackguard0是否等于stackPreempt,等于的话就不进行栈增长了,而是执行一次协程调度。

在这里插入图片描述

所以在协程不主动让出时,也可以设置stackPreempt标识,通知它让出。

在这里插入图片描述

不过这种抢占方式的缺陷,就是过于依赖栈增长代码,如果来个空的for循环,因为与栈增长无关,监控线程等也无法通过设置stackPreempt标识来实现抢占,所以最终导致程序卡死。

在这里插入图片描述

这一问题在1.14版本中得到了解决,因为它实现了异步抢占,具体实现在不同平台种不尽相同。例如在Unix平台中,会向协程关联的M发送信号(sigPreempt),接下来目标线程会被信号中断,转去执行runtime.sighandler,在sighandler函数中检测到函数信号为sigPreempt后,就会调用runtime.doSigPreempt函数,它会向当前被打断的协程上下文中,注入一个异步抢占函数调用,处理完信号后sighandler返回,被中断的协程得以恢复,立刻执行被注入的异步抢占函数, 该函数最终会调用runtime中的调度逻辑,这不就让出了嘛。所以在1.14版本中,这段代码执行之前就不会卡死了。

在这里插入图片描述

而监控线程的抢占方式又多了一种,异步抢占,其实为了充分利用CPU,监控线程还会抢占处在系统调用中的P,因为一个协程要执行系统调用,就要切换到g0栈,在系统调用没执行完之前,这个M和这个G算是抱团了,不能被分开,也就用不到P,所以在陷入系统调用之前,当前M会让出P,解除m.p与当前p的强关联,只在m.oldp中记录这个p,P的数目毕竟有限,如果有其他协程在等待执行,那么放任P如此闲置就着实浪费了,还是把它关联到其他M继续工作比较划算,不过如果当前M从系统调用中恢复,会先检测之前的P是否被占用,没有的话就继续使用,否则就再去申请一个,没申请到的话,就把当前G放到全局runq中去,然后当前线程m就睡眠了。

在这里插入图片描述

说了这么多,不是让出就是抢占。

在这里插入图片描述

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

在这里插入图片描述

而获取下一个待执行的G时,会先去本地runq中查找,没有的话,就调用findrunnable(),这个函数直到获取到待运行的G才会返回。在findrunnable()函数这里,也会判断是否要执行GC,然后先尝试从本地runq中获取,没有的话就从全局runq获取一部分,如果还没有,就先尝试执行netpoll,恢复那些IO事件已经就绪了的G,它们会被放回到全局runq中,然后才会尝试从其他P那里steal一些G 。

在这里插入图片描述

当调度程序终于获得一个待执行的G以后,还要看看人家有没有绑定的M,如果有的话还得乖乖的把G还给对应的M。而当前M就不得不再次进行调度了。如果没有绑定的M,就调用excute函数在当前M上执行这个G。excute函数这里会简历当前M和这个G的关联关系,并把G的状态从_Grunnable修改为_Grunning,如果不继承上一个执行中协程的时间片,就把P这里的调度计数加一,最后会调用gogo函数,从g.sched这里恢复协程栈指针,指令指针等,接着继续协程的执行。

在这里插入图片描述

之前介绍过,协程创建时,会伪装一个执行现场存到g.sched中,所以即使这个G初次执行,也是有一个完美的执行现场的。

在这里插入图片描述

现在我们已经知道,协程在某些情况下会主动让出,但有时也需要设置stackPreemt标识,或异步抢占的方式来通知它让出。也了解了调度程序如何获取待执行的G并把它运行起来。期间还穿插介绍了监控线程的主要工作任务”保障计时器正常工作,执行网络轮询,抢占长时间运行的,或处在系统调用的P“,这些都是为了保障程序健康高效的执行,其实监控线程还有一项任务,就是强制执行GC,待到内存管理部分再展开~

猜你喜欢

转载自blog.csdn.net/qq_42956653/article/details/121216540