(译) Go中的调度:第II部分 - Go Scheduler

(译) Go 中的调度:第 II 部分 - Go Scheduler

这是三部分系列中的第二篇文章,它将提供对 Go 中调度程序背后的机制和语义的理解。本文重点介绍 Go 调度程序。

三部分系列的索引:

  1. Go 中的调度:第 I 部分 - 操作系统调度程序
  2. Go 中的调度:第 II 部分 - Go Scheduler
  3. Go 中的调度:第 III 部分 - 并发

介绍

在本调度系列的第一部分中,我解释了操作系统调度程序的各个方面,我认为这些方面对于理解和理解 Go 调度程序的语义非常重要。在这篇文章中,我将在语义层面解释 Go 调度程序的工作原理并关注高级行为。Go 调度程序是一个复杂的系统,小的机器上的细节并不重要。重要的是拥有良好的工作和行为方式。这将使你能够做出更好的工程决策。

你的计划开始

当你的 Go 程序启动时,它会为主机上标识的每个虚拟核心提供一个逻辑处理器(P)。如果你的处理器每个物理核心具有多个硬件线程(超线程), 则每个硬件线程将作为虚拟核心呈现给你的 Go 程序。为了更好地理解这一点,请查看我的 MacBook Pro 的系统报告。


图 1

你可以看到我有一个带有 4 个物理内核的处理器。本报告未公开的是每个物理核心的硬件线程数。英特尔酷睿 i7 处理器具有超线程功能,这意味着每个物理内核有 2 个硬件线程。这将向 Go 程序报告,8 个虚拟核可用于并行执行操作系统线程。

要测试这一点,请考虑以下程序:

清单 1

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"runtime"
)

func main() {

// NumCPU returns the number of logical
// CPUs usable by the current process.
fmt.Println(runtime.NumCPU())
}

当我在本地机器上运行该程序时,NumCPU()函数调用的结果将是值 8. 我在我的机器上运行的任何 Go 程序将被赋予 8P。

每个 P 被分配一个操作系统线程(“M”)。’M’代表机器。该线程仍由操作系统管理,操作系统仍负责将线程放在核心上执行,如上一篇文章所述。这意味着当我在我的机器上运行 Go 程序时,我有 8 个线程可用于执行我的工作,每个线程都单独连接到 P.

每个 Go 程序也都有一个初始的 Goroutine(“G”), 这是 Go 程序的执行路径。Goroutine 本质上是一个 Coroutine, 但这是 Go, 所以我们用 “G” 代替字母 “C”, 我们得到了 Goroutine 这个词。你可以将 Goroutines 视为应用程序级线程,它们在很多方面类似于操作系统线程。正如操作系统线程在核心上下载上下文一样,Goroutines 在上下文中打开和关闭。

最后一个难题是运行队列。Go 调度程序中有两个不同的运行队列:全局运行队列(GRQ)和本地运行队列(LRQ)。每个 P 都有一个 LRQ, 用于管理指定在 P 的上下文中执行的 Goroutines。这些 Goroutines 轮流在上下文中切换到分配给 P 的 M。GRQ 用于尚未分配给的 Goroutines。还没有。有一个过程将 Goroutines 从 GRQ 转移到 LRQ, 我们将在后面讨论。

图 2 提供了所有这些组件的图像。


图 2

协作调度程序

正如我们在第一篇文章中讨论的那样,操作系统调度程序是一个抢占式调度程序。从本质上讲,这意味着你无法预测调度程序在任何给定时间将要执行的操作。内核正在做出决策,一切都是非确定性的。运行在操作系统之上的应用程序无法控制内核中发生的事情,除非它们利用原子指令和互斥调用等同步原语。

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

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

Goroutine 并发

