[翻译]Go 的调度器

近期工作有些调整,所以这篇东西用了差不多两个星期才翻译完。想起 @Fenng 几年前跟我说的关于行业和工作的话,虽然出发点不太一样,但是结论还真是正确啊!

工作上的变动,就不多扯了。原文在此《The Go scheduler》。

————翻译分割线————

Go 的调度器

Daniel Morsing

概述

Go 1.1 重要特性之一就是由 Dmitry Vyukov 贡献的新调度器。无需对程序进行任何调整,新的调度器就可以为 Go 程序带来令人兴奋的性能提升。因此我觉得有必要就此写点什么。

在本博文所述的大多数内容都已经在原始的设计文档中有所介绍。那是一篇相当全面的文档,同时也相当专业。

你想要了解的关于新的调度器的一切都能在那篇文档里找到,而这篇博文描绘了整体情况,所以优略得所。

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

在了解新调度器之前,先要了解为什么需要它。为什么在操作系统已经能够对线程进行调度的情况下还需要创建一个用户空间调度器。

POSIX 线程 API 绝对是对已有的 Unix 进程模型的逻辑扩展,这样线程就获得了跟进程类似的控制方式。线程拥有自己的信号掩码,可以与 CPU 关联起来,可以放入 cgroups 或查询哪些资源被其使用。所有这些控制方式所带来的特性对于使用 goroutine 的 Go 程序来说都不需要,并且当程序有 100000 个线程的时候,所需的控制会急速膨胀。

另一个问题是 OS 不能基于 Go 模型根据实际情况进行调度。例如,Go 垃圾收集器在执行回收时,需要所有的线程都先停止,而内存也必须在一致的状态。这包含了等待正在运行的线程执行到某个已知内存会达到一致状态的地方。

当有许多线程进行随机的调度,挑战是你必须不停的等待他们达到一致状态。Go 调度器可以决定在已知内存会一致的地方进行调度。这意味着当停下进行垃圾收集时,只需要等待在 CPU 内核上实际运行的线程。

我们的阵容

通常有三个线程模型。一个是 N:1,也就是若干个用户空间线程运行在一个 OS 线程上。它的好处是上下文切换非常迅速,而坏处是无法发挥多核系统。另一个是 1:1,也就是一个执行线程对应一个 OS 线程。好处是可以利用机器上的所有内核,不过由于它是通过 OS 来进行的,所以上下文切换非常慢。

Go 试图利用 M:N 调度器在两个世界中找到平衡点。若干 goroutine 调度在若干 OS 线程上。得到了快速的上下文切换,并且可以利用系统里的所有核心。而主要的问题是这个方法会增加调度器的复杂度。

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

our-cast

三角形代表 OS 线程。它是由系统管理执行的线程,并且工作方式与标准的 POSIX 线程相当类似。在运行时的代码里,叫做 M 代表设备。

圆形代表 goroutine。它包括了栈、指令指针和其他调度 goroutine 所需的重要信息,如可能阻塞它的任何一个 channel。在运行时代码里,它被叫做 G。

矩形代表调度的上下文。可以将其看作是一个在一个线程上运行 Go 代码的局部版本的调度器。这是从 N:1 调度器演化到 M:N 调度器的重要的一环。在运行时代码中,它被叫做 P 代表处理器。关于这部分还得再多说几句。

in-motion

这里有 2 个线程(M),每个都拥有一个上下文(P),每个都执行一个 goroutine(G)。线程必须拥有一个上下文才能执行 goroutine。

上下文的数量是由环境变量 GOMAXPROCS 在启动的时候设置的,也可以通过运行时函数 GOMAXPROCS() 设置。事实上上下文的数量是固定的,这也就是说任何时候都只有 GOMAXPROCS 个 Go 代码在执行。可以使用这个来在不同的计算机上进行调整,比如 4 核 PC 会运行 4 条 Go 代码的线程。

灰色的 goroutine 没有在运行,但是已经准备好被调度了。它们排列在一个叫做 runqueues 的列表里。当 goroutine 执行 go 语句时就会被添加到 runquque 的尾部。一个正在运行的 goroutine 到达调度点时,上下文就会从 runqueue 中弹出这个 goroutine,并且设置栈和指令指针,然后开始执行下一个 goroutine。

为了减少互斥争用,每个上下文都有它自己本地的 runqueue。上一个版本的 Go 调度器只有一个使用互斥量保护的全局 runqueue。线程经常为了等待互斥量解锁而被阻塞。当在一个 32 核的机器上想要尽可能的压榨性能时这会变得非常糟糕。

只要上下文有 goroutine 需要运行,调度器就会在这个稳定的状态下持续的进行调度。然而,有一些情况可能会改变这个局面。

你要(系统)调用谁?

你现在可能在想,为什么需要上下文?为什么不能抛开上下文直接将 runqueue 放在线程上?其实不是这样的。有上下文的原因是当由于某些原因正在执行的线程会阻塞时可以切换到其他线程。

一个关于阻塞的例子就是系统调用。由于线程无法在运行代码的同时又阻塞在系统调用上,所以需要上下文进行切换来保证调度。

syscall

这里可以看到一个线程放弃了它的上下文,因此其他线程可以运行。调度器保证了有足够的线程运行所有的上下文。为了正确的处理系统调用,会创建或者是从线程缓存中获取上图中的 M1。技术上说被系统调用线程持有的进行了系统调用的 goroutine 仍然是在运行的,尽管在 OS 层它被阻塞了。

当系统调用返回,线程必须尝试获取上下文以便让 goroutine 继续运行。通常的模式是从其他线程窃取一个上下文。如果没办法偷得到,就会将 goroutine 放入全局 runqueue 中,然后自己返回线程缓存继续休眠。

当上下文执行完本地的 runqueue 后,会从全局 runqueue 获取 goroutine。上下文也会定期的检查全局 runqueue。否则的话在全局 runqueue 上的 goroutine 可能由于缺乏资源而永远都不会运行。

这个处理系统调用的方法说明了为什么即使 GOMAXPROCS 为 1 的时候,Go 程序也会运行多个线程。运行时用 goroutine 调用系统调用,而让线程藏在背后。

窃取工作

当一个上下文调度执行完所有的 goroutine 时系统的稳定状态也会发生变化。这发生在上下文的 runqueue 分配的工作不平衡的时候。这会导致当上下文清空其 runqueue 后,在系统中仍然有工作需要完成。为了让 Go 代码继续执行,上下文可以从全局 runqueue 获取 goroutine,但是如果没有 goroutine 在其中的话,总得从其他什么地方获取到它们。

steal

这个其他地方其实就是其他上下文。当一个上下文执行完,它会试图偷取其他上下文的一半 runqueue。这保证了每个上下文都总是有工作可做,也保证了所有的线程都进其最大的能力在工作。

何去何从?

还有许多调度器的细节,如 cgo 线程,LockOSThread() 函数和带有网络池的指令。它们不在本文讨论的范围内,但是仍然值得学习。以后我可能会写一些关于这些的内容。在 Go 运行时库中还有许许多多有趣的创造等待着被探索。

Join the Conversation

6 Comments

Leave a comment

Your email address will not be published. Required fields are marked *