Go 的并发机制(2)

Go 的并发机制

4.1 原理探究

4.1.2 调度器

两级线程模型中的一部分调度任务会由操作系统内核之外的程序承担,在 Go 语言中,调度器就负责这一部分调度任务,调度的主要对象就是 M、P 和 G 的实例。

1. 基本结构

调度器有自己的数据结构,主要目的就是更加方便地管理和调度各个核心元素的实例。其中包括:空闲 M 列表、空闲 P 列表、可运行 G 队列和自由 G 列表。还有其他几个重要的字段:
在这里插入图片描述
这里需要先介绍一个概念串行运行时任务:在 Go 运行时系统中,一些任务在执行前是需要暂停调度的,比如垃圾回收任务中的某些自任务,以及发起运行时 panic 的任务。

  1. gcwaiting 字段用于表示是否需要停止调度:在停止调度前,该值会被设置为 1;在恢复调度之前,该值会被设置为 0。
  2. 这个和前文提到的 P 的状态有关。一些调度任务执行时,只要发现 gcwaiting 的值是 1,就会把当前 P 的状态置为 Pgcstop,然后自减 stopwait 字段的值。
  3. 如果发现 stopwait 的值为 0,说明所有 P 地状态都已经为 Pgcstop,这时候就可以利用 stopnote 字段,唤醒因等待调度停止而暂停的串行运行时任务了。

同样的,针对系统监测任务,也需要先暂停,然后再执行串行运行时任务。不过它是处于无尽的循环当中的:

  1. 系统监测程序先检查调度情况,如果发现调度停止(gcwaiting 不为 0或所有的 P 已经闲置),就会把 sysmonwait 置为 1,并利用 sysmonnote 暂停自身
  2. 恢复调度之前,调度器如果发现 sysmonwait 不为 0,就会把它置为 0,随后利用 sysmonnote 恢复监测程序;
2. 一轮调度

引导程序会为 Go 程序的运行建立必要的环境,在完成一系列初始化工作之后,Go 程序的 main 函数才会真正执行。引导程序会在最后让调度器进行一轮调度,这样才能让封装了 main 函数的 G 马上有机会运行。

接下来,我们深入了解下调度器在一轮调度中做了什么:
在这里插入图片描述

  1. 调度器先判断当前 M 是否已被锁定。为什么呢?我们知道,Go 的调度器会按照一定策略动态地关联 M、P 和 G,并以此高效地执行并发程序,一般来说并不需要用户程序的任何干预。但是,在极少数情况下,用户程序不得已要对 Go 的运行时调度进行干预。
    **锁定 M 和 G 可以说是为了 CGO 准备的。**因为有些 C 语言的函数库(OpenGL)会用到线程本地存储技术,将数据存储在当前内核线程的私有缓存中。因此,包含了调用此类 C 函数库的代码的 G 会变得特殊:在特定时间只能与同一个 M 产生关联,这样会对调度效率造成负面影响。从上述流程图也可看出,就算锁定了,一定要尽量减少锁定的时间。(锁定:runtime.LockOSThread,解锁 runtime.UnlockOSThread)
  2. 如果发现当前 M 已经和某个 G 锁定了,那么会立即停止调度并停止当前 M。一旦和它锁定的 G 处于可运行状态,它就会被唤醒并继续运行那个 G。
  3. 如果判断当前 M 未与任何 G 锁定,那么调度器会检查是否有运行时串行任务正在等待执行(此类任务执行时需要停止 Go 调度器,STW)。如果 gcwaiting 不为 0,那么会停止并阻塞当前 M 以等待运行时串行任务执行完成。
  4. 否则,开始真正的可运行 G 寻找之旅。一旦找到一个可运行的 G,调度器会在判定该 G 未与任何 M 锁定之后,立即让当前 M 运行它。(至于找可运行的 G,会从容易找到的地方开始:全局的可运行 G 队列,本地 P 地可运行 G队列开始查找,都查找不到,就会进入“全力查找可运行的 G”,详细的后文再说明)