就像线程一样,Goroutines 拥有相同的三个高级状态。这些决定了 Go 调度程序在任何给定的 Goroutine 中所起的作用。Goroutine 可以处于以下三种状态之一:Waiting,Runnable 或 Executing。

  • 等待:这意味着 Goroutine 已停止并等待某些事情继续进行。这可能是出于等待操作系统(系统调用)或同步调用(原子操作和互斥操作)等原因。这些类型的延迟是性能不佳的根本原因。

  • 可运行:这意味着 Goroutine 需要时间在 M 上,因此它可以执行其指定的指令。如果你有很多想要时间的 Goroutines, 那么 Goroutines 必须等待更长时间才能得到时间。此外,随着更多 Goroutines 争夺时间,任何给定的 Goroutine 获得的个人时间缩短了。这种类型的调度延迟也可能是性能不佳的原因。

  • 执行:这意味着 Goroutine 已被置于 M 并正在执行其指令。与应用程序相关的工作即将完成。这是每个人都想要的。

上下文切换

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

注意:有一个 1.12 的提议被接受在 Go 调度程序中应用非协作抢占技术,以允许抢占紧密循环。

Go 程序中发生了四类事件,允许调度程序做出调度决策。这并不意味着它总是会发生在其中一个事件上。这意味着调度程序获得了机会。

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

使用关键字 go

关键字 go 是你创建 Goroutines 的方式。一旦创建了新的 Goroutine, 它就为调度程序提供了做出调度决策的机会。

垃圾收集

由于 GC 使用自己的 Goroutines 运行,因此那些 Goroutines 需要时间在 M 上运行。这会导致 GC 产生大量的调度混乱。但是,调度程序非常聪明地了解 Goroutine 正在做什么,它将利用这些智能做出明智的决策。一个聪明的决定是上下文切换一个 Goroutine, 它想要在 GC 期间接触那些没有接触堆的堆。当 GC 运行时,正在做出许多调度决策。

系统调用

如果 Goroutine 进行系统调用会导致 Goroutine 阻塞 M, 有时调度程序能够将 Goroutine 从 M 上下文切换并将新的 Goroutine 上下文切换到相同的 M. 但是,有时新的 M 是需要继续执行在 P 中排队的 Goroutines。如何工作将在下一节中更详细地解释。

同步和编排

当你运行的操作系统具有异步处理系统调用的能力时,可以使用称为网络轮询器的内容来更有效地处理系统调用。这是通过在这些相应的操作系统中使用 kqueue(Mac 操作系统),epoll(Linux)或 iocp(Windows)来实现的。

基于网络的系统调用可以由我们今天使用的许多操作系统异步处理。这是网络轮询器获得其名称的地方,因为它的主要用途是处理网络操作。通过使用网络轮询器进行网络系统调用,调度程序可以防止 Goroutines 在进行系统调用时阻止 M. 这有助于保持 M 可用于在 P 的 LRQ 中执行其他 Goroutines 而无需创建新的 Ms. 这有助于减少操作系统上的调度负载。

查看其工作原理的最佳方法是运行示例。


图 3

图 3 显示了我们的基本调度图。Goroutine-1 正在 M 上执行,并且还有 3 个 Goroutines 等待 LRQ 在 M 上等待。网络轮询器无所事事。


图 4

在图 4 中,Goroutine-1 想要进行网络系统调用,因此 Goroutine-1 被移动到网络轮询器并处理异步网络系统调用。一旦 Goroutine-1 移动到网络轮询器,M 现在可以从 LRQ 执行不同的 Goroutine。在这种情况下,Goroutine-2 在 M. 上下文切换。


图 5

在图 5 中,异步网络系统调用由网络轮询器完成,Goroutine-1 被移回到 L 的 LRQ 中。一旦 Goroutine-1 可以在 M 上进行上下文切换,Go 负责的 Go 相关代码可以再次执行。这里的最大优势是,要执行网络系统调用,不需要额外的 Ms。网络轮询器具有操作系统线程,它正在处理有效的事件循环。

同步系统调用

当 Goroutine 想要进行无法异步完成的系统调用时会发生什么?在这种情况下,网络轮询器不能被使用,并且进行系统调用的 Goroutine 将阻止 M. 这是不幸的,但是没有办法防止这种情况发生。不能异步进行的系统调用的一个示例是基于文件的系统调用。如果你正在使用 CGO, 则可能还有其他情况,调用 C 函数也会阻止 M.

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

