Go并发机制
在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。Go的独立控制流不是内核级线程而是goroutine协程。Go不推荐用共享内存的方式传递数据,而推荐使用channel(或称“通道”)。channel主要用来在多个goroutine之间传递数据,并且还会保证整个过程的并发安全性。
下面我们聊一聊go的线程模型(MPG模型)和并发机制:
说起Go的线程实现模型,有3个必知的核心元素,它们支撑起了这个模型的主框架,简要说明如下。
M :machine的缩写。一个M代表一个内核线程,或称“工作线程”。
P :processor的缩写。一个P代表执行一个goroutine和Go代码片段(函数)所必需的资源(或称“上下文环境”)。
G :goroutine的缩写。一个G代表一个用户级线程,它由go程序调度而不由内核调度。
首先一个内核级线程M会与一个或多个上下文环境P关联,每个P都会包含一个可运行的G的队列(runq),因此一个P下会有多个G排队运行。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。我把运行当前G的那个M称为“当前M”,并把与当前M关联的那个P称为“本地P”。因此一个M会包含和管理多个G。
M、P和G之间的联系如图所示
M与KSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。Go的运行时系统(runtime system)用M代表一个内核调度实体。M与KSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。
M与P之间也总是一对一的(在运行过程中M可能会从本来的关联P1变为关联P2,但同一时刻一个M总是只关联一个P),而P与G之间则是一对多的关系(这些G被放在了P的一个队列中)。所以M和G是一对多关系,但是同一时刻一个M只能运行一个G。M、P和G之间的关系在实际调度过程中是多变的。
Go如何控制M对G的调度是由go的运行时系统(runtime system)决定的。运行时系统就是goroutine的调度器,它的代码在go的runtime包中,充当着类似内核的作用。
M
一个M在go中本质是一个结构体,它代表了一个内核线程(但本质上M本身不是内核线程而是用户线程,是go封装好的一个结构体变量)。在大多数情况下,创建一个M,都是由于没有足够的M来关联P去运行底下大量的G。除此之外,在运行时系统执行系统监控或垃圾回收等任务的时候,也会导致新M的创建。M的部分结构如图所示
g0 表示一个特殊的goroutine。这个goroutine是Go运行时系统在启动之初创建的,用于执行一些运行时任务。
mstartfn 代表的是用于在新的M上启动某个特殊任务的函数,这些任务可能是系统监控、GC辅助或M自旋。
curg 存放当前M正在运行的那个G的指针
p 指向与当前M相关联的那个P。mstartfn 、curg 和p 最能体现当前M的即时情况。
nextp 用于暂存与当前M有潜在关联的P(又叫做预联P指针)。调度器将某个P赋给某个M的nextp字段的操作,称为对M和P的预联。运行时系统有时候会把刚刚重新启用的M和已与它预联的那个P关联在一起,这也是nextp字段的主要作用。
spinning 是bool 类型的,它用于表示这个M是否正在寻找可运行的G。在寻找过程中,M会处于自旋状态(该状态下的M会进行运算消耗CPU资源)。Go运行时系统可以把一个M和一个G锁定在一起。一旦锁定,这个M就只能运行这个G,这个G也只能由该M运行。标准库代码包runtime中的函数LockOSThread 和UnlockOSThread ,也为我们提供了锁定和解锁的具体方法。
lockedg 表示的就是与当前M锁定的那个G。
M在创建之初,会被加入全局的M列表(runtime.allm )中。这时,它的起始函数和预联的P也会被设置。M被创建之后,Go运行时系统会先对它进行初始化,然后执行起始函数(起始函数仅当运行时系统要用此M执行系统监控或垃圾回收等任务的时候才会被设置,因此有些M是没有起始函数的。如果这个起始函数代表的是系统监控任务的话,那么该M会一直执行它,而不会继续后面的流程。否则,在起始函数执行完毕之后,当前M将会与那个预联的P完成关联)。之后,运行时系统会为这个M这个结构体专门创建一个新的内核线程KSE与之相关联,和内核线程关联后,M就开始寻找可运行的G去运行。
M(或者说runtime.allm 中的M)有时候也会被停止,比如在运行时系统执行垃圾回收任务的过程中。运行时系统在停止M的时候,会把它放入调度器的空闲M列表(runtime.sched.midle )。在需要一个未被使用的M时,运行时系统会先尝试从该列表中获取。M是否空闲,仅以它是否存在于调度器的空闲M列表中为依据。
单个Go程序所使用的M的最大数量是可以设置的。M的最大数量默认是10 000。也就是说,一个Go程序最多可以使用10 000个M。这就意味着,最多可以有10 000个内核线程服务于当前的Go程序。但是由于内存大小的限制,和调度的效率问题,根本不可能创建这么多个M,顶多创建几十个就封顶了,几乎不会被M的最大数量限制。
调用标准库代码包runtime/debug中的SetMaxThreads 函数,并提供新的M最大数量,但一般不会用到。如果调用runtime/debug.SetMaxThreads 函数时给定的新值比当前已创建的 M数量还要小,运行时系统就会立即引发一个panic。
P
P是G能在M运行的关键,Go的运行时系统会适时地让P与不同的M建立或断开关联,这与操作系统内核在CPU之上实时的切换不同的进程或线程的情形类似。改变单个Go程序间接拥有的P的最大数量有两种方法。第一种方法,调用函数runtime.GOMAXPROCS 并把想要设定的数量作为参数传入。第二种方法,在Go程序运行前设置环境变量GOMAXPROCS 的值。
GOMAXPROCS 可以间接限制G的数量规模或者说并发的规模,其实GOMAXPROCS 既是P的最大数量,也是go程序并发时能使用到的最大CPU核数。
当M1因系统调用而阻塞(更确切地说,是它运行的某个G进入了系统调用)的时候,运行时系统会把该M1和与之关联的P分离开来然后找到一个空闲M2或创建一个新的M2去运行P中其他的G(G阻塞导致其所在的M1阻塞,这个M1在阻塞过程是无法运行其他G的。)。因此,M的数量在很多时候也都会比P多。而G的数量取决于你在代码调用了多少个go关键字的函数。
在go程序初始化的时候,GOMAXPROCS 一般都会默认被设置为和CPU的总核数相同。除非我们设置了环境变量GOMAXPROCS 大于0或者显式的调用了runtime. GOMAXPROCS(n)的方法强制设置了GOMAXPROCS 值。但这个值最大不能超过256,如果超过了那么系统认为就是256。
另外尽可能不要在程序运行的时候调用runtime. GOMAXPROCS (n)而应该是在开始运行我们的逻辑任务之前就调用,因为该函数的执行会暂时让所有的P和G都脱离运行状态。只有在新的P最大数量设定完成之后,运行时系统才开始陆续恢复它们。这对于程序的性能是非常大的损耗。
另外如果程序中只有1个P,并不意味着他不能并发,他依旧能够进行单核的并发。
G
一个G就代表一个goroutine(或称Go例程),也与go函数相对应。作为编程人员,我们只是使用go语句向Go的运行时系统提交了一个并发任务,而Go的运行时系统则会按照我们的要求并发地执行它。
一个G运行完成之后不会被马上销毁,而是会被存放到一个空闲G队列中,当有新的go函数需要并发运行时就会从空闲G队列取出一个G,并将这个go函数放到这个G中再将这个G关联一个M进行运行。
一个G可能不会一直都存放到一个P的G队列中,它也可能在运行过程中或运行结束时被放到运行时系统的G队列,然后又流转到其他的P的G队列中。
另外我们需要知道,go程序的任务除了并发运行众多G之外,还会进行一些其他重要的任务,如垃圾回收和系统监测。
go程序在运行的过程中随时可能进行垃圾回收,而一旦开始准备垃圾回收,所有的G都会暂停运行,垃圾回收就开始了。垃圾回收完成之后会陆续恢复G的运行。
系统监测任务是持续执行的;更确切地说,它处在无尽的循环之中。
当代码中执行到 “go 函数名()”的时候,这个任务函数不会马上运行,运行时系统会用一个G封装go函数,对这个G进行初始化。一旦该G准备就绪,其状态就会被设置成Grunnable ,进入可运行的G队列。也就是说,一个G真正开始被使用是在其状态设置为Grunnable 之后。
当G在运行过程中遇到了需要等待的情况,比如运行到一个接收或者发送channel的操作且被阻塞时、涉及网络I/O、操纵定时器(time.Timer)和调用time.Sleep的时候,这个G会进入到Gwaiting状态,不过这些情况下的G阻塞不会导致M阻塞。如果时G在进行系统调用syscall而发生阻塞的话才会真的阻塞内核线程M,此时G会进入Gsyscall状态而不是Gwaiting状态(关于G的各种阻塞情况以及什么样的G阻塞情况会导致M阻塞后面会再提)。在事件就绪后,G会被“唤醒”并被转换至Grunnable 状态。
最后,G运行结束之后(return)会进入结束状态(Gdead ),Gdead的G不会被销毁回收而是会被放入本地P的自由G列表(所谓的自由是指空闲的G)或调度器的自由G列表等着被重新初始化并使用,当然如果自由列表中的G长时间都没能重新被使用还是会被销毁的。相比之下,P在进入死亡状态(Pdead)之后,就只能面临销毁的结局。
从上面的描述我们知道,一个队列中的不同G是可以在不同的M中执行,也就是说G和M是动态关联的。但是我们也可以锁定一个G和一个M,这意味着这个G只能在这一个M中运行。我们可以通过调用runtime.LockOSThread 函数,把当前的G与当时运行它的那个M锁定在一起,也可以通过调用runtime.UnlockOSThread 函数解除当前G与某个M的锁定。一个M只能与一个G锁定,如果多次调用runtime.LockOSThread 函数,那么仅有最后一次调用是有效的。另一方面,即使当前的G没有与任何M锁定,调用runtime.UnlockOSThread 函数也不会产生任何副作用。当一个M与一个G锁定时,M就不会再去运行其他的G,这会造成一定程度的资源浪费。当然啦,绝大部分情况我们无需一个M绑定一个G,除非是写CGO的时候,这里不对其进行展开。
MPG的容器(各种放置MPG的队列)
中文名称 |
源码中的名称 |
作用域 |
简要说明 |
全局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.gfreeNoStack |
|
|
|
P的可运行G队列 |
runtime.p.runq |
本地P |
存放当前P中的可运行G的一个队列 |
P的自由G列表 |
runtime.p.gfree |
本地P |
存放当前P中的自由G的一个单向链表 |
PS:所谓的自由是指空闲的G
3个全局容器存在的主要目的,都是为了罗列全部的M/P/G。相比之下,最应该值得我们关注的是那些非全局的容器,尤其是与G相关的那4个非全局容器:调度器的可运行G队列、调度器的自由G列表、本地P的可运行G队列,以及本地P的自由G列表。
两个可运行G列表中的G都拥有几乎平等的运行机会。由于这种平等性的存在,我们无需关心哪些可运行的G会进入哪个队列。不过顺便提一下,从Gsyscall 状态转出的G都会被放入调度器的可运行G队列,而刚被运行时系统初始化的G都会被放入本地P的可运行G队列(当然这不重要,我们只要知道它是放到可运行队列就行,不用管是放到P的可运行G队列还是调度器的可运行G队列)。至于从Gwaiting 状态转出的G,有的会被放入本地P的可运行G队列,有的会被放入调度器的可运行G队列,还有的会被直接运行(刚进行完网络I/O的G就是这样)。此外,这两个可运行G队列之间也会互相转移G。
在G转入Gdead 状态之后,首先会被放入本地P的自由G列表,而在运行时系统需要用自由的G封装go 函数的时候,也会先尝试从本地P的自由G列表中获取。如果本地P的自由G列表空了,那么运行时系统就会先从调度器的自由G列表转移一部分G到前者中。而当本地P的自由G列表已满,运行时系统也会把前者中的自由G转移一些给调度器的自由G列表。
MPG的状态
M是没有状态的,或者说go语言中没有为M做专门的状态描述,但我们可以主观的认为M的状态为运行中、阻塞中和空闲。
P则是有状态,Go为P的每种状态都有一个单词进行描述:
Pidle 。空闲状态的P, 此状态表明当前P未与任何M存在关联(说明其下的所有G都没有在运行)。
Prunning 。运行中的P, 此状态表明当前P正在与某个M关联。
Psyscall 。此状态表明当前P中的运行的某个G正在进行系统调用(正在系统调用说明这个G正在阻塞其所在的M)。
Pgcstop 。停止调度的P,此状态表明运行时系统需要停止对这个P的调度。例如,运行时系统在开始垃圾回收的某些步骤前,就会试图把全局P列表中的所有P都置于此状态。
Pdead 。已废弃的P,此状态表明当前P已经不会再被使用。如果在Go程序运行的过程中,通过调用runtime.GOMAXPROCS 函数减少了P的最大数量,那么多余的P就会被运行时系统置于此状态。
P在创建之初的状态是Pgcstop ,虽然这并不意味着运行时系统要在这时进行垃圾回收。不过,P处于这一初始状态的时间会非常短暂。在紧接着的初始化之后,运行时系统会将其状态设置为Pidle ,并放入调度器的空闲P列表。
下图是P各个状态之间进行流转的具体情况。
非Pdead 状态的P都会在运行时系统(runtime)欲停止调度时(如垃圾回收)被置于Pgcstop 状态。不过,等到需要重启调度的时候,它们并不会被恢复至原有状态(例如Prunning状态),而会被统一地转换为Pidle 空闲状态,被放入到空闲P列表中。
当减小GOMAXPROCS时,全局P列表会缩小,超出P列表容量的P会被置于Pdead 状态。不过,我们并不用担心其中的G会失去归宿。因为,在P被转换为Pdead 状态之前,这些P中的可运行G队列和自由G列表中的G都会被转移到调度器的可运行G队列和自由G列表中。
G也是有被go赋予状态的:
Gidle 。表示当前G刚被新分配,但还未初始化。
Grunnable 。表示当前G正在可运行队列中等待运行。
Grunning 。表示当前G正在运行。
Gsyscall 。表示当前G正在执行某个系统调用(如果是阻塞的系统调用,G会阻塞所在的M)。
Gwaiting 。表示当前G正在等待事件的到来但并不会阻塞线程。
Gdead 。表示当前G正在闲置(G在运行完go关键字指定的函数之后不会被销毁,而是进入Gdead闲置状态)。
Gcopystack 。表示当前G的栈正被移动,移动的原因可能是栈的扩展或收缩。