Golang调度器(9)—总结
0. 简介
前面几篇博客,我们针对Golang调度器
进行了介绍,下面,我们总结一下以上的知识点。
1. goroutine的意义
其实,在Go
中,为什么不使用操作系统线程,而要发明goroutine
及其调度器,为的就是解决操作系统线程的弊端——太重:
- 创建和切换太重:操作系统线程的创建和切换都需要陷入内核态,而这开销就比较大,在系统调用过多的场景下,明显会成为性能瓶颈;
- 内存使用太重:为了避免极端情况下的操作系统线程的栈溢出,内核在创建操作系统线程会默认分配一块较大的栈内存,然而在绝大多数情况下,系统线程远用不到那么多的内存,这会导致浪费。
相对的,用户态的goroutine
则要轻量得多:
goroutine
是用户态协程,其创建和切换都不会陷入内核态,所以开销比较小;goroutine
启动时默认栈大小是2k,这在多数情况下够用了。如果不够用的话,前面说过,在每个没有标记//go:nosplit
的函数的开头,都会插入栈扩张指令,发现当栈指针寄存器SP
小于g.stackguard0
时(这里要注意:栈是从高地址到低地址运行的,所以正常情况下,_g_.stackguard0 = _g_.stack.lo + _StackGuard
即栈”顶“低地址+一个固定值;而在抢占时,g.stackguard0 = stackPreempt
,直接设置一个很大的值,使得SP
必然小于g.stackguard0
),便触发栈扩张。- 更神奇的,
Go
调度器如果发现所使用的栈太小,还会在GC
的时候进行栈收缩操作,收缩策略是,如果栈大小大于最小栈大小,且使用的大小不过栈大小的1/4,则会收缩至原来的一半大小。
从上面可以看出,goroutine
要解决的就是系统线程太重的问题,goroutine
及其调度器本身的设计就是为了一个宗旨——高效!
2. GMP模型
G表示的就是goroutine
,是用户态的轻量级线程,也叫协程。具体可以创建多少的协程,取决于系统资源,比如内存大小。
M表示的是系统线程,Go
的调度器限制了最多允许创建10000个操作系统线程,超过时会抛出异常。
P表示调度器,也就是用户态的调度器,其数量默认为机器的CPU核心数。
为什么需要P
前面说过,在Go
中,线程模型是M:N
的,其实可以认为就是G-M
的,那要P做什么呢?其实在最开始,Go
的线程模型确实是没有P的,那么没有G-M
模型会带来什么问题呢:
- 不可能给M和P一样设置一个本地
goroutine
队列,那样的话会当M陷入系统调用的时候,其本地队列得不到执行,造成饥饿; - 如果都要从全局队列获取G,那么需要一把锁来锁住队列,这在程序并发量大的时候会成为性能的瓶颈;
基于没有什么是加一个中间层不能解决的思想指导下,加入P,会带来以下效果:
- 每个P都有本地G队列,大幅减轻了对全局队列的直接依赖,减少了锁竞争;
- 每个P都实现了
Work Stealing
算法,如果本地队列为空,会尝试从全局队列或者其他队列中窃取可运行的G来执行,减少空转,提高了效率;
那为什么以上优化逻辑不能直接加在M上,而要引入一个P呢:
- 一般,M的数量在存在阻塞系统调用时会不断增加,这会使得在M挂载本地队列时,本地队列的数目变得不可控,
Work Stealing
的效率会越来越低; - M存在阻塞时,如果什么都不做,其本地队列的其他G会遭受饥饿;那么还需要复杂的逻辑将其本地队列挂载到全局队列或者分配其他M,但这会造成很大的性能瓶颈;
所以总的来说,P的数量是固定的,一般而言是CPU核数,在GMP
模型中可以大概率保证每个P都在工作,最大效率地利用CPU。而M的数量一般会大于P的数量,在存在大量系统阻塞的应用中M的数量会急剧上升。G的数量不确定,在Golang
的网络IO密集型应用中,基本上是goroutine-per-connection
。
基本的工作机制
GMP
模型中,为了提高P的利用效率,主要是做以下两件事:
- 将P绑定到一个M:P本身并不能直接运行G,需要将将P和M绑定,利用M才能执行G;
- 为P选中一个G来执行。
P的流转状态
通常来说,在运行时不会调整P的个数,那么P只会在以上四种状态之间流转:
- 当M需要运行时,会通过
runtime.acquirep
来使P的状态变为_Prunning
,当需要释放P时,则通过runtime.releasep
来释放; - 当G执行时需要系统调用,P会被
entersyscall
置为_Psyscall
,如果这时候被sysmon
后台线程抢夺(retake
)了,则会变为_Pidle
;如果等待系统调用返回,这个P还没有被抢占,则会优先选择这个P来继续执行,状态则会通过exitsyscall
重新变为_Prunning
; - 如果在程序中发生
GC
,那么则会被置为_Pgcstop
。
G的状态流转
上图介绍了调度器调度行为过程中主要的状态流转:
- 在
goroutine
被创建时,其状态是_Gidle
(其实就是零值),然后会被立马置为_Gdead
状态,在之后就会被置为_Grunnable
状态,放到所在P的本地队列的下一个; - 正在运行的
goroutine
状态为_Grunning
,在协程退出时会通过goexit
系列函数重新回到调度循环,而此时这个goroutine
也不会被直接消灭,而是会把状态置为_Gdead
,等待下次newproc
的时候从这些“死去”
的goroutine
中取; - 在从本地队列、或从全局队列、或从网络轮询器、或从其他队列窃取到可执行的
goroutine
,即_Grunnable
状态,经过execute
进行执行后,状态会变成_Grunning
; - 正在执行的
goroutine
在进入系统调用后会被置为_Gsyscall
,此时M和P解绑;如果之后P被sysmon
后台线程retake
去做别的事情了,而经过系统调用回来的线程M没有找到其他空闲的P,那么这个协程会被置为_Grunnable
,放到全局队列中等待执行;如果之后系统不忙碌,P还是_Psyscall
状态,那么M会继续绑定此P执行此协程,协程状态就变为了_Grunning
;亦或者能找到其他空闲的P,那么也会结合此P将协程状态就变为了_Grunning
进行执行; - 如果
goroutine
因为一些原因被挂起,比如说channel
的阻塞读写,比如说网络轮询器的阻塞读写,那么该goroutine
被挂起后的状态会被置为_Gwaiting
,在条件ready
后,又会被置为_Grunnable
扔回队列等待执行; - 如果在执行过程中,由用户主动调用
runtime.Gosched
放弃CPU执行权,或者说是因为时间过长发生抢占,其后协程状态会从_Grunning
变为_Grunnable
,并扔进全局队列中。
M的状态流转
可以看到,M的状态要简单的多:
- 在创建一个M时,通过
mstart
创建一个系统线程; - 在线程执行过程中,如果发现没有G需要执行了,那么会通过
findrunnable
函数进行找工作阶段,这个过程被成为spinning
,即自旋找工作阶段,如果找到工作则继续执行; - 如果没有找到工作则会通过
notesleep
进入睡眠; - 在其后需要线程来执行工作时,会通过
notewakeup
函数唤醒线程从而继续执行或者spinning
继续找工作。
3. 抢占
为了防止一些goroutine
占据CPU太久,导致其他协程饿死,所以Go
调度器采取了抢占机制,有两种抢占机制:
- 基于协作的抢占:每个函数的开头位置插入栈扩张标记,在运行过长的协程中设置栈抢占标记,在下次进入到任何函数的调用时,会进入抢占,从而放弃运行权,为表示惩罚,此
goroutine
会被丢到全局队列; - 基于信号的抢占:基于协作的抢占无法满足在没有函数调用的循环中抢占协程,所以还需要基于信号的抢占,在运行时可以直接打断运行此
goroutine
的系统线程M。
4. 系统调用和网络轮询器
在Linux系统中,传统的文件读写无法利用到网络轮询器,也就是说每次文件的增删改查和创建都会陷入系统调用,系统调用发生时,P会和M解绑,等待sysmon
的retake
操作将其和其他M结合,去执行别的任务,从而提升效率;在从系统调用恢复后,再继续找P来执行此G,找不到就扔队列。
在Linux系统中,网络IO可以使用底层为epoll
的网络轮询器,降低系统调用的频次,提升效率。所以可以说,Go
是适宜于网络IO密集型的应用。
5. 小结
我们把整个系统理解为一个育婴工作室,其最重要的工作就是喂孩子吃奶。把P理解为工人,把M比作喂养孩子的机器,把G比喻为一个个嗷嗷待哺的婴儿。在工作室中,只有四个育婴室(4核),每个工人需要一个育婴室才能工作,所以设置工人(P)也就是4个,因为多了没啥用,多出来的工人也没有地方使用工具(M)。每个育婴室有着本地嗷嗷待哺的孩子(本地队列),整个车间还有排队等待吃奶的孩子(全局队列)。每个婴儿吃饱了就走了。
系统监控线程sysmon
:为了高效的管理这个工作室,万恶的资本家设置了一个工作室主任,负责监管工作室的一切,确保工作室的工作效率;
全局队列执行策略:为了不让外面的孩子饿死,喂了一定的孩子后还需要去外面抱个婴儿来喂。
抢占机制:如果有的婴儿像个貔貅,喂不饱的,那么还需要打断其进食,并且为了惩罚它,将其放到车间队列中,下次有机会再喂。
窃取策略:如果某个工人(P)效率比较快,将本房间的孩子都喂好了,外面也找不到需要喂养的孩子了,那也得不到休息,万恶的工作室主任会为其分配其他工作室一半的喂养任务。
系统调用:如果某个孩子需要的营养素车间没有,需要机器等待管理员(内核)从其他地方进货,那么工人(P)会放弃这台机器,想短暂休息下;但是这时如果被万恶的工作室主任(sysmon
)发现了,它会重新选一台空闲的机器或者买一台新的来给P继续工作,反正不能让P闲着。
类似于channel
的阻塞读写:如果某个小朋友比较调皮,非得等其他小朋友给个糖才能继续吃奶,那么工人会将这个孩子挂起来,等着有谁给了糖,再将其放到本地队列中等着喂养。
总之,所有一切的规章制度、机制都是为了这个育婴工作室高效运转!
6. 参考文献
转载自:https://juejin.cn/post/7222507980445466683