源码剖析
数据段上重要的全局变量
- 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函数调用。
- newproc函数这里主要做的就是切换到g0栈去调用newproc1函数
- newproc1创建一个新G
- 调用runqput把这个G放到P的本地队列中,如满则放全局队列
- 如果当前有空闲的p,而且没有处于spinning状态(线程自旋)的M,也就是说所有M都在忙,同时主协程以及开始执行了,那么就调用wakep函数,启动一个m并把它置为spinning状态。spinning状态的M启动后,忙不迭的执行调度循环寻找任务,从本地runq,到全局runq,再到其他p的runq,只为找到一个待执行的G。
runtime.gopark挂起G
timer,channel,IO等都会调用runtime.gopark()函数挂起G,让出CPU时间片(主动让出)
- 状态从_Grunning修改为_Gwaiting
- 调用mcall(park_m),切换到g0,执行park_m,它主要负责保存当前协程的执行现场
- park_m会根据g0找到当前M,把m.curg置为nil
- 调用schedult()寻找下一个待执行的G
runtime.goready唤醒G
- 切换到g0栈,并执行runtime.ready函数
- 把_Gwaiting修改为_Grunnable
- 放到当前P的本地队列,如满则放全局队列
- 同协程创建时一样,接下来也会检查是否有空闲的P,并且没有spinning状态的M,是的话也会调用wakep()函数启动新的M
sysmon监控线程
程序刚开始执行时,M0切换到main goroutine,执行入口是runtime.main,它会做很多事,包括创建监控线程,进行包初始化等等
- 监控线程是由main goroutine创建的,这个监控线程与GMP中的工作线程不同。并不需要依赖P,也不由GMP模型调度,它会重复执行一系列任务,只不过会视情况调整自己的休眠时间
- 监控线程检测到接下来有timer要执行时,不仅会按需调整休眠时间,还会在空不出M时创建新的工作线程,以保障timer可以顺利执行
- 获取就绪的IO时间需要主动轮询,所以为了降低IO延迟,需要时不时的那么轮询一下,也就是执行netpoll
- 强制执行GC
- 其实为了充分利用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()的职责了
- schedul这里要给这个M找到一个待执行的G,首先要确定当前M是否和当前G绑定了,如果绑定了,那当前M就不能执行其他G,所以需要阻塞当前M,等到当前G再次得到调度执行时,自会把当前M唤醒。
- 如果没有绑定,就先看看GC是不是在等待执行
- 全局变量sched这里,有一个gcwaiting标识,如果GC在等待执行,就去执行GC,回来再继续执行调度程序
- 接下来还会检查一下有没有要执行的timer。调度程序还有一定几率会去全局runq中获取一部分G到本地runq中。
接着调用findrunnable函数
5. 也会判断是否要执行GC
6. 先尝试从本地runq中获取,没有的话就从全局runq获取一部分,如果还没有,就先尝试执行netpoll,恢复那些IO事件已经就绪了的G,它们会被放回到全局runq中,然后才会尝试从其他P那里steal一些G 。
7. 当调度程序终于获得一个待执行的G以后,还要看看人家有没有绑定的M,如果有的话还得乖乖的把G还给对应的M。而当前M就不得不再次进行调度了。
excute
如果没有绑定的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的系统调用结束/非阻塞
特殊点需关注
- work stealing时,从偷取的P那里,取尾部的一半放到自己的P中
- 从全局队列中取时,取n个,len(GQ)为全局队列G的个数,公式: n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
- 本地队列满了,又创建新的G,此时把本地队列的前一半与新创建的G,打乱后,一起放到全局队列中
- 自旋线程的最大限制不能超过GOMAXPROCS,多出的线程休眠(⻓时间休眠等待GC回收销毁)