首页 > 技术文章 > Goroutine 调度器

ltaodream 2022-03-22 13:49 原文

Goroutine 调度器

Goroutine 占用的资源非常小,每个 Goroutine 栈的大小默认是 2KB。而且,Goroutine 调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的 Goroutine。而将这些 Goroutine 按照一定算法放到“CPU”上执行的程序,就被称为 Goroutine 调度器(Goroutine Scheduler)

其任务:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行

Goroutine 调度器模型与演化过程

G-M 模型

2012 年 3 月 28 日,Go 1.0版本

G(Goroutine)、M(machine)

调度器的工作就是将 G 调度到 M 上去运行。为了更好地控制程序中活跃的 M 的数量,调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。

问题:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序

体现在以下几个方面:

  • 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁;
  • Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
  • 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差;
  • 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。

G-P-M 调度模型和work stealing 算法

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

通过向 G-M 模型中增加了一个 P(逻辑 Proccessor),让 Go 调度器具有很好的伸缩性。

每个 G(Goroutine)要想真正运行起来,首先需要被分配一个 P,也就是进入到 P 的本地运行队列(local runq)中。对于 G 来说,P 就是运行它的“CPU”,可以说:在 G 的眼里只有 P。但从 Go 调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定,才能让 P 的 runq 中的 G 真正运行起来。

work-stealing 未充分利用的处理器会主动去寻找其他处理器的线程并 窃取 一些.

基于协作的“抢占式”调度

Go 1.2

Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。

问题:局部解决了“饿死”问题,只在有函数调用的地方才能插入“抢占”代码(埋点),对于没有函数调用而是纯算法循环计算的 G,Go 调度器依然无法抢占。

非协作的抢占式调度

Go 1.14

这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。

推荐阅读