[译] Go语言调度器 by Daniel Morsing

简介

Go 1.1的一个最大的特性是由Dmitry Vyukov贡献的新的调度器。新的调度器给并发Go程序提高了巨大的性能。

这篇文档的大部分内容已经在这篇原始设计文档有描述。那是一篇相当全面的文档,但是太技术性了。

那篇设计文档中包含了所有新调度器你需要知道的东西,但是这篇文章有插图,所以会更好理解些。

为什么Go 运行时需要一个调度器?

讨论新调度器之前,我们需要理解为什么需要它。为什么在操作系统已经可以调度线程的前提下,还需要构建一个用户态的调度器?

POSIX线程API在很大程度上可以看做是对现有Unix进程模型的逻辑延伸,线程和进程有很多相似处。线程有自己的信号掩码(signal mask),可以分配与CPU关联,可以被放入cgroups,可以被查询使用了哪些资源。所有这些控制的特性都增加了开销,当你的程序中有10万个线程的时候,这些开销将会非常巨大。当不需要这些特性时,在Go语言中可以使用协程。

另一个问题是,操作系统在Go模型下不能做出好的调度决策。举例来说,Go垃圾回收器在执行一次回收时,需要所有线程都停止,使得内存处于一致性状态。这导致需要等待所有运行着的线程到达某一个我们可以确定内存是一致的点。

当你有很多线程被调度运行在随机的点时,你必须等待它们达到一致性状态。而Go调度器可以决策出只调度到它可以确认内存是一致的点即可。这意味着当我们停下来做垃圾回收时,我们只需要等待那些正在CPU上运行的活跃的线程。

译者yoko注,上面这两段话有点绕,我个人的理解是,Go GC的时候需要全局内存一致性,也即全局都停下来不操作内存,Go的调度器由于系统线程数有限,更容易做到这一点。比如所有空闲系统线程不用管,所有运行时的系统线程都在执行完当前协程后暂停。这里与Go调度器做对比的应该是大量系统线程的模型,比如最土的1:1模型。

模型中的角色分工

有三种场景的线程模型。第一种是N:1,即多个用户态线程运行在一个系统线程中。优点是上下文切换快,缺点是不能利用多核。另一种是1:1,一个用户态线程对应一个系统线程。这种利用了机器的多核,但是上下文切换会很慢,因为上下文切换需要陷入操作系统内核态。

Go试图使用一种M:N的调度器来结合前两种模型,扬长避短。它调度任意数量的协程运行于任意数量的系统线程中。即保证了上下文切换的速度,又利用了多核。这种模型的主要缺点是增加了调度器的复杂度。

为了实现这个目标,Go调度器使用了三个实体:

cast

三角形代表一个系统线程。这是被操作系统管理用来执行代码的线程,工作起来就像标准POSIX线程一样。在runtime代码中,被称为M,即machine的简称。

圆圈代表一个协程。它包含了栈,指令指针和其它关于调度协程的重要信息。比如任何可能导致阻塞的channel。它被称为G

正方形代表一个用于调度的上下文。你可以把它看成一个本地化版本的调度器,用于在一个线程中执行Go代码。它是让我们可以从N:1模型转变成M:N模型的重要部分。在runtime代码中,被称为P,即processor的简称。接下来讨论这部分内容。

motion

如图所示,我们有2个线程(M),每个线程持有一个上下文(P),每个P执行一个协程(G)。为了执行协程,一个线程必须持有一个上下文。

上下文的数量在程序启动时通过环境变量GOMAXPROCS指定,或者通过运行时函数GOMAXPROCS()指定。正常情况下在程序运行时这个数量不会变。上下文数量固定以为着在任意时刻只有GOMAXPROCS数量的Go代码在运行。利用这点我们可以调整对Go调用的数量,比如在4核机器上运行4个线程。

灰色部分的协程处于非运行状态,但是已经准备就绪,可以被调度。它们被安置于被称为runqueues的队列中。当使用go关键字开启一个协程时,这个协程被添加至队列的末尾。当上下文执行完一个协程后,到达一个调度的点,它会从队列中取出一个协程。设置好栈和指令指针并开始执行这个协程。

为了降低锁竞争,每个上下文都有它独立的本地队列。老版本的Go调度器只有一个被单个锁保护的全局的队列。线程经常阻塞在这把锁上。当你有一个32核的机器而你又想榨干硬件性能的时候这是相对糟糕的。

当所有上下文都有协程在运行时,调度器保持这种调度方式。然而,还有一些场景会改变这种稳定状态。

调用系统调用时发生了什么?

你可能会奇怪,为什么我们需要P,我们不能把任务队列直接挂载到线程上而去除掉P吗?答案是不行。原因是当运行着的线程由于某些原因需要阻塞时,我们需要通过P把任务队列挂载到其它线程中。

什么时候需要阻塞,举个例子,当我们调用系统调用的时候。因为一个线程不能一边执行代码一边阻塞在系统调用上,我们需要移交P使得它可以继续被调度。

syscall

上图演示了一个线程放弃它的P,使得其它线程可以运行这个P。调度器会确保有足够的线程运行所有的P。图中的M1用来处理这个系统调用,它可能是被新创建的,也有可能从空闲线程池中取出。这个线程用来保持调用系统调用的这个协程,因为理论上来说,这个线程依然在执行,只不过是阻塞在了系统调用上。

当系统调用返回,这个线程必须尝试获取一个上下文以用来执行这个返回的协程。常规操作是从其他线程偷取一个上下文。如果偷取失败,会把这个协程放入全局运行队列。再把线程放入空闲线程池中并睡眠。

当某个上下文本地运行队列为空时,会从全局运行队列取任务。所有的上下文都会周期性从全局队列获取协程任务。以防止全局队列中的协程饿死,永远得不到执行。

这种处理系统调用的方式就是为什么Go程序是多线程执行的原因,即使GOMAXPROCS是1。运行时使用协程调用系统调用,将线程隐藏在其后。

偷取任务

另一种导致这种稳定状态发生改变的原因是一个上下文执行完了调度给它的所有协程。这种情况发生的原因是所有上下文的运行队列上的任务不均衡。此时系统中还有其他等待执行的任务。为了保持Go代码继续执行,一个上下文可以从全局运行队列取协程,但是如果全局运行队列没有协程了,这个上下文只能从其他地方取任务。

steal

这个其他地方就是其他上下文。当一个上下文没任务了,它会尝试从另一个上下文的运行队列中偷取一般的任务。这保证了每个上下文都总是有任务在执行,这也就保证了所有的线程都在尽可能的工作。

其他

还有许多关于调度器的细节,比如cgo的线程,LockOSThread()函数,和网络poller的结合。这些内容超出了这篇文章的范围,但是值得学习。我以后可能会写这方面的内容。当然,在Go运行时库中还有很多有趣的东西值得被挖掘。

英文原文地址: http://morsmachine.dk/go-scheduler

0%