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

简介: GolangGMP模型 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,待到内存管理部分再展开~


目录
相关文章
|
7月前
|
网络协议 调度 开发者
python中gevent基于协程的并发编程模型详细介绍
`gevent`是Python的第三方库,提供基于协程的并发模型,适用于I/O密集型任务的高效异步编程。其核心是协程调度器,在单线程中轮流执行多个协程,通过非阻塞I/O实现高并发。主要特点包括协程调度、事件循环的I/O模型、同步/异步编程支持及易用性。示例代码展示了一个使用`gevent`实现的异步TCP服务器,当客户端连接时,服务器以协程方式处理请求,实现非阻塞通信。
91 0
|
1月前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
123 29
|
1月前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
2月前
|
前端开发 Java API
vertx学习总结5之回调函数及其限制,如网关/边缘服务示例所示未来和承诺——链接异步操作的简单模型响应式扩展——一个更强大的模型,特别适合组合异步事件流Kotlin协程
本文是Vert.x学习系列的第五部分,讨论了回调函数的限制、Future和Promise在异步操作中的应用、响应式扩展以及Kotlin协程,并通过示例代码展示了如何在Vert.x中使用这些异步编程模式。
61 5
vertx学习总结5之回调函数及其限制,如网关/边缘服务示例所示未来和承诺——链接异步操作的简单模型响应式扩展——一个更强大的模型,特别适合组合异步事件流Kotlin协程
|
4月前
|
NoSQL Unix 编译器
Golang协程goroutine的调度与状态变迁分析
文章深入分析了Golang中goroutine的调度和状态变迁,包括Grunnable、Gwaiting、Grunning和Gsyscall等状态,以及它们之间的转换条件和原理,帮助理解Go调度器的内部机制。
55 0
|
5月前
|
编译器 程序员 调度
协程问题之C++20 的协程实现是基于哪种协程模型的
协程问题之C++20 的协程实现是基于哪种协程模型的
|
5月前
|
传感器 设计模式 监控
屏幕监控软件中的Kotlin协程使用
在屏幕监控软件开发中,使用Kotlin协程可以有效地管理并发任务,提高应用程序的性能和响应能力。本文将介绍如何在屏幕监控软件中使用Kotlin协程,并提供一些实际的代码示例。
248 0
|
7月前
|
关系型数据库 MySQL 测试技术
协程的调度实现与性能测试
协程的调度实现与性能测试
|
安全 Go 调度
go一个协程安全协程调度的问题
go一个协程安全协程调度的问题
123 0
go一个协程安全协程调度的问题
|
存储 负载均衡 数据可视化
[典藏版]深入理解Golang协程调度GPM模型
<深入理解Golang协程调度器GPM模型>介绍了Golang中调度器的由来,以及如何演进到GPM模型的设计,其中包含一个Go协程在启动过程中如何运行和加载GPM模型的细节动作,也包括GPM模型的可视化编程和调试分析。最后形象介绍GPM模型的各个触发条件及运作的场景。
341 1
[典藏版]深入理解Golang协程调度GPM模型