让我们来看看同步系统调用(如文件 I / O)会导致 M 阻塞的情况。


图 6

图 6 再次显示了我们的基本调度图,但这次 Goroutine-1 将进行同步系统调用以阻止 M1。


图 7

在图 7 中,调度程序能够识别 Goroutine-1 已导致 M 阻塞。此时,调度程序将 M1 与 P 分离,同时仍然附加阻塞 Goroutine-1。然后调度器引入新的 M2 来为 P 服务。此时,可以从 LRQ 中选择 Goroutine-2 并且在 M2 上进行上下文切换。如果由于之前的交换而已经存在 M, 则此切换比必须创建新 M 更快。


图 8

在图 8 中,由 Goroutine-1 完成的阻塞系统调用完成。此时,Goroutine-1 可以移回 LRQ 并再次由 P 服务。如果需要再次发生这种情况,则将 M1 放在侧面以备将来使用。

工作窃取

调度程序的另一个方面是它是一个工作窃取调度程序。这有助于在一些领域保持有效的调度。首先,你想要的最后一件事就是 M 进入等待状态,因为一旦发生这种情况,操作系统就会将 M 从核心上下文切换。这意味着即使有一个 Goroutine 处于可运行状态,P 也无法完成任何工作,直到 M 在核心上进行上下文切换。窃取工作也有助于平衡所有 P 的 Goroutines, 从而更好地分配工作并更有效地完成工作。

让我们来看一个例子。


图 9

在图 9 中,我们有一个多线程 Go 程序,其中两个 P 服务四个 Goroutines, 每个服务 GRQ 中有一个 Goroutine。如果 P 的所有 Goroutines 中的一个服务很快就会发生什么?


图 10

在图 10 中,P1 没有更多的 Goroutines 来执行。但是 Goroutines 处于可运行状态,无论是在 LRQ 中还是在 GRQ 中。这是 P1 需要偷工作的时刻。窃取工作的规则如下。

清单 2

1
2
3
4
5
6
7
8
runtime.schedule() {
//只有1/61的时间,检查G的全局可运行队列
//如果找不到,请检查本地队列。
//如果没找到,
//试图从其他Ps窃取
//如果没有,请检查全局可运行队列。
//如果找不到,轮询网络。
}

因此,基于清单 2 中的这些规则,P1 需要在其 LRQ 中检查 P2 for Goroutines 并获取其发现的一半。


图 11

在图 11 中,Goroutines 的一半来自 P2, 现在 P1 可以执行那些 Goroutines。

如果 P2 完成为其所有 Goroutines 提供服务并且 P1 的 LRQ 中没有任何东西会发生什么?


图 12

在图 12 中,P2 完成了所有工作,现在需要窃取一些。首先,它将查看 P1 的 LRQ, 但它不会找到任何 Goroutines。接下来,它将查看 GRQ。那里会发现 Goroutine-9。


图 13

在图 13 中,P2 从 GRQ 窃取了 Goroutine-9 并开始执行工作。所有这些偷窃工作的好处在于它允许女士保持忙碌而不会闲着。这项工作窃取在内部被视为旋转 M. 这种旋转具有 JBD 在她的工作窃取博客文章中解释得很好的其他好处。

实际例子

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


图 14

在图 14 中,有 2 个线程来回传递消息。线程 1 在 Core 1 上进行上下文切换,现在正在执行,这允许线程 1 将其消息发送到线程 2。

注意:消息的传递方式并不重要。当业务流程继续进行时,重要的是线程的状态。


图 15

在图 15 中,一旦线程 1 完成发送消息,它现在需要等待响应。这将导致线程 1 从 Core 1 上下文关闭并进入等待状态。一旦线程 2 收到有关该消息的通知,它就会进入可运行状态。现在操作系统可以执行上下文切换并在 Core 上执行线程 2, 它恰好是 Core 2. 接下来,线程 2 处理消息并将新消息发送回线程 1。


图 16

