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
都不会执行用户代码,并且它们的执行栈都是被做该GC
的goroutine
所拥有。不过_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队列中取走一半。
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
队列(如channel
的waitq
),该G的状态由_Gruning
变为_Gwaitting
,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable
的G
供M
运行,那么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,但P
的Local
队列中仍然有G
需要执行,则创建一个新的M;当系统调用完成后,G
会重新尝试获取一个idle
的P
进入它的Local
队列恢复执行,如果没有idle
的P
,G
会被标记为runnable
加入到Global
队列。
管理协程
上面说到go语言自己定义一个结构体,叫协程。自己在用户态控制多个协程(结构体)的调度和执行,那它是怎么实现的呢?
go引入了P(Processor)的概念。一个P表示一个逻辑处理器,用于调度G。称之为逻辑处理器,一般与物理处理器对应
M(Machine),可以理解成一个线程,真正执行P的线程。
G、P、M之间的关系如下图:
每一个P都有一个对应的G队列,P绑定了线程M0
正在执行协程G0
,当遇到阻塞事件的时候,runtime会为P绑定一个新的线程M1
,执行新的新的线程