一轮调度的触发
  • 首次启动并使封装 main 函数的那个 G 被调度运行
  • 某个 G 的运行的阻塞、结束、退出系统调用,以及其栈的增长,都会使调度器进行一轮调度
  • 用户程序对某些标准库韩式的调用也会触发
    运行时系统中,几乎所有的 M 都会参与调度任务的执行,它们共同实现了 Go 调度器的调度功能。
3. 全力查找可运行的 G

概括来说,就是会多次尝试从各个地方查找可运行的 G。由 runtime.findrunnable 韩式代表,大致分为2 阶段 10 步骤:

  1. 获取执行终结器的 G:所有的终结函数的执行都会由一个专用的 G 负责,如果这个专用 G 已完成任务,调度器就会获取它,把它置为 Grunable 状态并放入本地 P 地可运行 G 队列
  2. 从本地 P 的可运行 G 队列获取 G:调度器尝试从这里获取一个 G,并返回
  3. 从调度器的可运行 G 队列获取 G:调度器尝试从这里获取一个 G,并返回
  4. 从网络 I/O 轮询器(netpoller)获取 G:如果 netpoller 已经被初始化且有过网络 I/O 操作,那么调度器就会尝试从这里获取一个 G 列表,并且把表头的 G 返回,同时把其他的 G 都放入调度器的可运行 G 队列。如果没有初始化或者没有过网络 I/O 操作,就跳过。这里没有获取成功也不会阻塞。
  5. 从其他 P 地可运行 G 队列获取 G:条件允许时,调度器会使用伪随机算法从全局 P 列表中选取 P,然后尝试从它们的可运行 G 队列中盗取一半的 G 到本地 P 地可运行 G 队列。选取 P 和盗取 G 会重复多次,成功就停止。如果一直没有成功,那么第一阶段结束。
  6. 获取执行 GC 标记任务的 G第二阶段,调度器会先判断是否正处于 GC 的标记阶段,以及本地 P 是否可以用于 GC 标记任务,如果都是 true,就将本地 P 持有的 GC 标记专用 G 置为 Grunnable 并返回
  7. 从调度器的可运行 G 队列获取 G:调度器再次尝试从这里获取一个 G,并返回。如果还是找不到,就会解除本地 P 与当前 M 地关联,并把该 P 放入调度器的空闲 P 列表
  8. 从全局 P 列表中的每个 P 的可运行 G 队列获取 G:遍历全局 P 中的所有 P,检查他们的可运行 G 队列。只要某个 P 的可运行 G 队列不为空,那就从调度器的空闲 P 列表去中一个 P,判定其可用后,将其和当前 M 关联在一起,然后返回第一阶段(第五步),否则继续后续;
  9. 获取执行 GC 标记任务的 G:判断是否正处于 GC 的标记阶段,以及与 GC 标记任务相关的全局资源是否可用,如果都是 true,调度器从空闲 P 列表拿出一个 P。如果这个 P 持有一个 GC 标记专用 G,就关联该 P 与当前 M,然后再次执行第二阶段(从6 开始)
  10. 从网络 I/O 轮询器(netpoller)获取 G:此步骤与步骤4 基本相同,但是这里的获取是阻塞的。只有当 netpoller 那里有可用的 G 时,阻塞才会解除。同样的,如果没有初始化或者没有过网络 I/O 操作,就跳过。

如果经过上述10个步骤之后,还没有找到可运行的 G,调度器就会停止当前 M。后续重新唤醒时,会重新进入查找可运行的 G 的子流程。

