Golang - 调度剖析【第二部分】

回顾本系列的第一部分,重点讲述了操作系统调度器的各个方面,这些知识对于理解和分析 Go 调度器的语义是非常重要的。
在本文中,我将从语义层面解析 Go 调度器是如何工作的,并重点介绍其高级特性。
Go 调度器是一个非常复杂的系统,我们不会过分关注一些细节,而是侧重于剖析它的设计模型和工作方式。
我们通过学习它的优点以便够做出更好的工程决策。

开始

当 Go 程序启动时,它会为主机上标识的每个虚拟核心提供一个逻辑处理器(P)。如果处理器每个物理核心可以提供多个硬件线程(超线程),那么每个硬件线程都将作为虚拟核心呈现给 Go 程序。为了更好地理解这一点,下面实验都基于如下配置的 MacBook Pro 的系统。

Golang - 调度剖析【第二部分】

可以看到它是一个 4 核 8 线程的处理器。这将告诉 Go 程序有 8 个虚拟核心可用于并行执行系统线程。

用下面的程序来验证一下:

package main

import (
    "fmt"
    "runtime"
)

func main() {

    // NumCPU 返回当前可用的逻辑处理核心的数量
    fmt.Println(runtime.NumCPU())
}

当我运行该程序时,NumCPU() 函数调用的结果将是 8 。意味着在我的机器上运行的任何 Go 程序都将被赋予 8 个 P

每个 P 都被分配一个系统线程 M 。M 代表机器(machine),它仍然是由操作系统管理的,操作系统负责将线程放在一个核心上执行。这意味着当在我的机器上运行 Go 程序时,有 8 个线程可以执行我的工作,每个线程单独连接到一个 P。

每个 Go 程序都有一个初始 G。G 代表 Go 协程(Goroutine),它是 Go 程序的执行路径。Goroutine 本质上是一个 Coroutine,但因为是 Go 语言,所以把字母 “C” 换成了 “G”,我们得到了这个词。你可以将 Goroutines 看作是应用程序级别的线程,它在许多方面与系统线程都相似。正如系统线程在物理核心上进行上下文切换一样,Goroutines 在 M 上进行上下文切换。

最后一个重点是运行队列。Go 调度器中有两个不同的运行队列:全局运行队列(GRQ)本地运行队列(LRQ)每个 P 都有一个LRQ,用于管理分配给在P的上下文中执行的 Goroutines,这些 Goroutine 轮流被P绑定的M进行上下文切换。GRQ 适用于尚未分配给P的 Goroutines。其中有一个过程是将 Goroutines 从 GRQ 转移到 LRQ,我们将在稍后讨论。

下面图示展示了它们之间的关系:

Golang - 调度剖析【第二部分】

协作式调度器

正如我们在第一篇文章中所讨论的,OS 调度器是一个抢占式调度器。从本质上看,这意味着你无法预测调度程序在任何给定时间将执行的操作。由内核做决定,一切都是不确定的。在操作系统之上运行的应用程序无法通过调度控制内核内部发生的事情,除非它们利用像 atomic 指令 和 mutex 调用之类的同步原语。

Go 调度器是 Go 运行时的一部分,Go 运行时内置在应用程序中。这意味着 Go 调度器在内核之上的用户空间中运行。Go 调度器的当前实现不是抢占式调度器,而是协作式调度器。作为一个协作的调度器,意味着调度器需要明确定义用户空间事件,这些事件发生在代码中的安全点,以做出调度决策。

Go 协作式调度器的优点在于它看起来和感觉上都是抢占式的。你无法预测 Go 调度器将会执行的操作。这是因为这个协作调度器的决策不掌握在开发人员手中,而是在 Go 运行时。将 Go 调度器视为抢占式调度器是非常重要的,并且由于调度程序是非确定性的,因此这并不是一件容易的事。

Goroutine 状态

