go调度器

GMP模型简介

主要分为3个部分G(goroutine),M(machine), P(process)。

G(goroutine)

goroutine = Golang + Coroutine,是golang实现的的协程,相比较与线程更加轻量,有以下特点。

  1. 相比线程,其启动的代价很小,以很小栈空间启动(2Kb左右)
  2. 能够动态地伸缩栈的大小,最大可以支持到Gb级别
  3. 工作在用户态,切换成很小
  4. 与线程关系是n:m,即可以在n个系统线程上多工调度m个Goroutine
M(machine 系统线程)

抽象化代表内核线程,记录内核线程栈信息,当goroutine调度到线程时,使用该goroutine自己的栈信息。

P(processer 调度器)

代表调度器,负责调度goroutine,维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。
调度器负责协程与线程之间的协作调度,协程与线程是 m:n 的关系。如下图所示
m:n 结构

除此已上结构之外,每个进程都有一个全局的G队列。所以整体排布如下图所示(简化版)。
整体结构

关于 PM 的数量问题

P的数量

由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
由以上原理可知,直接设定P的数量为核心数量,是最好的选择。

PS: 在docker环境下,对可用核心数量的识别错误,会导致 P 的数量暴增,进而导致服务性能降低。

M的数量

M m的数量与 P 的数量无直接关系,go在程序启动的过程中,会默认设定 M 的上限为10000,同样,也可以通过 runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量

MP 的数量上没有任何关系,在一个M被阻塞后,P就会切换或者创建一个M 即使P默认为1,也可能会有很多个M出现。

PM 何时创建

  • 在已经确定了设定好的 P 的最大值后,会直接根据这个个数创建P
  • 当没有足够的 M 关联 P 运行 G 时,(比如 M 被阻塞),首先会寻找是否有空闲的 M,如没有,就会去创建新的 M

细节部分

  • 全局G队列(Global Queue): 存放部分等待运行的 G
  • P 的本地队列: 同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’ 时,G’ 优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
  • P 列表: 所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个,这个值一般为cpu核心数。
  • M: 当M需要运行G时,它会尝试从P的本地队列获取G,如果G的本地队列为空时,M也会选择偷取其他P的本地队列,或从全局队列获取一批G放至G的本地队列。
  • 在当前线程无可运行的 G 时,会通过上述方法获取 G ,如仍未获取到,M 会进入到休眠状态,并放入休眠队列(尽可能完成对线程的复用)
  • 当前线程由于系统调用等原因阻塞时,线程会释放掉绑定的P,并将 P 转移给其他空闲的线程执行。
  • 如上所示,P 的数量是固定的,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。所以,调度器通过限制 P 的数量来限制并发的程度。
  • 在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

    如下是启动一个G后的一个整体流程图。
    整体流程

调度器整体生命周期

调度器整体生命周期

M0P0

M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0

G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 GG0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0M0G0

我们针对如下代码做一个简单分析:

1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("Hello world")
}
  1. runtime 创建最初的线程 M0 和 goroutine G0,并把 2 者关联。
  2. 调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCSP 构成的 P 列表。
  3. 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
  4. 启动 M0M0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
  5. G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
  6. M 运行 G
  7. G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。

调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

Go 调度器调度场景过程全解析

场景1

P 拥有 G1M1 获取 P 后开始运行 G1G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
场景1

场景2

G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。
场景2

场景3

假设每个 P 的本地队列只能存 3 个 GG2 要创建了 6 个 G,前 3 个 GG3, G4, G5)已经加入 P1 的本地队列,P1 本地队列满了。
场景3

场景4

G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)
场景4
这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

场景5

G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。
场景5

场景6

在创建 G 时,运行的 G 会尝试唤醒其他空闲的 PM 组合去执行。

假定 G2 唤醒了 M2M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 GM2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。
场景6

场景7

M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。M2 从全局队列取的 G 数量符合公式:

1
n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。
场景7

场景8

假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。

全局队列已经没有 G,那 M 就要执行 work stealing (偷取):从其他有 GP 哪里偷取一半 G 过来,放到自己的 P 本地队列。P2P1 的本地队列尾部取一半的 G,本例中一半则只有 1 个 G8,放到 P2 的本地队列并执行。
场景8

场景9

G1 本地队列 G5G6 已经被其他 M 偷走并运行完成,当前 M1M2 分别在运行 G2G8M3M4 没有 goroutine 可以运行,M3M4 处于自旋状态,它们不断寻找 goroutine。

为什么要让 M3M4 自旋,自旋本质是在运行,线程在运行却没有执行 G,就变成了浪费 CPU. 为什么不销毁现场,来节约 CPU 资源。因为创建和销毁 CPU 也会浪费时间,我们希望当有新 goroutine 创建时,立刻能有 M 运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程 (当前例子中的 GOMAXPROCS=4,所以一共 4 个 P),多余的没事做线程会让他们休眠。
场景9

场景10

​假定当前除了 M3M4 为自旋线程,还有 M5M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9G8 进行了阻塞的系统调用,M2P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 MP2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 P。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。
场景10

场景11

G8 创建了 G9,假如 G8 进行了非阻塞系统调用。
M2P2 会解绑,但 M2 会记住 P2,然后 G8M2 进入系统调用状态。当 G8M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。
场景11

小结

总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

-------- 本文结束 感谢阅读 --------

本文标题:go调度器

文章作者:zyh

发布时间:2020年04月26日 - 11:25

最后更新:2021年09月16日 - 21:28

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持技术分享,您的支持将鼓励我继续创作!
0%