这里对一些细节再进行下补充:
第5步的条件:
(1)除了本地 P 还有非空闲的 P,因为空闲的 P 的可运行 G 队列必定为空,如果除了本地 P 之外的所有 P 都是空闲的,就没必要去偷了
(2)当前 M 处于自旋状态,或者处于自旋状态的 M 的数量小于非空闲的 P 的数量的二分之一。主要是为了控制自旋的 M 的数量,过多的自旋 M 会消耗太多 CPU 资源。
**自旋状态:**意味着它还没有找到 G 来运行。一般来说,运行时系统中至少会有一个自旋的 M,调度器也会尽量保证有一个自旋的 M 存在。除非发现没有自旋的 M,调度器是不会新启用或恢复一个 M 去运行新 G 的。

4. 启用或停止 M

以下几个函数负责 M 地启用或停止:

  • stopm():停止当前 M 的执行,知道有新的 G 变得可运行而被唤醒
  • gcstopm():为串行运行时任务的执行让路,停止当前 M 的执行,串行运行时任务完成后会被唤醒
  • stoplockedm():停止已与某个G 锁定的当前 M 的执行,直到因这个 G 变得可运行而被唤醒
  • startlockedm(gp *g):唤醒与gp 锁定的那个 M,并让该 M 去执行 gp
  • startm(p *p, spinning bool):唤醒或创建一个 M 去关联_p_ 并开始执行
    在这里插入图片描述
  1. 调度器先检查当前 M 是否已与某个 G 锁定,是的话,调用 stoplockedm 函数停止当前 M。该函数会先解除当前 M 与 本地 P 之间的关联,并通过调用一个名为 handoffp 的函数将这个 P 转手给其他 M(或者放入调度器的空闲 P 列表),转手过程中会间接调用 startm 函数。转手成功,stoplockedm 函数就会停止当前 M 的执行,并等待唤醒
  2. 调度器找到了一个可运行的 G,发现其已经和某个 M 锁定了,那么调用 startlockedm 函数。该函数会通过参数 gp 的 lockedm 字段找到与之锁定的那个 M,并且把当前 M 的本地 P 转手给他。startlockedm 函数会先解除当前 M 与本地 P 之间的关联,然后把这个 P 赋值给已锁 M 的 nextp 字段(预联)
  3. startlockedm 会唤醒已锁 M,唤醒之后就会和他预联的 P 产生正式的关联,并去执行与之关联的 G
  4. startlockedm 最后会调用 stopm 函数,将当前 M 放入调度器的空闲 M 列表,然后停止当前 M
  5. 检查是否有串行运行时任务正在等待执行,有的话,会调用 gcstopm 函数停止当前 M。gcstopm 会先检查当前 M 是否自旋(spinning)为true 会置为 false,让后将调度器中用于记录自旋 M 数量的 nmspinning 字段减 1,因为一个将要停止的 M 理应脱离自旋状态。随后,gcstopm 会释放本地 P,并将其轧辊天设置为 Pgcstop。然后再自减并检查调度器的stopwait 字段,为0时,通过调度器的 stopnote 字段唤醒等待执行的串行运行时任务。
  6. gcstopm 最后会调用 stopm 函数,随后当前 M 会被放入调度器的空闲 M 列表并停止
  7. 如果经过一个完整的调度之后,还是找不到一个可运行的 G,调用 stopm 函数停止当前的 M。
  8. 所有经由调用 stopm 函数停止的 M,都可以通过调用 startm 函数唤醒
5. 系统监测任务

上文讲解调度器字段的时候,提到过系统监测任务,由 sysmon 函数实现。其主流程如下:
在这里插入图片描述
概括来说,系统监测任务做了这些事情:

  • 在需要时抢夺符合条件的 P 和 G
  • 在需要时进行强制 GC
  • 在需要时清扫堆
  • 在需要时打印调度器追踪信息
    抢夺 P 和 G 的途径有两个,首先是通过网络 I/O 轮询器获取可运行的 G,其次是从调度器那里抢夺符合条件的 P 和 G。第一个前面已经讲过,这里不多说了。不过这里,需要判断自上次通过该途径获取 G 是否已超过 10 ms,超过的话记录下当前时间供下次判断,然后获取,否则跳过。
    第二个途径“抢夺符合条件的 P 和 G”,由 runtime 包中的 retake 函数实现:
    在这里插入图片描述
    系统监测程序是在 Go 程序启动之初由一个专用的 M 运行的,并且它运行在系统栈上。
