本文翻译自The Go scheduler
摘要
Go 语言中的一个重要功能就是 Go scheduler, 全新的调度程序使得Go运行并行代码的性能大大提高。这篇文章中所涉及的大部分内容已经在原始设计文档中进行了描述,这是一个相当全面的文件,但是技术型很强。本文尝试用简单的语言来进行描述。
为什么Go运行时需要一个调度器
在学习Go的调度程序之前,我们需要了解为什么需要它。为什么在操作系统可以为你调度线程时创建一个用户空间调度程序?
POSIX API对于现有的Unix进程模型来说是非常合乎逻辑的扩展,因此线程获得了与进程相同的控制。线程有自己的信号掩码,可以分配CPU亲和性(CPU affinity),可以放入CGroup,并且可以查询他们使用的资源。所有这些增加了Go程序直接使用操作系统线程时的开销,当程序中有100,000个线程时,这些开销会非常可观。
另一个问题是操作系统无法根据Go模型做出明智的调度决策。例如,Go垃圾回收器要求所有线程在运行垃圾收集时都停止,并且内存必须处于一致的状态。这涉及到等待正在运行的线程到达一个我们知道的内存一致的点。
当你有很多线程在随机点进行调度的时候,很可能你将不得不等待他们中的许多线程达到一致的状态。 Go调度程序可以仅在知道内存一致的地方做出调度的决定。这意味着当我们停止垃圾收集时,我们只需要等待CPU核心上正在运行的线程。
Go Scheduler
通常情况下,线程调度存在三种模型。
- N:1 - 几个用户空间的线程在一个OS内核线程上运行,这种模型使得线程上下文切换非常快,但是不能利用CPU多核的功能
- 1:1 - 一个用户线程匹配一个OS内核线程,它利用了CPU的所有核心,但是由于必须通过操作系统进行内陷,因此上下文切换很慢
- M:N - 看下文
Go试图通过使用M:N调度模型来获得两全其美的效果。它将任意数量的goroutine调度到任意数量的OS线程上。您可以快速切换上下文,并充分利用系统中的所有内核。这种方法的主要缺点是它增加了调度程序的复杂性。
为了完成调度任务,Go Scheduler使用了3个主要的结构体:
三角形表示一个操作系统线程。它是由OS管理的执行线程,与您的标准POSIX线程非常相似。在运行时代码中,它被称为M代表机器(machine)。
圆圈代表了一个goroutine。它包括堆栈,指令指针和其他对调度goroutine很重要的信息,就像它可能被阻塞的任何信道一样。在运行时代码中,它被称为G。
矩形表示用于调度的上下文。您可以将它看作是在单个线程上运行Go代码的调度程序的本地化版本。这是让我们从N:1调度器到M:N调度器的重要部分。在运行时代码中,它被称为P(processor)。
这里我们看到2个线程(M),每个M都有一个上下文(P),每个M运行一个goroutine(G)。为了运行goroutines,一个M必须拥有一个上下文P。
P的数量在启动时设置为GOMAXPROCS环境变量的值或者通过运行时函数GOMAXPROCS()。通常这在程序执行过程中不会改变。P的数量是固定的,这意味着同时只有GOMAXPROCS个线程在运行Go代码。我们可以使用它来调整Go进程的并发程度,例如在4个核心的CPU上同时有4个线程在运行Go程序。
灰色的goroutines此时没有运行,但准备好被调度。它们被安排在名为runqueues的列表中。 一个goroutine执行go语句时,其他的goroutines被添加到runqueue的末尾。一旦一个上下文运行了一个goroutine直到一个调度点,它就会从它的runqueue中弹出一个goroutine,设置堆栈和指令指针并开始运行goroutine。
为了消除互斥争用,每个上下文都有自己的本地runqueue。 Go调度程序的前一个版本只有一个全局runqueue和一个互斥体来保护它。线程经常被阻塞,等待互斥锁解锁。当你有台32个核心的机器时,这真的很糟糕,你希望尽可能地提高性能。
只要所有的上下文P都有goroutine运行,调度器就会保持这个稳定状态。但是,有两种情况可以改变这种情况。
系统调用(syscall)
你现在可能会想,为什么有上下文P呢?为什么我们不能把runqueues放到上并摆脱上下文P呢?我们有上下文P的原因是,如果正在运行的M由于某种原因需要阻塞,我们可以将P交给其他M。
一个例子是,当我们发起一个系统调用(syscall),此时M会被阻塞。由于一个M在被系统调用阻塞的同时不能执行其他goroutine的代码,那我们就需要切换上下文以保持调度。
这里我们看到一个线程M0放弃了它的上下文,以便另一个线程M1可以运行其他goroutine。调度程序确保有足够的线程来运行所有上下文。上图中的M1可能是为了处理这个系统调用而创建的,或者它可能来自线程池。系统调用线程将保留在进行系统调用的goroutine上,因为从技术上来说它仍然在执行,尽管在OS中被阻塞。
当系统调用返回时,线程M0必须尝试获取上下文以运行返回的goroutine。正常的操作模式是从其他线程中窃取一个上下文。如果不能偷一个,它会把goroutine放到全局runqueue上,并将自己放回线程池中去sleep。
当某个上下文P执行完本地runqueue时,会从全局runqueue中拉取一个goroutine执行。上下文P还会定期检查全局运行队列。否则全局runqueue的goroutines可能会因为永远得不到执行而饿死。
“偷”
调度系统的稳定状态被改变的另一种方式是,绑定到一个上下文P上的所有goroutines全部被调度完毕。如果上下文P的runqueues上的工作量不平衡,就会发生这种情况。这可能会导致在系统中仍然有工作要执行时上下文P却耗尽了运行队列。为了继续运行Go代码,一个上下文P可以从全局runqueue中取出goroutine,但是如果没有goroutines,就必须从其他地方取得它们。
其他地方指的就是其他上下文。当一个上下文用完时,它会尝试从另一个上下文中偷取大约一半的runqueue。这确保每个上下文总是有工作要做,从而确保所有线程都以最大容量工作。