Go并发编程系列(五) go并发机制之MPG模型-张柏沛IT博客

正文内容

Go并发编程系列(五) go并发机制之MPG模型

栏目:Go语言 系列:Go并发编程系列 发布时间:2021-02-05 11:17 浏览量:193

Go并发机制

在操作系统提供的内核线程之上,Go搭建了一个特有的两级线程模型。Go的独立控制流不是内核级线程而是goroutine协程。Go不推荐用共享内存的方式传递数据,而推荐使用channel(或称“通道”)。channel主要用来在多个goroutine之间传递数据,并且还会保证整个过程的并发安全性。

 

下面我们聊一聊go的线程模型(MPG模型)和并发机制:

说起Go的线程实现模型,有3个必知的核心元素,它们支撑起了这个模型的主框架,简要说明如下。

M machine的缩写。一个M代表一个内核线程,或称工作线程

P processor的缩写。一个P代表执行一个goroutineGo代码片段(函数)所必需的资源(或称上下文环境)。

G goroutine的缩写。一个G代表一个用户级线程,它由go程序调度而不由内核调度。

 

首先一个内核级线程M会与一个或多个上下文环境P关联,每个P都会包含一个可运行的G的队列(runq),因此一个P下会有多个G排队运行。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。我把运行当前G的那个M称为“当前M”,并把与当前M关联的那个P称为“本地P”。因此一个M会包含和管理多个G

MPG之间的联系如图所示

MKSE之间总是一对一的关系,一个M能且仅能代表一个内核线程。Go的运行时系统(runtime system)用M代表一个内核调度实体。MKSE之间的关联非常稳固,一个M在其生命周期内,会且仅会与一个KSE产生关联。

MP之间也总是一对一的(在运行过程中M可能会从本来的关联P1变为关联P2,但同一时刻一个M总是只关联一个P),而PG之间则是一对多的关系(这些G被放在了P的一个队列中)。所以MG是一对多关系,但是同一时刻一个M只能运行一个GMPG之间的关系在实际调度过程中是多变的。

Go如何控制MG的调度是由go的运行时系统(runtime system)决定的。运行时系统就是goroutine的调度器,它的代码在goruntime包中,充当着类似内核的作用。

 

M

一个Mgo中本质是一个结构体,它代表了一个内核线程(但本质上M本身不是内核线程而是用户线程,是go封装好的一个结构体变量)。在大多数情况下,创建一个M,都是由于没有足够的M来关联P去运行底下大量的G。除此之外,在运行时系统执行系统监控或垃圾回收等任务的时候,也会导致新M的创建。M的部分结构如图所示

g0  表示一个特殊的goroutine。这个goroutineGo运行时系统在启动之初创建的,用于执行一些运行时任务。

mstartfn 代表的是用于在新的M上启动某个特殊任务的函数,这些任务可能是系统监控、GC辅助或M自旋。

curg 存放当前M正在运行的那个G的指针

p 指向与当前M相关联的那个Pmstartfn curg p 最能体现当前M的即时情况。

nextp 用于暂存与当前M有潜在关联的P(又叫做预联P指针)调度器将某个P赋给某个Mnextp字段的操作,称为对MP的预联。运行时系统有时候会把刚刚重新启用的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 000M。这就意味着,最多可以有10 000个内核线程服务于当前的Go程序。但是由于内存大小的限制,和调度的效率问题,根本不可能创建这么多个M,顶多创建几十个就封顶了,几乎不会被M的最大数量限制。

 

调用标准库代码包runtime/debug中的SetMaxThreads 函数,并提供新的M最大数量,但一般不会用到。如果调用runtime/debug.SetMaxThreads 函数时给定的新值比当前已创建的 M数量还要小,运行时系统就会立即引发一个panic

 

P

PG能在M运行的关键,Go的运行时系统会适时地让P与不同的M建立或断开关联,这与操作系统内核在CPU之上实时的切换不同的进程或线程的情形类似。改变单个Go程序间接拥有的P的最大数量有两种方法。第一种方法,调用函数runtime.GOMAXPROCS 并把想要设定的数量作为参数传入。第二种方法,在Go程序运行前设置环境变量GOMAXPROCS 的值。

GOMAXPROCS 可以间接限制G的数量规模或者说并发的规模,其实GOMAXPROCS 既是P的最大数量,也是go程序并发时能使用到的最大CPU核数。

M1因系统调用而阻塞(更确切地说,是它运行的某个G进入了系统调用)的时候,运行时系统会把该M1和与之关联的P分离开来然后找到一个空闲M2或创建一个新的M2去运行P中其他的GG阻塞导致其所在的M1阻塞,这个M1在阻塞过程是无法运行其他G的。)。因此,M的数量在很多时候也都会比P多。而G的数量取决于你在代码调用了多少个go关键字的函数。

go程序初始化的时候,GOMAXPROCS 一般都会默认被设置为和CPU的总核数相同。除非我们设置了环境变量GOMAXPROCS 大于0或者显式的调用了runtime. GOMAXPROCS(n)的方法强制设置了GOMAXPROCS 值。但这个值最大不能超过256,如果超过了那么系统认为就是256

另外尽可能不要在程序运行的时候调用runtime. GOMAXPROCS (n)而应该是在开始运行我们的逻辑任务之前就调用,因为该函数的执行会暂时让所有的PG都脱离运行状态。只有在新的P最大数量设定完成之后,运行时系统才开始陆续恢复它们。这对于程序的性能是非常大的损耗。

 

另外如果程序中只有1P,并不意味着他不能并发,他依旧能够进行单核的并发。

 

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 ),GdeadG不会被销毁回收而是会被放入本地P的自由G列表或调度器的自由G列表等着被重新初始化并使用,当然如果自由列表中的G长时间都没能重新被使用还是会被销毁的。相比之下,P在进入死亡状态(Pdead)之后,就只能面临销毁的结局。

 

 

从上面的描述我们知道,一个队列中的不同G是可以在不同的M中执行,也就是说GM是动态关联的。但是我们也可以锁定一个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的一个单向链表

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/OG就是这样)。此外,这两个可运行G队列之间也会互相转移G

 

G转入Gdead 状态之后,首先会被放入本地P的自由G列表,而在运行时系统需要用自由的G封装go 函数的时候,也会先尝试从本地P的自由G列表中获取。如果本地P的自由G列表空了,那么运行时系统就会先从调度器的自由G列表转移一部分G到前者中。而当本地P的自由G列表已满,运行时系统也会把前者中的自由G转移一些给调度器的自由G列表。

 

MPG的状态

M是没有状态的,或者说go语言中没有为M做专门的状态描述,但我们可以主观的认为M的状态为运行中、阻塞中空闲

 

P则是有状态,GoP的每种状态都有一个单词进行描述:

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的栈正被移动,移动的原因可能是栈的扩展或收缩。

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > Go并发编程系列(五) go并发机制之MPG模型

热门推荐
推荐新闻