【系统深入学习GO】Go 的并发机制-原理探究 线程实现模型

在操作系统提供的内核线程之上,Go 搭建了一个特有的两级线程模型

* 两级线程模型:两级线程模型也称为多对多(M:N)的线程实现。与其他模型相比,两级线程模型提供了更求的灵活性。在此模型下,一个进程可以与多个KSE相关联,这与内核级线程模型相似。但与内核级线程模型不同的是,进程中的线程(以下称为应用程序线程)并不与KSE—一对应,这些应用程序线程可以映射到同一个已关联的KSE上。大概就是这个意思,想更了解的小伙伴可以自行深入探究~

在这里插入图片描述

上图为两级线程模型,在用户空间(user space) 与内核空间(kernel space) 中的示例图。

goroutine 这个特有名词是 Go 语言独创的,它代表着可以并发执行的 Go 代码片段。go 的开发者们认为已经存在的线程、协程、进程等术语都传达了错误的含义。为了与它们有所区别,才诞生了 goroutine 这个名词。

那么 goroutine 的含义是什么呢,Go 官方打出的标语是:
“不要用共享内存的方式来通信。作为替代,应该以通信作为手段来共享内存。”

更确切地讲,把数据放在共享内存中以供多个线程访问,这一方式虽然在基本思想上非常简单,却使并发访问控制变得异常复杂。只有做好了各种约束和限制,才有可能让这种看似简单的方法得以正确地实施。但是,正确性往往不是我们唯一想要的,软件系统的可伸缩性也是高优先级的指标。可伸缩性越好,就越能获得计算机硬件(比如多核 CPU)的红利。然而,一些同步方法的使用,让这种红利的获得变得困难了许多。
因此 Go 不推荐共享内存的方式传递数据,而推荐使用 channel (或称 “通道”)。而 Go 的并发机制正是指的用于支撑 goroutinechannel 的底层原理。

线程实现模型

上面稍微讲述了一下 两级线程模型 ,而Go的线程实现模型就没那么简单了,其中有三个必知的核心元素,它们支持起了这个模型的主框架,分别是 M,P,G:

  • M: machine 的缩写。一个 M 代表一个内心线程,或称 “工作线程”
  • P: processor 的缩写。一个 P 代表执行一个 Go 代码片段所必须的资源 (或称 “上下文环境”)
  • G: goroutine 的缩写。一个 G 代表一个 Go 代码片段。前者是后者的一种封装

简单来说,一个 G 的执行需要 P 和 M 的支持。一个M在与一个P关联之后,就形成了一个有效的G运行环境(内核线程+上下文环境)。每个 P 都会包含一个可运行的 G 的队列(rung)。该队列中的G会被依次传递给与本地P关联的M,并获得运行时机。通常我们网上看到的三者宏观图是这样的:

在这里插入图片描述
但实际要比上图展现的复杂得多:

在这里插入图片描述

可以看到,M 与 KSE 之间总是一对一的关系,一个M能且仅能代表一个内核线程。Go 的运行时系统(runtime system)用M代表一个内核调度实体。一个M 在其生命周期内,会且仅会与一个 KSE产生关联。M 与 P 之间也总是一对一的,而 P 与 G 之间则是一对多的关系。此外,M 与 G 之间也会建立关联,因为一个 G 终归会由一个 M来负责运行;它们之间的关联会由P来牵线。

M

一个 M 代表一个内核线程。大多数情况下,创建一个M,都是由于没有足够的 M 来关联 P 并运行其中的可运行的G。M 的声明如下:

