G-P-M 模型概述

  • 每一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。
  • 这个固定大小的栈同时很大又很小。因为2MB的栈对于一个小小的Goroutine来说是很大的内存浪费,而对于一些复杂的任务(如深度嵌套的递归)来说又显得太小。因此,Go语言做了它自己的『线程』。
  • 在Go语言中,每一个Goroutine是一个独立的执行单元,相较于每个OS线程固定分配2M内存的模式,Goroutine的栈采取了动态扩容方式, 初始时仅为2KB,随着任务执行按需增长,最大可达1GB(64位机器最大是1G,32位机器最大是256M),且完全由Golang自己的调度器 Go Scheduler 来调度。
  • 此外,GC还会周期性地将不再使用的内存回收,收缩栈空间。 因此,Go程序可以同时并发成千上万个Goroutine是得益于它强劲的调度器和高效的内存模型

任何用户线程最终肯定都是要交由OS线程来执行

​ Goroutine(称为G)也不例外,但是G并不直接绑定OS线程运行,而是由Goroutine Scheduler中的 P - Logical Processor (逻辑处理器)来作为两者的『中介』

P 可以看作是一个抽象的资源或者一个上下文,一个P绑定一个OS线程,在Golang的实现里把OS线程抽象成一个数据结构。

M,G实际上是由M通过P来进行调度运行的,但是在G的层面来看,P提供了G运行所需的一切资源和环境,因此在G看来P就是运行它的 “CPU”,由 G、P、M 这三种由Go抽象出来的实现,最终形成了Go调度器的基本结构:

G: Goroutine

  • G有以下状态

    • GC状态

      • idle:_Gidle for idle,意思是这个goroutine刚被创建出来,还未被进行初始化。
      • runnable: _Grunnable for runnable意思是这个goroutine已经在运行队列,在这种情况下,goroutine还未执行用户代码,M的执行栈还不是goroutine自己的
      • running: _Grunning for running,意思是goroutine可能正在执行用户代码,M的执行栈已经由该goroutine所拥有,此时对象g不在运行队列中。这个状态值要待分配给M和P之后,交由M和P来设定
      • syscall, waiting, dead, copystack
    • 对应的GC状态

      • scan, scanrunnable, scan running, scansyscall, scanwaiting
      • _Gscan系列,用于标记正在被GC扫描的状态,这些状态是由_Gscan=0x1000再加上_GRunnable, _Grunning, _Gsyscall_Gwaiting的枚举值所产生的,这么做的好处是直接通过简单的运算即可知道被Scan之前的状态。当被标记为这系列的状态时,这些goroutine都不会执行用户代码,并且它们的执行栈都是被做该GCgoroutine所拥有。不过_Gscanrunning状态有点特别,这个标记是为了阻止正在运行的goroutine切换成其它状态,并告诉这个G自己扫描自己的堆栈。正是这种巧妙的方式,使得Go语言的GC十分高效。
  • 每个Goroutine对应一个G结构体,G 存储 Goroutine的运行堆栈、状态以及任务函数,可重用。

  • G并非执行体,每个G需要绑定到P才能被调度执行。

P: Processor

  • 表示逻辑处理器, 对G来说,P相当于CPU核,G只有绑定到P(在P的local run中)才能被调度。对M来说,P提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等,P的数量决定了系统内最大可并行的G的数量(前提:物理CPU核数 >= P的数量),P的数量由用户设置的GoMAXPROCS决定,但是不论GoMAXPROCS设置为多大,P的数量最大为256
  • golang runtime是有个sysmon的协程,他会轮询的检测所有的P上下文队列,**只要 G-M 的线程长时间在阻塞状态,那么就重新创建一个线程去从runtime P队列里获取任务。先前的阻塞的线程会被游离出去了,当他完成阻塞操作后会触发相关的callback回调,并加入回线程组里。**简单说,如果你没有特意配置runtime.SetMaxThreads,那么在没有可复用的线程的情况下,会一直创建新线程。

M: Machine

​ OS线程抽象,代表着真正执行计算的资源

  • 在绑定有效的P后,进入schedule循环;而schedule循环的机制大致是从Global队列、P的Local队列以及wait队列中获取G,切换到G的执行栈上并执行G的函数,调用Goexit做清理工作并回到M,如此反复。

  • M并不保留G状态,这是G可以跨M调度的基础,M的数量是不定的,由Go Runtime调整,为了防止创建过多OS线程导致系统调度不过来,目前默认最大限制为10000个。

  • 在绝大多数时候,其实P的数量和M的数量是相等。 每创建一个p, 就会创建一个对应的M只有少数情况下,M的数量会大于P

work-stealing 的调度算法

  • 每个P维护一个G的本地队列;
  • 当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
  • 当一个G在M里执行结束后,P会从队列中把该G取出;如果此时P的队列为空,即没有其他G可以执行, M就随机选择另外一个P,从其可执行的G队列中取走一半。

image-20210809113044316

G-P-M 模型调度

​ Go调度器工作时会维护两种用来保存G的任务队列:

  • 一种是一个Global任务队列
  • 一种是每个P维护的Local任务队列

当通过Go关键字创建一个新的Goroutine的时候,它会优先被放入P的本地队列。

​ 为了运Goroutine,M需要持有(绑定)一个P,接着M会启动一个OS线程,循环从P的本地队列里取出一个Goroutine并执行。

当然还有上文提及的 work-stealing调度算法:

​ 当M执行完了当前P的Local队列里的所有G后,P也不会就这么在那躺尸啥都不干,它会先尝试从Global队列寻找G来执行,如果Global队列为空,它会随机挑选另外一个P,从它的队列里中拿走一半的G到自己的队列中执行。

用户态阻塞/唤醒

​ 当Goroutine因为channel操作或者network I/O而阻塞时(实际上Golang已经用netpoller实现了Goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G,这里仅仅是举个栗子),对应的G会被放置到某个wait队列(如channelwaitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnableGM运行,那么M将解绑P,并进入sleep状态;

当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。

系统调用阻塞

​ 当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall 状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G

​ 如果没有其它idle的M,但PLocal队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idleP进入它的Local队列恢复执行,如果没有idlePG会被标记为runnable加入到Global队列。

管理协程

​ 上面说到go语言自己定义一个结构体,叫协程。自己在用户态控制多个协程(结构体)的调度和执行,那它是怎么实现的呢?

  • go引入了P(Processor)的概念。一个P表示一个逻辑处理器,用于调度G。称之为逻辑处理器,一般与物理处理器对应

  • M(Machine),可以理解成一个线程,真正执行P的线程。

G、P、M之间的关系如下图:

image-20210809113107322

每一个P都有一个对应的G队列,P绑定了线程M0正在执行协程G0,当遇到阻塞事件的时候,runtime会为P绑定一个新的线程M1,执行新的新的线程

参考链接

Go协程管理

Go 调度模型 GPM