地鼠宝宝的轶事奇闻之线程模型"/>
地鼠宝宝的轶事奇闻之线程模型
Golang并发机制
线程模型
在操作系统提供的内核线程之上,go搭建了一个两级线程模型。毫无疑问,其中一级必然是内核线程,另一级便是goroutine。你可以将goroutine看作是应用程序线程。
goroutine一词是由go的开发者们专门创建的,它所表达的含义是:
不要用共享内存的方式来通信,而应该以通信作为手段来共享内存。
三大核心
go的线程模型由三个核心元素支撑:
-
M(machine):一个M代表一个内核线程。
-
P(processor):一个P代表执行一个go代码片段所必须的资源(上下文环境)。
-
G(goroutine):一个G代表一个go代码片段,goroutine是对go代码片段的一种封装。
不要惊讶于这里出现的三大核心元素。它们并不是新概念,我们之前已经见过它们了。在golang并发初探一文中,我们提到了操作系统线程,逻辑处理器和goroutine三个词,它们和这里的M、P、G是一一对应的。所谓的逻辑处理器就是这里的P,也就是上下文环境,所以我说前文是很不负责的。
有关M、P、G的声明都在
runtime
包的runtime2.go
文件中。
-
一个G的执行需要P和M的支持;
-
一个M和一个P关联后就形成了一个有效的G运行环境(内核线程+上下文环境);
-
每个P都会包含一个可运行的G的队列;
-
队列中的G会被依次传递给与本地P关联的M执行。
M、P、G、KSE(内核调度实体)之间的关系
-
M与KSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。
-
M与P之间也总是一对一的,但是M与P的关联可能在调度过程中改变。
-
P与G之间是一对多的关系,因为每个P都包含一个可运行的G队列。同样,它们之间的关联也会在调度中改变。
-
M与G之间也会建立关联,因为一个G终归由一个M来运行。它们之间的关联通过P来牵线。
M
type m struct { g0 *g //一个特殊的goroutine,由go运行时系统在启动之初创建,用于执行运行时任务。 mstartfn func() //在新的M上启动某个特殊任务的函数,可能系统监控,GC辅助或M自旋。 curg *g //当前M正在运行的那个G(goroutine)的指针。 p puintptr //与当前M关联的P(上下文环境)。 nextp puintptr //暂存与当前M有潜在关联的P,将P赋给M的nextp字段称为M和P的预联。 spinning bool //当前M是否正在寻找可运行的G。寻找过程中,M处于自旋状态。 lockedg *g //与当前M锁定的G。一旦锁定,这个M只能运行这个G,这个G也只能由该M运行。 thread uintptr //线程句柄。真正用来执行go代码的系统线程。
}
字段p
的作用是当前M与一个P关联,字段nextp
的作用是将当前M与一个P预联。我们知道,一个M和一个P关联以后才是一个G的运行环境。那么预联有什么用呢?运行时系统有时会把刚重新启动的M和已与它预联的P关联在一起,也就是将nextp
字段的值赋给p
字段,这就是nextp
字段的主要作用。
除了lockedg
字段可以将一个M和一个G锁定之外,runtime
包中的函数LockOSThresd
和UnlockOSThread
也提供了锁定和解锁的方法。
以上列出的只是结构体m
中的一部分字段
M的创建
M创建之初会做如下3件事:
-
将创建的M加入全局M列表(
runtime.allm
)中。 -
设置它的起始函数(
mstartfn
字段)和预联的P(nextp
字段)。其中,起始函数仅当运行时系统要用此M执行系统监控或垃圾回收等任务时才会被设置。 -
运行时系统创建一个新的内核线程并与之关联,也就是赋给
thread
字段。
全局M列表除了可以通过它获取到所有M的信息以及防止M被当作垃圾回收掉之外,并没有特殊意义。
M的初始化
新的M创建之后,go运行时系统会对它进行初始化。包括自身所持栈空间以及信号处理方面的初始化。
初始化完成后,如果有起始函数,那么该M的起始函数就会执行。如果这个起始函数代表的是系统监控任务,那么该M会一直执行它,而不会继续后面的流程。
起始函数执行完毕以后,当前M会与那个预联的P完成关联,也就是将nextp
字段的值赋给p
字段。
然后M会依次在多处寻找可运行的G并运行之。这也是调度的一部分。
M的停止
运行时系统管辖的M(runtime.allm
中的M)有时也会停止。比如在运行时系统执行垃圾回收任务的过程中。
运行时系统停止M后会将它放入调度器的空闲M列表(runtime.sched.midle
)中。这很重要,因为当需要一个未被使用的M时,运行时系统会首先尝试从该列表中获取。M是否空闲,仅以它是否存在于调度器的空M列表中为依据。
M的限制
go程序运行前会先启动一个引导程序,用来为程序运行建立必要的环境。引导程序在初始化调度器时,会对M的最大数量进行初始化,初始值是1000。也就是说最多能创建一万个M为当前的go程序服务。
同时runtime/debug
包的SetMaxThreads
函数可以用来修改这一限制。该函数会返回旧的M数量的最大值。需要注意的是,如果你给定的新值比当时已有的M的数量小,运行时系统会立即引发一个运行时恐慌。所以调用这个函数一定要慎重,而且如果真的有必要,那么越早调用越好。因为调整的过程中会损耗部分性能。
P
P是goroutine的上下文环境,也是G能在M中运行的关键。go运行时系统会适时的让P与不同的M建立或断开连接,以使P中那些可运行的G能即使获得运行时机。
在go中,P也是一个结构体。
type p struct { lock mutex //互斥锁 status uint32 //P的状态 m muintptr //和当前P绑定的M runqhead uint32 //可运行G队列的队头 runqtail uint32 //可运行G队列的队尾 runq [256]guintptr //可运行G队列,固定长度为256 runnext guintptr //下一个要运行的G gfree *g //自由G队列 gfreecnt int32 //自由G队列长度
}
上面只是p
的部分字段,后面你会看到他们都有什么用。
设置P的最大数量
go提供了两种方法来设置程序拥有的P的最大数量:
-
调用
runtime.GOMAXPROCS
函数并将新值作为参数传入。该函数会返回旧值。 -
在go程序运行前,设置环境变量
GOMAXPROCS
的值。
还记得前面的引导程序吗?它在初始化调度器时,也会设置P的最大数量。默认与CPU核心数相同。但是如果设置了GOMAXPROCS
环境变量,引导程序就会在检查该环境变量的有效性后将P的最大数量设置为该值。有效的最大P数量必须大于0,小于等于256。256是硬性的上限值,因为go目前无法保证在多于256个P同时存在的情况下,go程序还能保持高效。这个值不是永久的,也许将来会提高。P的最大值的作用是限制并发G的规模。
一个G在被启用后,会先被追加到某个P的可运行G队列中,以等待运行时机。当M运行的G进入系统调用而阻塞时,运行时系统会将该M和与之关联的P分离开。如果这个P的可运行G队列中还有未被运行的G,那么运行时系统会找一个空闲的M或创建一个新的M,并与该P关联,以运行这些G。因此M的数量常常多于P,从他们的最大值的限制上也能看出这一点。
对runtime.GOMAXPROCS
函数的调用会会暂时让所有P都脱离运行状态,并试图阻止任何用户G的运行。新的P最大数量设置完毕后,运行时系统才开始陆续恢复它们。这对程序性能是非常大的损耗。因此最好不要去调用它,万不得已的时候也也该尽量在main
函数的最前面去调用。
然后运行时系统根据新的P的最大值调整全局P列表(runtime.allp
)。与全局M列表类似,该列表包含了当前运行时系统创建的所有P。运行时系统会把这些P中的可运行G全部移入调度器的可运行G队列中。这是调整全局P列表的重要前提。被转移的G会在之后的调度中再次放入某个P的可运行队列中。
当一个P不再与任何M关联并且该P的可运行G列表为空时,运行时系统就会把它放入调度器的空闲P列表(runtime.sched.pidle
)。当运行时系统需要一个空闲M的时候也会首先从此列表中获取。
P的状态:
-
Pidle
:空闲状态,表明当前P未与任何M关联。 -
Prunning
:表明当前P正在与某个M关联。 -
Psyscall
:表明当前P中运行的那个G正在进行系统调用。 -
Pgcstop
:表明运行时系统需要停止调度。例如,运行时系统在开始垃圾回收的某些步骤之前,就会试图把全局P列表中的所有P都置于此状态。 -
Pdead
:表明当前P已经不会再被使用。如果go程序运行过程中,调用runtime.GOMAXPROCS
函数减少了P最大数量,那么多余的P就会被运行时系统置于此状态。此状态的P将被销毁。
P在创建之初的状态是Pgcstop
,但这并不意味着运行时系统要进行垃圾回收。P出于这一状态的时间会非常短暂,在紧接着的初始化后,运行时系统会将其状态设置为Pidle
并放入调度器的空闲P列表。
非Pdead
状态的P在运行时系统停止调度时都会被置于Pgcstop
状态。重启调度时(如垃圾回收结束后),所有P都会被置于Pidle
状态,而不是他们原来的状态。
非Pgcstop
状态的P都会因最大P数量的减小而被认为是多于的,并被置于Pdead
状态。当P进入Pdead
状态之前,该P的可运行G队列会被转移到调度器的可运行G队列,它的自由G列表会被转移到调度器的自由G列表。
每个P中有一个可运行的G队列,以及一个自由G列表。自由G列表中包含了已运行完成的G。随着已运行完成的G越来越多,该列表会不断增长。如果它增长到一定程度,运行时系统会把其中部分G转移到调度器的自由G列表。同样,当调度器发现其中的自由G太少时,会预先尝试从调度器的自由G列表中转移一些G过来。
当使用go语句启用一个G时,运行时系统会先从相应P的自由G列表中获取一个G来封装这个go语句的函数。仅当获取不到的时候,也就是调度器的自由G列表也空了,才会创建一个新的G。
G
一个G代表一个goroutine,也与go函数相对应。如你所想,G也是一个结构体,其中封装了一个go函数。
go编译器会把go语句变成对内部函数newProc
的调用,并把go函数及其参数作为参数传递给newProc
函数。它的作用就是创建一个G。该函数的调用会触发下面这一系列事情的发生:
-
检查go函数及其参数的合法性;
-
从本地P的自由G列表和调度器的自由G列表获取可用的G,如果没有就新建一个G;
-
新建的G会在第一时间加入到运行时系统的全局G列表(
runtime.allgs
),该列表用于存放当前运行时系统中所有G的指针; -
无论用于封装当前go函数的G是否是新的,运行时系统都会对它进行一次初始化,包括关联go函数以及设置G的状态和ID等;
-
初始化完成后,这个G会立即被存储到本地P的
runnext
字段中。该字段用于存放新鲜出炉的G,以求尽早运行它; -
如果
runnext
字段中已有一个G,那么这个已有的G会被移到该P的可运行G队列的末尾。如果该队列已满,就移到调度器的可运行G队列。
G的状态:
-
Gidle
:当前G刚被分配,但还未初始化。 -
Grunnable
:当前G正在可运行队列中等待运行。 -
Grunning
:当前G正在运行。 -
Gsyscall
:当前G正在执行系统调用。 -
Gwaiting
:当前G正在阻塞。 -
Gdead
:当前G正在闲置。 -
Gcopystack
:当前G的栈正在被移动,原因可能是栈的扩展或收缩。 -
Gidle
: -
Gscanrunnable
:当前G正等待运行,同时它的栈正被扫描。扫描原因一般是GC(垃圾回收)任务的执行。 -
Gscanrunning
:当前G正在运行,同时它的栈正被扫描。 -
Gscansyscall
:当前G正在执行系统调用,同时它的栈正被扫描。 -
Gscanwaiting
:当前G正在阻塞,同时它的栈正被扫描。
-
一个G在创建之初是
Gidle
状态。只有被初始化之后,其状态才变成Grunnable
。一个G真正开始被使用是在其状态设置为Grunnabel
之后。 -
一个G在运行过程中是否会等待某个事件以及等待什么事件,完全由其封装的go函数决定。涉及通道操作,网络I/O以及操纵定时器和调用
time.sleep
函数会使G进入Gwaiting
状态。 -
事件到来之后,等待的G会被唤醒,并置于
Grunnable
状态,等待运行。 -
G在退出系统调用时,运行时系统会首先尝试直接运行这个G。仅当无法直接运行时,才会把它转换为
Grunnable
状态并放入调度器的可运行G队列。那么为什么不是放入本地P的可运行G队列呢?因为在G进入系统调用之后,本地P就与当前M分离开了。当G退出系统调用时,本地P已经不在了,也就是说这个G没有本地P,所以只能让调度器去接纳它了。 -
进入死亡状态(
Gdead
)的G会被放入本地P或调度器的自由G列表,可以在需要的时候重新初始化并使用。相比之下,P在进入死亡状态(Pdead
)之后,只能面临销毁的结局。同样是“死”,但一个去往轮回,一个魂飞魄散。
M、P、G的容器
名称 | 源码 | 作用域 | 说明 |
---|---|---|---|
全局M列表 | runtime.allm | 运行时系统 | 存放所有M的一个单向链表 |
全局P列表 | runtime.allp | 运行时系统 | 存放所有P的一个数组 |
全局G列表 | runtime.allgs | 运行时系统 | 存放所有G的一个切片 |
调度器的空闲M列表 | runtime.sched.midle | 调度器 | 存放空闲M的一个单向链表 |
调度器的空闲P列表 | runtime.sched.pidle | 调度器 | 存放空闲P的一个单向链表 |
调度器的可运行G队列 | runtime.sched.runqhead | 调度器 | 存放可运行G的一个队列 |
runtime.sched.runqtail | 调度器 | ||
调度器的自由G列表 | runtime.sched.gfreeStack | 调度器 | 存放自由G的一个单向链表 |
runtime.sched.gNoStack | 调度器 | 存放自由G的一个单向链表 | |
P的可运行G队列 | runtime.p.runq | 本地P | 存放当前P中可运行G的一个队列,长度为256 |
P的自由G列表 | runtime.p.gfree | 本地P | 存放当前P中自由G的一个单向链表 |
调度器和P都有可运行的G队列,它们拥有几乎平等的运行机会。
可运行G的去向:
-
任何G都会存在于全局G列表中。
-
从
Gsyscall
状态转出的G都会被放入调度器的可运行G队列。 -
刚被运行时系统初始化的G都会被放入本地P的可运行G队列。
-
从
Gwaiting
状态转出的G,有的会被放入本地P的可运行G队列,有的会被放入调度器的可运行G队列,还有的会直接运行(刚进行完网络I/O的G)。 -
调用
runtime.GOMAXPROCS
函数后调度器会把多于的P的可运行G队列中的G全部转移到调度器的可运行G队列。 -
本地P的可运行G队列已满时,其中一半的G都会被转移到调度器的可运行G队列中。注意P的可运行G队列长度固定为256。
调度器的可运行G队列有两个变量,runqhead
代表队列头,runqtail
代表队列尾。已入队的G总是从头部取走,一般情况下,新的G会追加到队列尾,但有时也会插入到队列头部,例如runtime.GOMAXPROCS
函数的调用就间接执行了此操作。
自由G的去向:
-
一个G转入
Gdead
状态后,首先会被放入本地P的自由G列表。 -
运行时系统在需要空闲的G来封装go函数时,会先尝试从本地P的自由G列表中获取。如果本地P的自由G列表为空,运行时系统会先从调度器的自由G列表中转移一些过来。
-
本地P的自由G列表已满时,运行时系统也会转移一些到调度器的自由G列表。
-
调度器有连个自由G列表,区别其中存放的G是否有栈。在把G放入自由G列表之前,运行时系统会检查该G的栈空间是否为初始大小。如果不是,就释放掉,让该G变成无栈的,以节约资源。而从自由G列表取出G后,运行时系统会检查它是否拥有栈,如果没有就初始化一个新的栈给它。
-
自由G列表的数据结构本质是栈,所以所有的自由G列表都是先进后出的。
与自由G列表对应的是调度器的空闲M列表和空闲P列表。没错,它们的数据结构也是栈,所以调度器的空闲M列表和空闲P列表也是先进后出的。它们用来存放暂时不用的M和P,等到需要时会先从这里获取。
本集的主角是M、P、G,但是“运行时系统”和“调度器”这两个概念又贯穿全篇,却也没有丝毫对他们的解释。其实它们是同一个东西。当同一个东西有了两幅面具,往往让人迷惑。你可以这样理解:完成调度需要数据和函数,它们分别从不同的角度描述了同一个功能。
更多推荐
地鼠宝宝的轶事奇闻之线程模型
发布评论