翻译:https://morsmachine.dk/go-scheduler
参考:
Golang 的 goroutine 是如何实现的? – Yi Wang的回答 – 知乎 https://www.zhihu.com/question/20862617/answer/27964865
引言
GO 1.1最大的特性之一是 Dmitry Vyukov 提供的新调度器。
新调度器极大提升了Go并发程序性能。
……
Go运行时需要调度器吗?
在我们研究新的调度器之前,我们先理解为什么需要它。
为什么需要创建用户空间调度器?操作系统不是可以调度线程吗?
POSIX线程API很大程度上是对已存在的Unix进程模型的逻辑扩展,因此线程有很多与进程相似的特性。
比如线程有自己的信号掩码,可以分配CPU affinity(CPU亲和性),可以放到cgroups当中,可以查询使用的资源。
但是很多特性都是Go程序使用goroutine时不需要的,而且当程序线程越多时,特性带来的开销也越大。
另一个问题是操作系统不能根据Go模型做出合理的调度决策。
比如Go垃圾回收要求在运行时停止所有线程,且内存必须处于一致的状态,包括等待正在运行的线程达到内存一致的点。
当依赖系统调度线程时,可能需要等待大量的线程停止工作达到内存一致的状态。
Go调度器可以判断出内存一致的点并进行调度,这意味着当我们需要停下来进行垃圾回收操作时,我们只需要等待正在CPU核心上运行的线程而不是所有的线程。
M、P、G (Our Cast of Characters)
有三种常用的线程模型,N:1、1:1 和 M:N。
N:1 的意思是多个用户空间线程在一个系统线程上执行,这样的优点是可以非常快速地进行上下文切换,但无法发挥多核系统的优势。
1:1 的意思是其中一个用户空间线程在一个系统线程上执行,它用上了计算机所有的内核,但是上下文切换很慢,因为它必须捕获整个OS才能工作。
M:N 的意思是多个用户空间线程在多个系统线程上执行,它可以快速进行上下文切换,也可以利用系统中的所有核心,但是缺点是调度程序更加复杂。
为了完成调度任务,Go调度器使用了3个主要实体:
三角形的M表示系统线程,它是由操作系统管理的执行线程,其工作原理和标准POSIX线程非常相似。
圆形的G表示goroutine,它拥有堆栈、指令指针和其他对调度goroutine重要的信息(比如等待channel)。
矩形的P表示上下文,可以将它视为局部调度器,它是实现从 N:1 转为 M:N 的关键。
上图中有两个物理线程M,每个M都有一个P,同时拥有正在运行的G 和 等待的G(灰色)。为了运行goroutine(G),每个线程(M)必须都拥有一个上下文(P)。
上下文数量在启动时设置为GOMAXPROCS环境变量的值,或者通过运行时函数GOMAXPROCS()设置。通常在程序执行期间它不会改变。上下文数量是固定的,它决定了多少个goroutine同时运行。我们可以使用它来调整Go进程对单个计算机的调用,比如在4核PC机上的4个线程运行Go代码。
图中灰色的G没有运行,但是它们已经准备好被调度。它们在P维护的队列(runqueues)中。每当goroutine执行Go语句的时候就把它加到runqueues的末尾。当上下文P运行到调度点时,会在运行队列弹出一个goroutine,设置堆栈和指令指针,然后运行这个goroutine。
为了减少互斥锁竞争,每个上下文都有自己的本地运行队列。Go调度程序的早期版本仅有一个全局运行队列,并且有Mutex来保护它。线程经常被阻塞等待互斥锁。当程序运行在32核的机器上想充分发挥性能时,就会变得非常糟糕。
只要所有的上下文都有要运行的goroutine,调度程序就可以在稳定状态下调度。但是有几种情况会改变这一点。
系统调用(Who you gonna (sys)call?)
为什么要有上下文?我们是不是能将运行队列放在线程上摆脱上下文?并不能,我们使用上下文的原因是如果正在运行的线程出于某种原因需要阻塞,P可以转交给其他线程运行。
当我们需要调用syscall时,就需要阻塞。由于线程既不能执行代码,又不能在系统调用中被阻塞,所以我们需要移交上下文,以便它可以保持调度。
如上图,当一个线程M0陷入阻塞时,P转而到M1上运行。
调度程序确保有足够的线程来运行所有上下文。上图中的M1可能是创建出来的,也可能是从线程缓存中取出来的。调用syscall的系统线程会保留当前运行的goroutine(G0),因为它实际上还在执行,只是被OS阻塞了。
当系统调用返回时,线程必须尝试获取上下文才能运行返回的goroutine。正常模式是从其他线程中的一个窃取上下文,如果无法窃取,它会把goroutine放到全局运行队列中,自身进入线程缓存休眠。
上下文运行队列为空时就会去全局运行队列提取。上下文还会定期检查全局运行队列中的goroutine,否则全局运行队列里的goroutine可能就永远都无法运行了。
这种处理系统调用的方式是即使GOMAXPROCS为1,Go程序也可以运行多个线程的原因。
窃取工作 Stealing work
改变系统稳定状态的另一种情况是上下文用尽了运行队列中的goroutine时。如果上下文的运行队列上的工作量不平衡,则会发生这种情况。当系统中仍有工作要做时,可能会导致上下文P耗尽其运行队列。为了继续运行Go代码,上下文可以将goroutine从全局运行队列中取出,但是如果全局运行队列中没有goroutine,则必须从其他位置获取了。
那当然是从其他上下文中获取了。当某个上下文中的goroutine耗尽时,它将尝试从另一个上下文中获取约一半的运行队列。这样可以确保每个上下文都在进行工作,从而确保所有线程都在最大容量下工作。
这就是Go调度器最简单的理解,核心还是MPG…