就像线程一样,Goroutines 有相同的三个高级状态。它们标识了 Go 调度器在任何给定的 Goroutine 中所起的作用。Goroutine 可以处于三种状态之一:Waiting(等待状态)Runnable(可运行状态)Executing(运行中状态)

Waiting这意味着 Goroutine 已停止并等待一些事情以继续。这可能是因为等待操作系统(系统调用)或同步调用(原子和互斥操作)等原因。这些类型的延迟是性能下降的根本原因。

Runnable 这意味着 Goroutine 需要M上的时间片,来执行它的指令。如果同一时间有很多 Goroutines 在竞争时间片,它们都必须等待更长时间才能得到时间片,而且每个 Goroutine 获得的时间片都缩短了。这种类型的调度延迟也可能导致性能下降。

Executing 这意味着 Goroutine 已经被放置在M上并且正在执行它的指令。与应用程序相关的工作正在完成。这是每个人都想要的。

上下文切换

Go 调度器需要有明确定义的用户空间事件,这些事件发生在要切换上下文的代码中的安全点上。这些事件和安全点在函数调用中表现出来。函数调用对于 Go 调度器的运行状况是至关重要的。现在(使用 Go 1.11或更低版本),如果你运行任何未进行函数调用的紧凑循环,你会导致调度器和垃圾回收有延迟。让函数调用在合理的时间范围内发生是至关重要的。

注意:在 Go 1.12 版本中有一个提议被接受了,它可以使 Go 调度器使用非协作抢占技术,以允许抢占紧密循环。

在 Go 程序中有四类事件,它们允许调度器做出调度决策:

  • 使用关键字 go
  • 垃圾回收
  • 系统调用
  • 同步和编配

使用关键字 go

关键字 go 是用来创建 Goroutines 的。一旦创建了新的 Goroutine,它就为调度器做出调度决策提供了机会。

垃圾回收

由于 GC 使用自己的 Goroutine 运行,所以这些 Goroutine 需要在 M 上运行的时间片。这会导致 GC 产生大量的调度混乱。但是,调度程序非常聪明地了解 Goroutine 正在做什么,它将智能地做出一些决策。

系统调用

如果 Goroutine 进行系统调用,那么会导致这个 Goroutine 阻塞当前M,有时调度器能够将 Goroutine 从M换出并将新的 Goroutine 换入。然而,有时需要新的M继续执行在P中排队的 Goroutines。这是如何工作的将在下一节中更详细地解释。

同步和编配

如果原子、互斥量或通道操作调用将导致 Goroutine 阻塞,调度器可以将之切换到一个新的 Goroutine 去运行。一旦 Goroutine 可以再次运行,它就可以重新排队,并最终在M上切换回来。

异步系统调用

当你的操作系统能够异步处理系统调用时,可以使用称为网络轮询器的东西来更有效地处理系统调用。这是通过在这些操作系统中使用 kqueue(MacOS),epoll(Linux)或 iocp(Windows)来实现的。

基于网络的系统调用可以由我们今天使用的许多操作系统异步处理。这就是为什么我管它叫网络轮询器,因为它的主要用途是处理网络操作。通过使用网络轮询器进行网络系统调用,调度器可以防止 Goroutine 在进行这些系统调用时阻塞M。这可以让M执行P的 LRQ 中其他的 Goroutines,而不需要创建新的M。有助于减少操作系统上的调度负载。

下图展示它的工作原理:G1正在M上执行,还有 3 个 Goroutine 在 LRQ 上等待执行。网络轮询器空闲着,什么都没干。

Golang - 调度剖析【第二部分】

接下来,情况发生了变化:G1想要进行网络系统调用,因此它被移动到网络轮询器并且处理异步网络系统调用。然后,M可以从 LRQ 执行另外的 Goroutine。此时,G2就被上下文切换到M上了。

Golang - 调度剖析【第二部分】

最后:异步网络系统调用由网络轮询器完成,G1被移回到P的 LRQ 中。一旦G1可以在M上进行上下文切换,它负责的 Go 相关代码就可以再次执行。这里的最大优势是,执行网络系统调用不需要额外的M。网络轮询器使用系统线程,它时刻处理一个有效的事件循环。

