楔子展开目录
读《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)状态:
- 如果一个工作线程(P)的本地队列、全局运行队列或网络轮询器中均没有可调度的任务(G),则该线程成为自旋线程;
- 满足该条件、被复始的线程也被称为自旋线程,对于这种线程,运行时不做任何事情。
自旋线程在进行暂止之前,会尝试从任务队列中寻找任务。当发现任务时,则会切换成非自旋状态, 开始执行 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 就处于此状态)
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 完成了运行
正在被初始化进行中的 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 列表中。
本文标题:Golang Runtime 并发调度
本文连接:https://blog.dextercai.com/archives/155.html
除另行说明,本站文字内容采用创作共用版权 CC-BY-NC-ND 4.0 许可协议,版权归本人所有。
除另行说明,本站图片内容版权归本人所有,未经许可前,严禁以任何形式的使用。
即日起视情况关闭全站评论区,您可以通过关于页面的电邮地址和我取得联系,谢谢