6. 变更 P 的最大数量

在 Go 的线程实现模型中,P 起到承上启下的重要作用,P最大数量的变更就意味着要改变 G 运行的上下文环境,也直接影响 Go 程序的并发性能。
默认情况下,P 的最大数量等于正在运行当前 Go 程序的逻辑 CPU 的数量。可以通过 runtime.GOMAXPROCS 函数改变。当使用时,首先会进行两项检查:
(1)传入的“新值”比运行时系统为此设定的硬性上限值(256)大,那么前者会被后者替代。
(2)如果新值不是正整数,或者与存储在运行时系统中的 P 的最大数量值相同,那么会忽略此变更,直接返回新值
在这里插入图片描述

  1. 一旦检查通过后,该函数会通过调度器停止一切调度工作(STW)。随后,暂存新值,重启调度工作;
  2. 调度工作真正重启之前,调度器如果发现有新值暂存,就会进入 P 最大数量的变更流程(runtime 包中的 procresize)
  • 如果全局列表中的 P 数量不够,那么会新建相应数量的 P,并且追加到全局 P 列表中(所有P的可运行 G 队列的固定长度都会是 256)。新的 P 的状态时 Pgcstop,标识它还不能使用。
  • 清理 P:1️⃣把这些 P 的可运行 G 队列中的 G 以及 runnext 字段中的 G 全部取出,依次放入调度器的可运行 G 队列;2️⃣获取这些P 持有的 GC 标记专用 G,全部转移到调度器的自由 G 列表中;3️⃣ 把 P 的自由 G 列表中的所有 G,全部转移到调度器的自由 G 列表中。(会被置为 Pdead 状态,不会直接销毁,因为可能被正在进行系统调用的 M 使用)

4.1.3 更多细节

1. g0 和 m0
  • 每个 M 都会有一个特殊的 G,一般称为 M 的 g0,管辖的内存称为 M 的调度栈,对应于操作系统为相应线程创建的栈(系统栈)
  • 运行时系统在初始化 M 时创建并分配给该 M 的,g0 一般用于执行调度、垃圾回收、栈管理等方面的任务(还有一个专门处理信号的 G)
  • 除了 g0 之外,其他由 M 运行的 G 称为用户级别的 G。Go 运行时系统会进行切换,以使每个 M 都可以交替运行用户 G 和它的 g0(也即每个 M 都会运行调度程序)
  • g0 不会被阻塞,也不会包含在任何 G 队列或者列表中。它的栈也不会在垃圾回收时被扫描
  • 每个 M 还存在一个 runtime.g0 ,用于执行引导程序。运行在 Go 程序拥有的第一个·内核线程中,这个内核线程也称为 runtime.m0。他们都是静态分配的,引导程序无需为他们分配内存。
2. 调度器锁和原子操作

其实,上述介绍的很多流程中都有使用调度器锁,只是为了简洁性,没有细说。
因为每个 M 都有可能执行调度任务,这些任务在执行时间上可能会重叠。所以,读写一些全局变量的时候就需要用调度器锁进行保护。

3. 调整 GC

当前 GC 有三种执行模式:

  • gcBackgroundMode:并发执行垃圾收集(标记)和清扫
  • gcForceMode:串行执行垃圾收集(执行时停止调度),但并发执行清扫
  • gcForceBlockMode:串行执行垃圾收集和清扫
    调度器驱使的自动 GC 和系统监测任务中的强制 GC,都会以 gcBackgroundMode 模式执行。但是前者会检查当前内存使用量,当使用量过大的时候才真正执行 GC。
  • 14
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jiangw557

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值