Golang - 调度剖析【第二部分】

同步系统调用

如果 Goroutine 要执行同步的系统调用,会发生什么?在这种情况下,网络轮询器无法使用,而进行系统调用的 Goroutine 将阻塞当前M。这是不幸的,但是没有办法防止这种情况发生。需要同步进行的系统调用的一个例子是基于文件的系统调用。如果你正在使用 CGO,则可能还有其他情况,调用 C 函数也会阻塞M

注意:Windows 操作系统确实能够异步进行基于文件的系统调用。从技术上讲,在 Windows 上运行时,可以使用网络轮询器。

让我们来看看同步系统调用(如文件I/O)会导致M阻塞的情况:G1将进行同步系统调用以阻塞M1

Golang - 调度剖析【第二部分】

调度器介入后:识别出G1已导致M1阻塞,此时,调度器将M1P分离,同时也将G1带走。然后调度器引入新的M2来服务P。此时,可以从 LRQ 中选择G2并在M2上进行上下文切换。

Golang - 调度剖析【第二部分】

阻塞的系统调用完成后:G1可以移回 LRQ 并再次由P执行。如果这种情况需要再次发生,M1将被放在旁边以备将来使用。

Golang - 调度剖析【第二部分】

任务窃取(负载均衡思想)

调度器的另一个方面是它是一个任务窃取的调度器。这有助于在一些领域保持高效率的调度。首先,你最不希望的事情是M进入等待状态,因为一旦发生这种情况,操作系统就会将M从内核切换出去。这意味着P无法完成任何工作,即使有 Goroutine 处于可运行状态也不行,直到一个M被上下文切换回核心。任务窃取还有助于平衡所有P的 Goroutines 数量,这样工作就能更好地分配和更有效地完成。

看下面的一个例子:这是一个多线程的 Go 程序,其中有两个P,每个P都服务着四个 Goroutine,另在 GRQ 中还有一个单独的 Goroutine。如果其中一个P的所有 Goroutines 很快就执行完了会发生什么?

Golang - 调度剖析【第二部分】

如你所见:P1的 Goroutines 都执行完了。但是还有 Goroutines 处于可运行状态,在 GRQ 中有,在P2的 LRQ 中也有。
这时P1就需要窃取任务。

Golang - 调度剖析【第二部分】

窃取的规则在这里定义了:https://golang.org/src/runtim...

if gp == nil {
        // 1/61的概率检查一下全局可运行队列,以确保公平。否则,两个 goroutine 就可以通过不断地相互替换来完全占据本地运行队列。
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        gp, inheritTime = runqget(_g_.m.p.ptr())
        if gp != nil && _g_.m.spinning {
            throw("schedule: spinning with local work")
        }
    }
    if gp == nil {
        gp, inheritTime = findrunnable()
    }

根据规则,P1将窃取P2中一半的 Goroutines,窃取完成后的样子如下:

Golang - 调度剖析【第二部分】

我们再来看一种情况,如果P2完成了对所有 Goroutine 的服务,而P1的 LRQ 也什么都没有,会发生什么?

Golang - 调度剖析【第二部分】

P2完成了所有任务,现在需要窃取一些。首先,它将查看P1的 LRQ,但找不到任何 Goroutines。接下来,它将查看 GRQ。
在那里它会找到G9P2从 GRQ 手中抢走了G9并开始执行。以上任务窃取的好处在于它使M不会闲着。在窃取任务时,M是自旋的。这种自旋还有其他的好处,可以参考 work-stealing

Golang - 调度剖析【第二部分】

实例

有了相应的机制和语义,我将向你展示如何将所有这些结合在一起,以便 Go 调度程序能够执行更多的工作。设想一个用 C 编写的多线程应用程序,其中程序管理两个操作系统线程,这两个线程相互传递消息。

下面有两个线程,线程 T1 在内核 C1 上进行上下文切换,并且正在运行中,这允许 T1 将其消息发送到 T2

