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