MENU

Golang Runtime 并发调度

September 15, 2021 • 学习

楔子

读《Go语言学习笔记》下卷

粗读一遍,记录下值得所关注以及记忆的内容。较少涉及汇编内容。

本文整理过程中可能包含了不同版本的源码,1.5.1、1.16。

并发调度

上文中,我们提到过Worker Thread(M)、GoRoutine(G)、Logical Process(P)

  • G: Goroutine,使用 go 关键字创建的执行体;
  • M: Machine,或 Worker Thread,即传统意义上进程的线程;
  • P: Logical Process,即一种人为抽象的、用于执行 Go 代码被要求局部资源。只有当 M 与一个 P 关联后才能执行 Go 代码。除非 M 发生阻塞或在进行系统调用时间过长时,没有与之关联的 P。

目前的 Go 的调度器实现中设计了工作线程的自旋(spinning)状态:

  1. 如果一个工作线程(P)的本地队列、全局运行队列或网络轮询器中均没有可调度的任务(G),则该线程成为自旋线程;
  2. 满足该条件、被复始的线程也被称为自旋线程,对于这种线程,运行时不做任何事情。

自旋线程在进行暂止之前,会尝试从任务队列中寻找任务。当发现任务时,则会切换成非自旋状态, 开始执行 Goroutine。而找到不到任务时,则进行暂止。

当一个 Goroutine 准备就绪时,会首先检查自旋线程的数量,而不是去复始一个新的线程。

如果最后一个自旋线程发现工作并且停止自旋时,则复始一个新的自旋线程。 这个方法消除了不合理的线程复始峰值,且同时保证最终的最大 CPU 并行度利用率。

  如果存在空闲的 P,且存在暂止的 M,并就绪 G
          +------+
          v      |
执行 --> 自旋 --> 暂止
 ^        |
 +--------+
  如果发现工作

M 的结构

M 是 OS 线程的实体。我们介绍几个比较重要的字段,包括:

  • 持有用于执行调度器的 g0
  • 持有用于信号处理的 gsignal
  • 持有线程本地存储 tls
  • 持有当前正在运行的G的指针 curg
  • 持有运行 Goroutine 时需要的本地资源 p
  • 表示自身的自旋和非自旋状态 spining
  • 管理在它身上执行的 cgo 调用
  • 将自己与其他的 M 进行串联
  • 持有当前线程上进行内存分配的本地缓存 mcache

等等其他五十多个字段,包括关于 M 的一些调度统计、调试信息等。

M可以运行两种代码:

  • Go代码, 即GoRoutine, M运行Go代码需要一个P
  • 原生代码,例如阻塞的syscall,M运行原生代码不需要P

M会从运行队列中取出G, 然后运行G,如果G运行完毕或者进入休眠状态,则从运行队列中取出下一个G运行,周而复始。
有时候G需要调用一些无法避免阻塞的原生代码, 这时M会释放持有的P并进入阻塞状态, 其他M会取得这个P并继续运行队列中的G。
go需要保证有足够的M可以运行G,不让CPU闲着,也需要保证M的数量不能过多。通常创建一个M的原因是由于没有足够的M来关联P并运行其中可运行的G。而且运行时系统执行系统监控的时候,或者GC的时候也会创建M。

M并没有像G和P一样的状态标记,但可以认为一个M有以下的状态:

  • 自旋中(spinning): M正在从运行队列获取G,这时候M会拥有一个P
  • 执行go代码中: M正在执行go代码,这时候M会拥有一个P
  • 执行原生代码中: M正在执行原生代码或者阻塞的syscall,这时M并不拥有P
  • 休眠中: M发现无待运行的G时会进入休眠,并添加到空闲M链表中,这时M并不拥有P

自旋这个状态非常重要,是否需要唤醒或者创建新的M取决于当前自旋中的M的数量。

M在被创建之初会被加入到全局的M列表runtime.allm。接着,M的起始函数mstartfn和准备关联的P都会被设置。最后,运行时系统会为M专门创建一个新的内核线程并与之关联。这时候这个新的M就为执行G做好了准备。其中起始函数mstartfn仅当运行时系统要用此M执行系统监控或者垃圾回收等任务的时候才会被设置。全局M列表的作用是运行时系统在需要的时候会通过它获取到所有的M的信息,同时防止M被gc。

