likes
comments
collection
share

Golang调度器(9)—总结

作者站长头像
站长
· 阅读数 16

0. 简介

前面几篇博客,我们针对Golang调度器进行了介绍,下面,我们总结一下以上的知识点。

1. goroutine的意义

其实,在Go中,为什么不使用操作系统线程,而要发明goroutine及其调度器,为的就是解决操作系统线程的弊端——太重

  1. 创建和切换太重:操作系统线程的创建和切换都需要陷入内核态,而这开销就比较大,在系统调用过多的场景下,明显会成为性能瓶颈;
  2. 内存使用太重:为了避免极端情况下的操作系统线程的栈溢出,内核在创建操作系统线程会默认分配一块较大的栈内存,然而在绝大多数情况下,系统线程远用不到那么多的内存,这会导致浪费。

相对的,用户态的goroutine则要轻量得多:

  1. goroutine是用户态协程,其创建和切换都不会陷入内核态,所以开销比较小;
  2. goroutine启动时默认栈大小是2k,这在多数情况下够用了。如果不够用的话,前面说过,在每个没有标记//go:nosplit的函数的开头,都会插入栈扩张指令,发现当栈指针寄存器SP小于g.stackguard0时(这里要注意:栈是从高地址到低地址运行的,所以正常情况下,_g_.stackguard0 = _g_.stack.lo + _StackGuard即栈”顶“低地址+一个固定值;而在抢占时,g.stackguard0 = stackPreempt,直接设置一个很大的值,使得SP必然小于g.stackguard0),便触发栈扩张。
  3. 更神奇的,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的流转状态

Golang调度器(9)—总结

通常来说,在运行时不会调整P的个数,那么P只会在以上四种状态之间流转:

  • 当M需要运行时,会通过runtime.acquirep来使P的状态变为_Prunning,当需要释放P时,则通过runtime.releasep来释放;
  • 当G执行时需要系统调用,P会被entersyscall置为_Psyscall,如果这时候被sysmon后台线程抢夺(retake)了,则会变为_Pidle;如果等待系统调用返回,这个P还没有被抢占,则会优先选择这个P来继续执行,状态则会通过exitsyscall重新变为_Prunning
  • 如果在程序中发生GC,那么则会被置为_Pgcstop

G的状态流转

Golang调度器(9)—总结

上图介绍了调度器调度行为过程中主要的状态流转:

  • 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的状态流转

Golang调度器(9)—总结

可以看到,M的状态要简单的多:

  • 在创建一个M时,通过mstart创建一个系统线程;
  • 在线程执行过程中,如果发现没有G需要执行了,那么会通过findrunnable函数进行找工作阶段,这个过程被成为spinning,即自旋找工作阶段,如果找到工作则继续执行;
  • 如果没有找到工作则会通过notesleep进入睡眠;
  • 在其后需要线程来执行工作时,会通过notewakeup函数唤醒线程从而继续执行或者spinning继续找工作。

3. 抢占

为了防止一些goroutine占据CPU太久,导致其他协程饿死,所以Go调度器采取了抢占机制,有两种抢占机制:

  • 基于协作的抢占:每个函数的开头位置插入栈扩张标记,在运行过长的协程中设置栈抢占标记,在下次进入到任何函数的调用时,会进入抢占,从而放弃运行权,为表示惩罚,此goroutine会被丢到全局队列;
  • 基于信号的抢占:基于协作的抢占无法满足在没有函数调用的循环中抢占协程,所以还需要基于信号的抢占,在运行时可以直接打断运行此goroutine的系统线程M。

4. 系统调用和网络轮询器

在Linux系统中,传统的文件读写无法利用到网络轮询器,也就是说每次文件的增删改查和创建都会陷入系统调用,系统调用发生时,P会和M解绑,等待sysmonretake操作将其和其他M结合,去执行别的任务,从而提升效率;在从系统调用恢复后,再继续找P来执行此G,找不到就扔队列。

在Linux系统中,网络IO可以使用底层为epoll的网络轮询器,降低系统调用的频次,提升效率。所以可以说,Go是适宜于网络IO密集型的应用。

5. 小结

我们把整个系统理解为一个育婴工作室,其最重要的工作就是喂孩子吃奶。把P理解为工人,把M比作喂养孩子的机器,把G比喻为一个个嗷嗷待哺的婴儿。在工作室中,只有四个育婴室(4核),每个工人需要一个育婴室才能工作,所以设置工人(P)也就是4个,因为多了没啥用,多出来的工人也没有地方使用工具(M)。每个育婴室有着本地嗷嗷待哺的孩子(本地队列),整个车间还有排队等待吃奶的孩子(全局队列)。每个婴儿吃饱了就走了。

系统监控线程sysmon:为了高效的管理这个工作室,万恶的资本家设置了一个工作室主任,负责监管工作室的一切,确保工作室的工作效率;

全局队列执行策略:为了不让外面的孩子饿死,喂了一定的孩子后还需要去外面抱个婴儿来喂。

抢占机制:如果有的婴儿像个貔貅,喂不饱的,那么还需要打断其进食,并且为了惩罚它,将其放到车间队列中,下次有机会再喂。

窃取策略:如果某个工人(P)效率比较快,将本房间的孩子都喂好了,外面也找不到需要喂养的孩子了,那也得不到休息,万恶的工作室主任会为其分配其他工作室一半的喂养任务。

系统调用:如果某个孩子需要的营养素车间没有,需要机器等待管理员(内核)从其他地方进货,那么工人(P)会放弃这台机器,想短暂休息下;但是这时如果被万恶的工作室主任(sysmon)发现了,它会重新选一台空闲的机器或者买一台新的来给P继续工作,反正不能让P闲着。

类似于channel的阻塞读写:如果某个小朋友比较调皮,非得等其他小朋友给个糖才能继续吃奶,那么工人会将这个孩子挂起来,等着有谁给了糖,再将其放到本地队列中等着喂养。

总之,所有一切的规章制度、机制都是为了这个育婴工作室高效运转!

6. 参考文献

1.5.3.  Golang GPM 模型剖析

GPM 是什么