image

​ Go语言的一大特色是他的并发设计,低成本的Goroutine使Go的并发处理变得更容易。对于开发者来说,由于runtime将Goroutine的创建、销毁、执行、等待都囊括了,所以我们并不需要关注太多。但是了解Goroutine的调度可以帮助我们设计出更好的程序。

GMP模型

首先先介绍一下GMP模型的概念:

G

G也就是也就是goroutine,G存储了M执行的上下文在他的stack上,因为程序指令执行的位置(Program Counter)和指令的栈指针(Stack Pointer)被保护在G上,所以M可以很容易的切换G的执行上下文。在执行协程时,所有调度的代码都会被拷贝到G的执行栈上。换句话说,线程栈被G实现了而不是OS线程。它的常见状态如下:

状态 释义
_Ginit 0 表示该G才被创建出来还没被初始化
_Grunnable 1 表示该G处在可运行的状态,此时它不持有栈,它正处于本地队列 / 全局队列中
_Grunning 2 表示该G正在执行,此时它必有一个关联的P和执行它的M
_Gsyscall 3 该G正在执行系统调用,此时它被分配了一个M
_Gwaiting 4 该G被runtime阻塞,它需要被记录,以备后续通过ready方法来唤醒,只有在等待chan时它才拥有栈,其他情况它的栈可能会被销毁
_Gdead 6 表示该G已经不用了
_Gpreempted 9 表示G由于suspendG抢占而暂停了自己,和_Gwait不同的是,不一定需要唤醒它
_GScan 0x1000 表示G的栈正在被扫描

G0:G0会在每个M创建时被创建,M会在调度和系统调用时使用G0的栈,所以G0的栈也就是M的栈

P

p即processor,goroutine执行代码时需要在M上执行,且M需要绑定一个P才能执行代码。当然,M也可能被阻塞在系统调用中(此时并没有关联的P),单个P最大存储256个G。它的常见状态如下:

状态 释义
_Pidle 0 表示该P没有执行用户代码,该P被维护在idle list上
_Prunning 1 表示该P被维护在M上,正在执行用户代码或者调度器。只有拥有该P的M可以改变P的状态。
_Psyscall 2 表示P所关联的M产生了系统调用,此时P可能被偷走或者夺回
_Pgcstop 3 表示P因为GC导致停止
_Pdead 4 表示P不再被使用了(GOMAXPROCS缩小),当GOMAXPROCS扩大时会被重用

状态转移图如下:

p状态转移

M

M也就是Machine,代表内核线程。操作系统调度器会将内核线程放到cpu核心上执行。

M0: 用来执行初始化操作并启动第一个G,操作后将和普通的M没有区别

调度

​ 我们知道对于一个M来说他会持有一个P来做G的代码执行。那么对于P来说,它是如何执行他所持有的G呢,是顺序执行还是随机执行?又如何保证每个G都有被调度的机会呢?这些问题都涉及到了P具体调度G的细节。下面我们就来详细介绍一下具体的执行过程(具体的代码可以参见go 1.16.5的runtime/proc.go:3095的schedule方法):

  1. 检查M状态:首先如果M有锁或者正在执行cgo代码,则会crash程序。在没锁的情况下,如果有被锁在此M上的G则执行该G。
  2. GC相关:如果发现程序的gcwaiting不为0,说明GC希望STW,所以此时M会被停止。如果safepoint被置为1,那么我们可以执行safepointfunc。并且我们需要检查gcBlackenEnabled是否为0,如果是代表需要mutator帮助标记对象,所以我们从本地队列拿标记的G出来。
  3. 在本地队列和全局队列寻找:此时如果仍没有获得G,我们会首先检查调度次数是否为61的倍数,如果是且全局队列不为空就将先从全局队列里拿G。其次我们将尝试从本地队列里获取G。如果依然没有获得G,则我们不断的重复以下动作:
    • 如果发现程序的gcwaiting不为0,说明GC希望STW,所以此时M会被停止。如果safepoint被置为1,那么我们可以执行safepointfunc。
    • 从本地队列拿G
    • 从全局队列拿G(需要全局队列的锁)
    • 获取已经成功建立netpoll的协程列表,并返回该列表的第一个G。如果当前有P,那么则将剩余的G列表给每个空闲的P分一个,当前的P保留剩余的。否则将剩余的G放入全局队列。
    • 最后我们一共尝试4次从其他非空闲P里偷G(一次偷一半)
  4. 最后我们解除该M的自旋状态(因为此时必然已经拿到一个G)

可以看到整体的调度逻辑除了check逻辑外,更多的是围绕P的本地队列和全局队列。如下图所示:

gmp模型

一些感想

​ 对于Go而言,GMP的设计帮助我们扩展了程序的并发,减少了线程切换的开销(线程大小为1M,而协程为2k)。线程在切换时,需要进入内核态。且对于线程来说,共享内存也没有协程这么方便。因此,合理的利用协程将使程序的效率大大的提升。当然,是在我们对它了如指掌的时候~

参考资料


<
Previous Post
浅谈Golang内存分配
>
Next Post
浅谈Golang数据结构之Map