5 min read

Scheduler

Goroutine的调度是靠Go自己完成,实现Go程序内Goroutine之间公平竞争CPU资源的任务,落到了Go运行时(runtime)头上了。在一个Go程序中,除了用户层代码,剩下的就是Go运行时

Goroutine调度器模型与演化过程

  • G-M模型
  • G-P-M模型
  • 不支持抢占
  • 支持协作式抢占
  • 支持基于信号的异步抢占

G-M模型

每个Goroutine对应于运行时中的一个抽象结构:G(Goroutine);而被视作物理CPU的操作系统线程,则被抽象为另外一个结构:M(machine)

调度器的工作就是将G调度到M上去运行。调度器引入了GOMAXPROCS变量来表示Go调度器可见“处理器”的最大数量

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

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

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

通过向G-M模型中增加一个P,让Go调度器具有更好的伸缩性

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

不足:不支持抢占式调度,这导致一旦某个G中出现死循环的代码逻辑,那么G将永久占用分配给他的P和M,而位于同一个P中的其他G将得不到调度,出现“饿死”情况 更严重的是,当只有一个P(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”

抢占式调度

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

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

比如,死循环等并没有给编译器插入抢占代码的机会,这就会导致GC在等待所有Goroutine停止时的等待时间过长,从而导致GC延迟,内存瞬间冲高;甚至在一些特殊情况,导致STE(stop the world)时死锁

非协作的抢占式调度