在新的M被创建后先做一番初始化工作。其中包括了对自身所持的栈空间以及信号做处理的初始化。在上述初始化完成后 mstartfn函数就会被执行 (如果存在的话)。

如果mstartfn 代表的是系统监控任务的话,那么该M会一直在执行mstartfn 而不会有后续的流程。否则 mstartfn 执行完后,当前M将会与那个准备与之关联的P完成关联。至此,一个并发执行环境才真正完成。之后就是M开始寻找可运行的G并运行。

运行时系统管辖的M会在GC任务执行的时候被停止,这时候系统会对M的属性做某些必要的重置并把M放置入调度器的空闲M列表。因为在需要一个未被使用的M时,运行时系统会先去这个空闲列表获取M。(只有都没有的时候才会创建M)

M本身是无状态的。M是否有空闲仅以它是否存在于调度器的空闲M列表 runtime.sched.midle 中为依据,空闲列表不是全局列表。

单个Go程序所使用的M的最大数量是可以被设置的。在我们使用命令运行Go程序时候,有一个引导程序先会被启动的。在这引导程序(init)中会为Go程序的运行建立必要的环境。引导程序对M的数量进行初始化设置,默认是最大值 1W

一个Go程序最多可以使用1W个M,即:理想状态下,可以同时有1W个内核线程被同时运行。

可使用 runtime/debug.SetMaxThreads() 函数设置

P 的结构

P 只是处理器的抽象,而非处理器本身,它存在的意义在于实现工作窃取(work stealing)算法。 简单来说,每个P 持有一个 G 的本地队列。

在没有 P 的情况下,所有的 G 只能放在一个全局的队列中。 当 M 执行完 G 而没有 G 可执行时,必须将队列锁住从而取值。

当引入了 P 之后,P 持有 G 的本地队列,而持有 P 的 M 执行完 G 后在 P 本地队列中没有 发现其他 G 可以执行时,虽然仍然会先检查全局队列、网络,但这时增加了一个从其他 P 的 队列偷取(steal)一个 G 来执行的过程。优先级为本地 > 全局 > 网络 > 偷取。

P也可以理解为控制go代码的并行度的机制

如果P的数量等于1,代表当前最多只能有一个线程(M)执行go代码,

如果P的数量等于2,代表当前最多只能有两个线程(M)执行go代码.

执行原生代码的线程数量不受P控制

因为同一时间只有一个线程(M)可以拥有P,所以P中的数据设计为锁自由,从而读写这些数据的效率会非常的高。

P是使G能够在M中运行的关键。Go运行时系统适当地让P与不同的M建立或者断开联系,以使得P中的那些可运行的G能够在需要的时候及时获得运行时机。

在Go程序开始运行时,会先由引导程序对M做了数量上的限制,即对P做了限制,P的数量默认为1。所以我们无论在程序中使用go关键字启用多少goroutine,它们都会被塞到一个P的可运行G队列中。

在确认P的最大数量后,运行时系统会根据这个数值初始化全局的P列表 runtime.allp,类似全局M列表,其中包含了所有 运行时系统创建的所有P。随后,运行时系统会把调度器的可运行G队列runtime.sched.runq中的所有G均匀的放入全局的P列表中的各个P的可执行G队列当中。到这里为止,运行时系统需要用到的所有P都准备就绪了。

类似M的空闲列表,调度器也存在一个P的空闲列表runtime.sched.pidle,当一个P不再与任何M关联的时候,运行时系统就会把该P放入这个列表中,而一个空闲的P关联了某个M之后会被从这个列表中取出。但就算一个P加入了空闲队列,但是它的可运行G队列不一定为空。

和M不同P是有状态的:(五种)

  • Pidle:当前P未和任何M关联
  • Prunning:当前P正在和某个M关联
  • Psyscall:当前P中的被运行的那个G正在进行系统调用
  • Pgcstop:运行时系统正在进行gc。(运行时系统在gc时会试图把全局P列表中的P都处于此状态)
  • Pdead:当前P已经不再被使用。(在调用runtime.GOMAXPROCS减少P的数量时,多余的P就处于此状态)

20181028201552804.png

P的初始状态就是为Pgcstop,处于这个状态很短暂,在初始化和填充P中的G队列之后,运行时系统会将其状态置为Pidle并放入调度器的空闲P列表 runtime.sched.pidle中。其中的P会由调度器根据实际情况进行取用。

