译前序

本文翻译自 The Go Scheduler ,虽然时间有点久了,但只是阐释思想的话并没有太大问题。由于我个人翻译水平有限,若有纰漏,敬请谅解。

由 Dmitry Vyukov 贡献的新调度器是 Go 1.1 版本中的一个重大功能。新的调度器大幅提高了 Go 程序的并发性能,没有什么比这更好的事了,我想我会写一些关于调度器的东西。

这篇博文中的大部分内容已经在原始设计文档中得到描述了。这是一份相当详尽的文档,但也很技术性。

你需要了解的关于新调度器的所有信息都在该设计文档之中,但本篇文章有图片,所以它显然更胜一筹。

Go 的运行时需要什么样的调度器?

在我们看向新的调度器之前,我们需要理解为什么我们需要它。为什么用操作系统就可以为你调度线程,但我们却选择去创建一个用户级的调度器来使用呢?

POSIX 线程 API 在很大程度上是对现有的 Unix 进程模型的逻辑扩展,因而线程获得许多与进程相同的控制。线程有自己的信号掩码,可以分配 CPU 亲和性, 可以放入 cgroups 中,可以查询他们使用了哪些资源。所有这些控制的功能并不为 Go 程序的 goroutine 所用,并且这些额外的开销相叠加,当你有 100000 个线程时,这些开销会快速叠加起来。

另一个问题是,操作系统不能基于 Go 的模型作出明智的调度决定。举个例子,Go 的垃圾收集器要求在收集时所有线程都停止,并且内存也要保持一致。这就涉及到正在运行的线程所要到达的内存一致的点在哪里这个问题。

当你在随机点安排了许多线程时,你很可能必须等待许多线程达到一致状态。使用 Go 调度器,它就可以知道内存一致的点,并在那时候进行调度。 这意味着当我们停止垃圾收集时,我们只需要等待正在 CPU 内核上主动运行的线程。

我们的角色阵容

这里有 3 种常用的线程模型。一个是 N:1 ,多个用户级的线程在一个系统级的线程上运行。这种方案的优势是可以快速切换线程但不能利用多核系统的优点。另一种是 1:1 ,一个线程对应一个系统级的线程。它的优势是可以利用机器多核的优势,但上下文切换非常慢,因为它掉入到了系统的陷阱里去。

Go 使用 M:N 调度器以达到两全其美。它将任意数量的 goroutine 安排到任意数量的系统 OS 线程上。你可以快速切换上下文且也可以利用你系统内的所有核心。这种方法的主要劣势为,它将增加调度器的复杂度。

为了完成调度任务, Go 的调度器使用 3 个主要的实体:

三角形代表一个系统线程。这个执行线程将由系统管理,且工作起来颇似标准的 POSIX 线程。在运行时的代码中,它被称做 M ,代表机器。

圆形代表一个 goroutine 。它包括堆栈,指令指针和其他用于调度 goroutines 的重要信息,就像它可以被阻塞的 channel 一样。在运行时代码中,称为G。

矩形的代表一个调度的上下文。你可以将其视为调度程序的本地化版本,该版本在单个线程上运行 GO 代码。这是让我们从 N:1 调度器向 M:N 调度器转换的重要部分。在运行时代码中,它被称为 P ,代表 processor 。

这里我们看到有 2 个线程(M),每一个保有一个上下文(P),每一个都跑着一个 goroutine (G)。为了运行 goroutine ,线程必须保留上下文。

上下文数量在在启动时由环境变量 GOMAXPROCS 设置或者通过运行函数 GOMAXPROCS() 。通常在程序执行期间不会改变。实际上,上下文的数量是固定的,意味着只有 GOMAXPROCS 贯穿于 Go 代码的运行。我们可以使用它来调整GO进程对单个计算机的调用,比如,在 4 个核心的 PC 上运行 4 个线程的 GO 代码。

灰色的 goroutine 代表没有运行但准备被调度。他们被安排到被叫做 runqueue 的列表里。每当执行一个 go statement 时,将会添加一个 goroutine 到 runqueue 尾部。一旦上下文中的 goroutine 到达调度点时,它将会从 runqueue 中推出一个 goroutine ,并设置堆栈和指示指针,开始运行 goroutine 。

为了减少 mutex 的争用,每一个上下文都有自己本地的 runqueue 。在先前的版本的 Go 调度器只有一个全局 runqueue 并使用一个 mutex 进行保护。线程经常因为等待 mutex 释放而阻塞。当你拥有 32 核机器的时候,这真的很糟糕,您想尽可能多地压榨性能。

只要所有上下文都有 goroutine 可以运行,调度程序就可以保持这种稳定状态。但是,有几个场景可以改变这种情况。

你要打电话(系统调用)给谁?

注:标题是个双关。

你可能好奇,为什么会有上下文?我们可以把 runqueue 放到线程上以消除上下文吗?不行。原因在于,当我们拥有上下文时,如果正在运行的线程由于某种原因需要阻塞,我们可以将它们交给其他线程。

一个我们需要阻塞的例子是系统调用。一个线程我们不能在运行代码的同时被系统调用阻塞,我们释放上下文以使得我们继续保持调度。

这里我们看到一个线程放弃了自己的上下文,以便另一个线程可以运行它。调度器可确保这里有足够的线程可以运行所有的上下文。 M1 可能只是为了处理此次系统调用而创建的,也可能来自线程缓存。系统调用线程将保留系统调用的 goroutine ,因为在技术上它仍在执行,尽管在操作系统中被阻塞。

当系统调用返回,线程必须尝试获取上下文,才能运行返回的 goroutine 。正常的操作模式是从其他线程上偷取一个上下文。如果它不能偷得一个,它将会把 goroutine 放到一个全局的 runqueue ,并将自己放入到线程缓存上并进入 sleep 态。

偷工

另一个改变系统的稳定状态的情况是上下文耗尽了它的 goroutine 调度。如果上下文的 runqueue 上的工作量不平衡,就会发生这种情况。这可能导致上下文在系统仍能运行时被消耗殆。上下文可以将 goroutine 从全局 runqueue 中取出,但如果其中没有 goroutine ,则必须从其他地方获取。

某处是指其他上下文。当一个上下文运行完时,它将会尝试从另一个上下文的 runqueue 中偷取一半的 goroutine 。这将可以确保每个上下文都有工作,从而确保所有线程都以最大容量工作。

下一步

调度器还有很多细节,比如 cgo 线程, LockOSThread() 功能并与网络轮询器集成。 这些超出了本文的范围,但仍然值得研究。我以后可能会写这些。在 Go 运行时库中肯定有很多有趣的结构。

By Daniel Morsing