在图 16 中,线程上下文切换再次由线程 2 接收线程 2 的消息。现在线程 2 上下文 - 从执行状态切换到等待状态和线程 1 上下文 - 从等待状态切换到可运行状态最后回到执行状态,允许它处理并发回新消息。

所有这些上下文切换和状态更改都需要时间来执行,这限制了工作的完成速度。由于每个上下文切换可能会产生约 1000 纳秒的延迟,并且希望硬件每纳秒执行 12 条指令,因此你可以查看 12k 指令,或多或少,在这些上下文切换期间不执行。由于这些线程也在不同的核心之间弹跳,因高速缓存行未命中而导致额外延迟的可能性也很高。

让我们采用相同的例子,但使用 Goroutines 和 Go 调度程序。


图 17

在图 17 中,有两个 Goroutine 正在编排,彼此之间来回传递消息。G1 在 M1 上进行上下文切换,这恰好在 Core 1 上运行,这允许 G1 执行其工作。G1 的工作是将其消息发送给 G2。


图 18

在图 18 中,一旦 G1 完成发送消息,它现在需要等待响应。这将导致 G1 上下文关闭 M1 并进入等待状态。一旦 G2 收到有关该消息的通知,它就会进入可运行状态。现在,Go 调度程序可以执行上下文切换并在 M1 上执行 G2,M1 仍然在 Core 1 上运行。接下来,G2 处理消息并将新消息发送回 G1。


图 19

在图 19 中,当 G2 接收到由 G2 发送的消息时,事物再次上下文切换。现在 G2 上下文 - 从执行状态切换到等待状态,G1 上下文 - 从等待状态切换到可运行状态,最后返回到执行状态,这允许它处理并发回新消息。

表面上的事情似乎没有任何不同。无论你使用线程还是 Goroutines, 都会发生所有相同的上下文切换和状态更改。但是,使用线程和 Goroutines 之间存在一个主要区别,乍一看可能并不明显。

在使用 Goroutines 的情况下,相同的操作系统线程和核心用于所有处理。这意味着,从操作系统的角度来看,操作系统线程永远不会进入等待状态;不止一次。因此,在使用 Goroutines 时,使用 Threads 时我们丢失到上下文切换的所有指令都不会丢失。

从本质上讲,Go 已将 IO / Blocking 工作转变为操作系统级别的 CPU 限制工作。由于所有上下文切换都是在应用程序级别进行的,因此在使用 Threads 时,每个上下文切换都不会丢失相同的~12k 指令(平均)。在 Go 中,那些相同的上下文切换花费大约 200 纳秒或~2.4k 指令。调度程序还有助于提高缓存线效率和 NUMA。这就是为什么我们不需要比虚拟核心更多的线程。在 Go 中,随着时间的推移,可以完成更多的工作,因为 Go 调度程序尝试使用更少的线程并在每个线程上执行更多操作,这有助于减少操作系统和硬件的负载。

结论

Go 调度程序在设计如何考虑操作系统和硬件如何工作的复杂性方面确实令人惊讶。在操作系统级别将 IO / 阻塞工作转换为 CPU 限制工作的能力是我们在利用更多 CPU 容量的过程中获得巨大成功的地方。这就是为什么你不需要比虚拟核心更多的操作系统线程。你可以合理地期望每个虚拟核心只需一个操作系统线程即可完成所有工作(CPU 和阻塞 IO 绑定)。对于不需要阻止操作系统线程的系统调用的网络应用程序和其他应用程序,可以这样做。

作为开发人员,你仍然需要了解你的应用在你正在处理的工作类型方面正在做什么。你无法创建无限数量的 Goroutines 并期望惊人的性能。少总是更多,但是通过理解这些 Go-scheduler 语义,你可以做出更好的工程决策。在下一篇文章中,我将探讨以保守方式利用并发性以获得更好性能的想法,同时仍然平衡可能需要添加到代码中的复杂性。

原文:

1) Scheduling In Go : Part I - 操作系统 Scheduler
2) Scheduling In Go : Part II - Go Scheduler
3) Scheduling In Go : Part III - Concurrency

评论

0 comments
Anonymous
Markdown is supported

Be the first person to leave a comment!

`
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×