除了Pdead之外的其他状态的P都会在运行时系统欲进行GC是被指为Pgcstop。在gc结束后状态不会回复到之前的状态的,而是都统一直接转到了Pidle 。

这意味着,他们都需要被重新调度。

Tips:

  • 除了Pgcstop 状态的P,其他状态的P都会在 调用runtime.GOMAXPROCS 函数去减少P数目时,被认为是多余的P而状态转为Pdead,这时候其带的可运行G的队列中的G都会被转移到 调度器的可运行G队列中,它的自由G队列gfree也是一样被移到调度器的自由列表 runtime.sched.gfree中。
  • 每个P中都有一个可运行G队列及自由G队列。自由G队列包含了很多已经完成的G,随着被运行完成的G的积攒到一定程度后,运行时系统会把其中的部分G转移的调度器的自由G队列 runtime.sched.gfree中。
  • 当我们每次用 go关键字 启用一个G的时候,运行时系统都会先从P的自由G队列获取一个G来封装我们提供的函数 (go 关键字后面的函数) ,如果发现P中的自由G过少时,会从调度器的自由G队列中移一些G过来,只有连调度器的自由G列表都弹尽粮绝的时候,才会去创建新的G。

G 的结构

G 既然是 Goroutine,必然需要定义自身的执行栈。

除了执行栈之外,还有很多与调试和 profiling 相关的字段。 一个 G 没有什么黑魔法,无非是将需要执行的函数参数进行了拷贝,保存了要执行的函数体的入口地址,用于执行。

调度器 sched 结构

调度器,所有 Goroutine 被调度的核心,存放了调度器持有的全局资源,访问这些资源需要持有锁:

  • 管理了能够将 G 和 M 进行绑定的 M 队列
  • 管理了空闲的 P 链表(队列)
  • 管理了 G 的全局队列
  • 管理了可被复用的 G 的全局缓存
  • 管理了 defer 池

特点:

G的新建,休眠,恢复,停止都受到go运行时的管理。
G执行异步操作时会进入休眠状态,待操作完成后再恢复,无需占用系统线程。
G新建或恢复时会添加到运行队列,等待M取出并运行。

Go语言的编译器会把我们编写的go语句编程一个运行时系统的函数调用,并把go语句中函数及其参数都作为参数传递给这个运行时系统函数中。

运行时系统在接到这样一个调用后,会先检查一下go函数及其参数的合法性,紧接着会试图从本地P的自由G队列中(或者调度器的自由G队列)中获取一个可用的自由G (P中有讲述了),如果没有则新创建一个G。类似M和P,G在运行时系统中也有全局的G列表runtime.allg,那些新建的G会先放到这个全局的G列表中,其列表的作用也是集中放置了当前运行时系统中给所有的G的指针。在用自由G封装go的函数时,运行时系统都会对这个G做一次初始化。

初始化:包含了被关联的go关键字后的函数及当前G的状态机G的ID等等。在G被初始化完成后就会被放置到当前本地的P的可运行队列中。只要时机成熟,调度器会立即尽心这个G的调度运行。

G的各种状态:

  • Gidle:G被创建但还未完全被初始化。
  • Grunnable:当前G为可运行的,正在等待被运行。
  • Grunning:当前G正在被运行。
  • Gsyscall:当前G正在被系统调用
  • Gwaiting:当前G正在因某个原因而等待
  • Gdead:当前G完成了运行

20181029003749799.png

正在被初始化进行中的G是处于Grunnable状态的。一个G真正被使用是在状态为Grunnable之后。

若事件到来,那么G在运行过程中,是否等待某个事件以及等待什么样的事件?完全由起封装的go关键字后的函数决定。(如:等待chan中的值、涉及网络I/O、time.Timer、time.Sleep等等事件)

G退出系统调用,及其复杂:运行时系统先会尝试直接运行当前G,仅当无法被运行时才会转成Grunnable并放置入调度器的自由G列表中。

最后,已经是Gdead状态的G是可以被重新初始化并使用的。而对比进入Pdead状态的P等待的命运只有被销毁。处于Gdead的G会被放置到本地P或者调度器的自由G列表中。

Archives QR Code
QR Code for this page
Tipping QR Code