Golang - 调度剖析【第二部分】

T1 发送完消息,它需要等待响应。这将导致 T1C1 上下文换出并进入等待状态。
T2 收到有关该消息的通知,它就会进入可运行状态。
现在操作系统可以执行上下文切换并让 T2 在一个核心上执行,而这个核心恰好是 C2。接下来,T2 处理消息并将新消息发送回 T1

Golang - 调度剖析【第二部分】

然后,T2 的消息被 T1 接收,线程上下文切换再次发生。现在,T2 从运行中状态切换到等待状态,T1 从等待状态切换到可运行状态,再被执行变为运行中状态,这允许它处理并发回新消息。

所有这些上下文切换和状态更改都需要时间来执行,这限制了工作的完成速度。
由于每个上下文切换可能会产生 50 纳秒的延迟,并且理想情况下硬件每纳秒执行 12 条指令,因此你会看到有差不多 600 条指令,在上下文切换期间被停滞掉了。并且由于这些线程也在不同的内核之间跳跃,因 cache-line 未命中引起额外延迟的可能性也很高。

Golang - 调度剖析【第二部分】

下面我们还用这个例子,来看看 Goroutine 和 Go 调度器是怎么工作的:
有两个goroutine,它们彼此协调,来回传递消息。G1M1上进行上下文切换,而M1恰好运行在C1上,这允许G1执行它的工作。即向G2发送消息。

Golang - 调度剖析【第二部分】

G1发送完消息后,需要等待响应。M1就会把G1换出并使之进入等待状态。一旦G2得到消息,它就进入可运行状态。现在 Go 调度器可以执行上下文切换,让G2M1上执行,M1仍然在C1上运行。接下来,G2处理消息并将新消息发送回G1

Golang - 调度剖析【第二部分】

G2发送的消息被G1接收时,上下文切换再次发生。现在G2从运行中状态切换到等待状态,G1从等待状态切换到可运行状态,最后返回到执行状态,这允许它处理和发送一个新的消息。

Golang - 调度剖析【第二部分】

表面上看起来没有什么不同。无论使用线程还是 Goroutine,都会发生相同的上下文切换和状态变更。然而,使用线程和 Goroutine 之间有一个主要区别:
在使用 Goroutine 的情况下,会复用同一个系统线程和核心。这意味着,从操作系统的角度来看,操作系统线程永远不会进入等待状态。因此,在使用系统线程时的开销在使用 Goroutine 时就不存在了。

基本上,Go 已经在操作系统级别将 IO-Bound 类型的工作转换为 CPU-Bound 类型。由于所有的上下文切换都是在应用程序级别进行的,所以在使用线程时,每个上下文切换(平均)不至于迟滞 600 条指令。该调度程序还有助于提高 cache-line 效率和 NUMA。在 Go 中,随着时间的推移,可以完成更多的工作,因为 Go 调度器尝试使用更少的线程,在每个线程上做更多的工作,这有助于减少操作系统和硬件的负载。

结论

Go 调度器在设计中考虑到复杂的操作系统和硬件的工作方式,真是令人惊叹。在操作系统级别将 IO-Bound 类型的工作转换为 CPU-Bound 类型的能力是我们在利用更多 CPU 的过程中获得巨大成功的地方。这就是为什么不需要比虚拟核心更多的操作系统线程的原因。你可以合理地期望每个虚拟内核只有一个系统线程来完成所有工作(CPU和IO)。对于网络应用程序和其他不会阻塞操作系统线程的系统调用的应用程序来说,这样做是可能的。

作为一个开发人员,你当然需要知道程序在运行中做了什么。你不可能创建无限数量的 Goroutine ,并期待惊人的性能。越少越好,但是通过了解这些 Go 调度器的语义,您可以做出更好的工程决策。

在下一篇文章中,我将探讨以保守的方式利用并发性以获得更好的性能,同时平衡可能需要增加到代码中的复杂性。

相关推荐