Go从入门到放弃19--goroutine的调度原理1
Goroutine是由Go运行时管理的用户层轻量级线程。相较于操作系统线程,Goroutine的资源占用和使用代价都要小得多。Go的运行时负责对goroutine进行管理。所谓的管理就是“调度”,将 Goroutine 按照一定算法放到不同的操作系统线程中去执行
Goroutine调度模型与演进过程
Goroutine调度器的实现不是一蹴而就的,它的调度模型与算法也是几经演化,从最初的G-M模型、到G-P-M模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,Goroutine 调度器经历了不断地优化与打磨。
G-M模型
2012年3月28日,Go 1.0正式发布。在这个版本中,Go开发团队实现了一个简单的goroutine调度器。在这个调度器中,每个goroutine对应于运行时中的一个抽象结构——G(goroutine),而被视作“物理CPU”的操作系统线程则被抽象为另一个结构——M(machine)
M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。
带来的问题主要体现在如下几个方面。
- 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作(如创建、重新调度等)都要上锁。
- goroutine传递问题:经常在M之间传递“可运行”的goroutine会导致调度延迟增大,带来额外的性能损耗。
- 每个M都做内存缓存,导致内存占用过高,数据局部性较差。
- 因系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞会带来额外的性能损耗。
G-M-P模型
面对之前调度器的问题,Go设计了新的调度器。在新调度器中,出了M(thread)和G(goroutine),又引进了P(Processor)。
- G — 表示 Goroutine,它是一个待执行的任务;
- M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
- P — 表示处理器,它可以被看做运行在线程上的本地调度器;
P是一个“逻辑处理器”,每个G要想真正运行起来,首先需要被分配一个P,即进入P的本地运行队列(local runq)中,这里暂忽略全局运行队列(global runq)那个环节。对于G来说,P就是运行它的“CPU”,可以说在G的眼里只有P
。但从goroutine调度器的视角来看,真正的“CPU”是M,只有将P和M绑定才能让P的本地运行队列中的G真正运行起来。这样的P与M的关系就好比Linux操作系统调度层面用户线程(user thread)与内核线程(kernel thread)的对应关系:多对多(N:M)。
基于协作的抢占式调度
G-P-M模型的实现是goroutine调度器的一大进步,但调度器仍然有一个头疼的问题,那就是不支持抢占式调度,这导致一旦某个G中出现死循环的代码逻辑,那么G将永久占用分配给它的P和M,而位于同一个P中的其他G将得不到调度,出现“饿死 ”的情况。更为严重的是,当只有一个P(GOMAXPROCS=1)时,整个Go程序中的其他G都将“饿死”。于是Dmitry Vyukov又提出了“Go抢占式调度器设计”(Go Preemptive Scheduler Design),并在Go 1.2版本中实现了抢占式调度。
这个抢占式调度的原理是在每个函数或方法的入口加上一段额外的代码,让运行时有机会检查是否需要执行抢占调度。这种协作式抢占调度 的解决方案只是局部解决了“饿死”问题,对于没有函数调用而是纯算法循环计算的G,goroutine调度器依然无法抢占
基于信号的抢占式调度
在Go1.14中加入了基于信号的协程调度抢占。原理是这样的,首先注册绑定SIGURG信号及处理方法runtime.doSigPreempt,sysmon会间隔性检测超时的P,然后发送信号,M收到信号后休眠执行的goroutine并且进行重新调度。
参考资料
- 《Go 语言第一课》
- 《Go语言底层原理剖析》
- 《Go语言精进之路》
- 《Go 语言设计与实现》
转载自:https://juejin.cn/post/7142890141156114463