The Go Scheduler
译者注:本文提到的P(processor)虽然数量上与CPU核数相等,但它并不完全等同于CPU的processor,严格意义上,它应该是一个包含CPU核信息以及一些其他信息,如goroutine runqueue信息等的数据结构或调度器。所以本文都使用上下文指代它(原文中为context)。
介绍
Go1.1提供的一个大功能之一是一个由 Dmitry Vyukov贡献的新的调度器。新的调度器大大提高的Go并行程序的性能,而且基本没有什么能比它做得更好的了。我想我要写一些关于这个新的调度器的事情。
这篇文章所写的大部分东西都在原始设计文档Scalable Go Scheduler Design Doc
中有描述。那是一篇很全面的文档,但是太偏技术了。
所有你需要了解的东西在那篇设计文档中都有,但是这篇文章中有图片,这是它好的地方。
Go运行时需要调度器做什么?(What does the Go runtime need with the scheduler?)
在我们去了解scheduler前面,我们首先需要理解为什么需要一个scheduler。为什么在操作系统已经为你提供了线程调度的情况下还需要创建一个用户空间(Userspace)调度器?
POSIX线程API看起来非常像只是对已有的Unix进程模型的扩展,线程与进程非常的类似。线程有自己的信号掩码,可以被分配CPU affinity,可以被放入cgroups以及可以查询它们使用的资源。所有这些功能都是Go编程中使用Goroutines根本不需要的,这些操作会增加额外的开销,而且当你的程序中有成千上万的线程时,这些额外的开销增长非常迅速。
另一个问题就是,基于Go的模型,操作系统无法做出明智的调度决策。例如Go的垃圾回收要求在执行回收的时候所有的线程停止运行,且内存必须在一个一致的状态。这涉及到等待正在运行的进程到达我们知道的内存一致的某个点。
当你有很多线程被随机的调度,你不得不一个个的等他们到达一致的状态。Go的调度器则可以只在它知道的内存一致的点执行调度。这就意味着当我们停止垃圾回收时,我们只需要等待实际运行在CPU核上的线程就可以了。
Our Cast of Characters
线程有三种常见模型。N:1多个用户空间线程运行在一个操作系统线程上。这个模型的好处是上下文切换很快,缺点是无法利用多核的优势。1:1 一个用户线程对应一个操作系统线程。正好相反,这个模型充分利用了CPU的多核,但是上下文切换很慢,因为每次切换都需要陷入到OS层。
Go通过使用一个M:N调度器,来吸取两个模型的长处。它将多个Goroutines运行在多个操作系统线程上。你既能快速的切换上下文,又能充分利用CPU的多核优势。这个模型的唯一缺点就是增加了调度器的复杂度。
为了实现任务调度,Go调度器使用了三个重要的结构:
-
M: 三角形表示操作系统线程,它是操作系统管理的执行线程,并且工作起来非常像你所理解的POSIX线程。在runtime代码中,它被称为M,代表machine。
-
G: 圆形代表Goroutine。它包括栈,指令指针(IP)以及其他一些调度Goroutines需要的重要信息,比如任何可能阻塞Goroutine的channel。在runtime代码中,它被称为G,代表goroutine。
-
P: 正方形代表调度的上下文。可以把它看成是可以让goroutine运行在一个线程上的本地化的调度器。它是让我们从N:1线程模型向M:N线程模型过度的重要部分。在runtime代码中,它被称为P,代表Processor。
上图中有两个线程(M),每一个线程有一个上下文§,每个线程上正在执行一个goroutine(G)。为了运行goroutines,每个线程必须有一个上下文。
上下文的数量在启动时根据环境变量GOMAXPROCS
的值设定,或通过runtime function GOMAXPROCS()
设置。通常情况下,这个在你的程序运行过程中不会发生改变。上下文的数量固定意味着在任何时间点都只有GOMAXPROCS
个线程在执行Go代码。我们可以用这个来调优调用Goroutines到不同的CPU core执行,例如一个4核CPU在4个线程上运行Go代码。
上图中灰色的Goroutines没有执行,但是都处于就绪态。它们被排列在一个称为runqueues
的列表中。任何时候通过go
语句起的一个新goroutine都将被排在runqueues
的末尾。一旦一个上下文(P)执行一个goroutine的调度点时,调度器从runqueue
中弹出一个goroutine,并为它设置栈,指令指针等,然后开始执行这个goroutine。
以前版本的Go调度器只有一个使用mutex
包含的全局共享的runqueue
。线程经常因为等待mutex
解锁而被阻塞。当你有一个32core的机器,你希望获得尽可能高的性能时,这样的阻塞将使情况变得非常糟糕。所以,为了降低mutex
竞争,新的调度器没一个上下文都有一个自己独立的runqueue
。
只要在所有的context都有goroutines等待执行时,调度器就会这样一直稳定的执行下去。但是,还有一些特殊的情况会造成这种状态的改变。
Who you gonna (sys)call?
你可能会想,为什么我们需要上下文?我们不可以直接把runqueue
给线程,然后拿掉上下文吗?事实是不行,因为当一个正在运行的线程因为某种原因被阻塞时,我们可以将上下文交给其他的线程继续执行。
一个需要被阻塞的例子就是当我们需要调用syscall
时。有个一个线程不能在被阻塞在syscall
上时,又同时执行代码,所以我们需要将它的context交给其他线程来继续调度。
这里我们看到有一个线程放弃了它的上下文,这样其他的线程就可以继续运行它的上下文了。调度器将确保有足够的线程来运行所有的上下文。在上图示例中的M1
线程,可能只是为了处理syscall
而新创建的,也有可能是从thread cache中拉取的。syscalling
的线程会继续持有执行syscall
的goroutine,因为从技术上说,这个goroutine仍然在继续执行,虽然在操作系统中它已经被阻塞。
当一次系统调用返回,线程必须尝试获取上下文才能继续运行返回的goroutine。正常的操作模式是,它将从其他线程那里偷一个上下文,如果失败,那么它会把这个goroutine放到全局的runqueue
中,然后把自己放到Thread cache中就去睡大觉去了。
当上下文执行完本地的runqueue
后,它们就会从全局的runqueue
拉取全局runqueuq
中的goroutines。另外,从上下文还会定期去检查全局的runqueque
中是否有goroutine,否则全局runqueue
中的goroutine可能会因为饥饿而永远不会被执行。
这种处理syscall
的方式,就是为什么Go会运行多个线程的原因,即使GOMAXPROCS
的值为1。runtime使用调用syscall的goroutine,留下线程。
Stealing work
系统的稳定状态会发生改变的另一种情况是一个context执行完所有可以调度的goroutine时。这种情况可能会发生在context的runqueue
上的工作量分配不均时。这可能造成一个context已经运行完了它的runqueue
中的所有的goroutine,但是系统中仍然有工作需要完成。为了继续执行Go代码,context可以从全局的runqueuq
中获取goroutine
,但是如果全局runqueue中没有goroutine,它就不得不从其他地方获取goroutines以继续执行。
其他地方就是其他的contexts,当一个context执行完了自己的runqueue,它将尝试从其他的context那儿偷一半的runqueue过来。这可以确保每个context都有足够的工作执行,这又确保了所有的线程都能够以最大的容量运行。
Where to go
调度器还有很多其他的细节,例如cgo线程,LockOSThread
函数,网络轮询器(network Poller)集成。这些已经超出了本文的讨论范围,但是值得研究。我可能以后会写一些相关的东西。Go的runtime库确实有很多非常有趣的构造。