type m struct {
	g0      *g     // goroutine with scheduling stack
	morebuf gobuf  // gobuf arg to morestack
	divmod  uint32 // div/mod denominator for arm - known to liblink
	_       uint32 // align next field to 8 bytes

	// Fields not known to debuggers.
	procid        uint64            // for debuggers, but offset not hard-coded
	gsignal       *g                // signal-handling g
	goSigStack    gsignalStack      // Go-allocated signal handling stack
	sigmask       sigset            // storage for saved signal mask
	tls           [tlsSlots]uintptr // thread-local storage (for x86 extern register)
	mstartfn      func()
	curg          *g       // current running goroutine
	caughtsig     guintptr // goroutine running during fatal signal
	p             puintptr // attached p for executing go code (nil if not executing go code)
	nextp         puintptr
	oldp          puintptr // the p that was attached before executing a syscall
	id            int64
	mallocing     int32
	throwing      throwType
	preemptoff    string // if != "", keep curg running on this m
	locks         int32
	dying         int32
	spinning 	  bool
	lockedg	      *g
	...

此声明位于 runtime 包下的 runtime2.go 文件中,可以自行查阅

这里只挑几个字段初步认识一下

  • g0:表示一个特珠的 goroutine。这个 goroutine 是 Go 运行时系统在启动之初创建的,用于执行一些运行时任务。
  • mstartfn:代表的是用于在新的M上启动某个特任务的函数/更具体地说,这些任务可能是系统监控、GC 辅助或M 自旋。
  • curg:此字段会存放当前M正在运行的那个 G 的指针
  • p: 此字段则会指向与当前M相关联的那个 P
  • nextp:用于暂存与当前M有潜在关联的 P,把调度器将某个P赋给某个M的nextp 字段的操作,称对M和P的预联。运行时系统有时候会把刚刚重新启用的M和已与它预联的那个 P 关联在一起,这也是 nextp 字段的主要作用。
  • spinning:用于表示这个M是否正在寻找可运行的 G。在寻找过程中,M 会处于自旋状态。
  • lockedg:表示的就是与当前M 锁定的那个 G(如果有的话)。

M 在创建之初,会被加人全局的M列表(runtime.allm)中。这时,它的起始函数(这里指的是 mstartfn 字段)和预联的 P 也会被设置。最后,运行时系统会这个 M 专门创建一个新的内核线程并与之相关联。如此一来,这个 M 就为执行G做好了准备。

在新 M被创建之后,Go 运行时系统会先对它进行一番初始化,其中包括对自身持的栈空间以及信号处理方面的初始化。在这些初始化工作都完成之后,该M的起始函数会执行(如果存在的话)。

单个Go程序所使用的M的最大数量是可以设置的。Go程序运行的时候会先启动一个引导程序,这个引导程序会为其运行建立必要的环境。在初始化调度器的时候,它会对M 的最大数量进行初始设置,这个初始值是 10000。也就是说,一个 Go 程序最多可以使用 10000 个 M。这就意味着,最多可以有1 0000 个内核线程服务于当前的Go程序。

P

P 是 G 能够在 M 中运行的关键。Go 的运行时系统会适时地让 P 与不同的M建立或断开关联,以使 P 中的那些可运行的 G 能够及时获得运行时机。
改变单个 Go 程序间接拥有的P的最大数量有两种方法。第一种方法,调用函数 runtime.GOMAXPROCS 并把想要设定的数量作为参数传入。第二种方法,在 Go 程序运行前设置环境变量 GOMAXPROCS 的值。P 的最大数量实际上是对程序中并发运行的 G 的规模自一种限制。P 的数量即为可运行 G 的队列的数量。一个 G 在被启用后,会先被追加到某个 P 的可运行 G 队列中,以等待运行时机。一个 P 只有与一个 M 关联在一起,才会使其可运行 G 队列中的 G 有机会运行。
在 Go 程序启动之初,引导理序会在初始化调度器时,对卫的最大数最进行沒置。这里的默认值会与当前 CPU 的总核心数相同。一旦发现环境变量 GOMXPROCS 的值大于 0,引导程序就会认为我们想要对 P 的最大数量进行设置。它会先检查一下此值的有效性:如果不大于预设的硬性上限值(256),就认为是有效的,否则就会被这个硬性上限值取代。也就是说,跟终的 P 最大数值绝不会比这个硬性上限值大。硬性上限值是256,原因是 GO 目前还不能保证在数最比 256 更多的 P 中同时存在的情形下 Go 程序仍能保持高效。

注意,虽然 GO 并未对何时调用 runtime.GOMAXPROCS 函数作限制,但是该函数调用的执行会暂时让所有的P都脱离运行状态,并试图阻止任何用户级别的 G 的运行。只有在新的 P 最大数量设定完成之后,运行时系统才开始陆续恢复它们。这对于程序序的性能退非常大的损耗。所以,你最好只在 Go 程序的 main 函数的最前面调月runtime.GOMXPROCS 函数。当然,不在程序中改变 P 最大数量再好不过了,实际上在大多数情况下也无需改变。

在确定 P 最大数量之后,运行时系统会根据这个数值重整全局的 P 列表(runtime.allp)。该列表中包含了当前运行时系统创建的所有 P。运行时系统会把这些 P 中的可运行 G 全部取出,并放入调度器的可运行 G 队列中。 这是调整全局 P 列表的一个重要前提。被转移的那些 G,会在以后经由调度在吃放入某个 P 的可运行 G 队列。

另外与空闲 M 列表类似,运行时系统中也存在一个调度器的空闲 P 列表(runtime.sched.pidle)。当一个 P 不在与任何 M 关联的时候,运行时系统就会把它放入该列表;当运行时系统需要一个空闲的 P 关联某个 M 的话,会从此列表中取出一个。

注意,P进入空闲 P 列表的一个前提条件是它的可运行 G 列表必须为空。例如,在整个全局 P 列表的时候, P 在被清空可运行 G 列表之后,才会被放入空闲 P 列表。

与 M 不同,P 本身是有状态的:

  • Pidle:此状态北冥当前 P 未与任何 M 存在关联。
  • Prunning:此状态表明当前 P 正在与某个 M 关联。
  • Psyscall:此状态表明当前 P 中的运行的那个 G 正在进行系统调用。
  • Pgcstop:此状态表明运行时系统需要停止调度。例如,运行时系统在开始垃圾回收的某些步骤前,会试图把全局 P 列表中的所有 P 置于此状态。
  • Pdead:此状态表明当前 P 已经不会再被使用。如果在 Go 程序运行的过程中,通过调用 runtime.GOMAXPROCS 函数减少 P 的最大数量,那么多余的 P 就会被运行时系统置于这个状态。

下面是 P 的状态流转图:
在这里插入图片描述

在 P 被转为 Pdead 状态之前,其可运行 G 队列中的 G 都会被转移到调度器的可运行 G 队列,而它的自由 G 列表中的 G 也都会被转移到调度器的自由 G 列表。

每个 P 中除了都有一个可运行 G 列表外,还包含了一个自由 G 列表。这个列表中包含了一些已经运行完成的 G。随着运行完成的 G 的增多,该列表可能会很长。如果它增长到一定程度,运行时系统就会把其中的 G 转移到 调度器的自由 G 列表中。另一个方面,当使用 go 语句欲启动一个 G 的时候,运行时系统会先试图从相应的 P 的自由 G 列表中获取一个现成的 G 来封装这个 go 语句携带的函数,仅当获取不到这样一个新的 G。考虑到由于当前 P 的自由 G 列表为空而获取不到自由 G 的情况,运行时系统会在发现其中的自由 G 太少时,预先尝试从调度器的自由 G 列表中转移过来一些 G。如此依赖,只有在调度器的自由 G 列表也弹尽粮绝的时候,才会有新的 G 被创建。

G

一个 G 就代表一个 goroutine (或称 Go 例程),也与 go 函数相对应。Go 的编译器会把 go 语句变成对内部函数 newproc 的调用,并把 go 函数及其参数都作为参数传递给这个函数。

运行时系统在接到这样一个调用之后,会先检查 g0函数及其参数的合法性,然后试图从本地P的自由G列表和调度器的自由G列表获取可用的G,如果没有获取到,就新建一个G。与M和P相同,运行时系统也持有一个G的全局列表(runtime.allgs)。新建的G会在第一时间被加入该列表。类似地,这个全局列表的主要作用是:集中存放当前运行时系统中的所有G的指针。无论用于封装当前这个 go 函数的 G 是否是新的,运行时系统都会对它进行一次初始化,包括关联 go 函数以及设置该 G 的状态和 ID 等步骤。在初始化完成后,这个 G 会立即被存储到本地P的 runnext 字段中;该字段用于存放新鲜出炉的G,以求更早地运行它。如果这时 runnext 字段已存有一个 G,那么这个已有的 G 就会被“踢到”该 P 的可运行 G 队列的末尾。如果该队列已满,那么这个 G 就只能追加到调度器的可运行 G 队列中了。

每个 G 都会由运行时系统根据其实际情况设置不同的状态,其主要状态如下:

  • Gidle:表示当前 G 刚被新分配,但还未初始化。
  • Grunnable:表示当前 G 正在可运行队列中等待运行。
  • Grunning:表示当前 G 正在运行。
  • Gsyscallo:表示当前 G 正在执行某个系统调用。
  • Gwaiting:表示当前 G 正在阻塞。
  • Gdead:表示当前 G 正在闲置。
  • Gcopystack:表示当前 G 的栈正被移动,移动的原因可能是栈的扩展或收缩。

在运行时系统想用一个G封装g0函数的时候,会先对这个G进行初始化。一旦该G准备就绪,其状态就会被设置成 Grunnable。也就是说,一个G真正开始被使用是在其状态设置次 Grunnable 之后。其生命周期如下:

在这里插入图片描述

根据上图可见 G 进入死亡状态(Gdead) 是可以重新初始化并使用的。相比之下,P 在进入死亡状态(Pdead)之后,就只能面临销毁的结局。由此可以说明 Gdead 状态与 Pdead 状态所表达的含义截然不同,处于 Gdead 状态的 G 会被放入本地 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、 runtime.sched.runqtail调度器存放空闲的 G 的一个队列
调度器的自由 G 列表runtime.sched.gfreeStack runtime.sched.gfreeNoStack调度器存放自由的 G 的两个单向链表
P的可运行 G 列表runtime.p.runq本地P存放当前 P 中的可运行 G 的一个队列
P的自由 G 列表runtime.p.gfree本地P存放当前 P 中的自由 G 中的一个单向链表

上表中最应该值得我们关注的是那些非全局的容器,尤其是与 G 相关的那4个非全局容器。
任何 G 都会存在于全局 G 列表中,而其余的4个容器则只会存放在当前作用域内的、具有某个状态的 G。注意,这里的两个可运行 G 列表中的G都拥有几乎平等的运行机会。由于这种平等性的存在,我们无需关心哪些可运行的 G 会进入哪个队列。不过顺便提一下,从 Gsyscall 状态转出的G都会被放入调度器的可运行 G 队列,而刚被运行时系统初始化的 G 都会被放人本地P的可运行 G 队列。至于从 Gwaiting 状态转出的 G,有的会被放入本地 P 的可运行 G 队列,有的会被放人调度器的可运行G队列,还有的会被直接运行(刚进行完网络 I/O 的 G 就是这样)。此外,这两个可运行 G 队列之间也会互相转移G。例如,调用 runtime.GOMAXPROCS 函数,会导致运行时系统把将死的P的可运行 G 队列中的 G,全部转移到调度器的可运行 G 队列。这也是了重新分配它们。再如,如果本地 P 的可运行 G 队列已满,其中的一半 G 都会被转移到调度器的可运行 G 队列中。

注意,调度器的可运行 G 队列由两个变量代表。变量 runghead 代表队列的头部,而 rungtail 则代表队列的尾部。一般情况下,新的可运行 G 会被追加到队列的尾部,并且已人队的 G 只会从头部取走,这也体现了队列的 FIFO(先进先出)特性。不过,新的可运行 G 有时候也会被插人队列头部,刚刚说的 runtime.GOMAXPROCS 函数调用就间接地执行了此操作。

注意,调度器的自由 G 列表有两个。从变量名上也能看出,它们的区别就是其中存放的自由 G 是有栈的还是无栈的。在把 G 放人自由 G 列表之前,运行时系统会检查该 G 的栈空间是否为初始大小。如果不是,就释放掉它,让该 G 变成无栈的,这主要是为了节约资源。另一方面,在从自由 G 列表取出 G 之后,运行时系统会检查它是否拥有栈,如果没有就初始化一个新的栈给它。顺便说一句,所有的自由 G 列表都是 FILO(先进后出)的。

以上篇幅都是在围绕着 Go 的线程实现模型展开的。上面所一直说的 “运行时系统”,实际上它可以明确的称为 “调度器”。而一个 Go 程序 中只会存在一个调度器实例。

更详细内容,欢迎大家读取书籍《Go并发编程实战 第二版》,一起学习go

  • 19
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值