Go调度器-Part0

介绍

Go 调度程序的设计和行为使您的多线程 Go 程序更加高效和高性能。这要归功于Go调度程序对操作系统(OS)调度程序的协调机制。但是,如果您的多线程 Go 软件的设计和行为与调度程序的工作方式没有协调的机制,那么这些都无关紧要。对 OS 和 Go 调度程序如何工作以正确设计多线程软件有一个一般性和代表性的理解是很重要的。

这篇由多部分组成的文章将重点介绍调度程序的高级机制和语义。我将提供足够的详细信息,让您直观地了解事物的工作方式,以便您做出更好的工程决策。尽管您需要为多线程应用程序做出很多工程决策,但机制和语义构成了您需要的基础知识的关键部分。

OS 调度器 (OS Scheduler)

操作系统调度程序是一套复杂的软件。他们必须考虑他们运行的硬件的布局和设置。这包括但不限于多处理器和核、CPU caches and NUMA的存在。如果没有这些知识,调度器就不能尽可能地高效。很棒的是,您仍然可以开发一个关于OS调度器如何工作的良好心智模型,而无需深入研究这些主题。

你的程序只是一系列机器指令,需要一个接一个地按顺序执行。 为了实现这一点,操作系统使用了线程的概念。 线程的工作是负责并顺序执行分配给它的指令集。 执行将继续,直到没有更多的指令供线程执行。 这就是为什么我称线程为“执行路径”。

您运行的每个程序都会创建一个进程,并且每个进程都被赋予一个初始线程。线程具有创建更多线程的能力。所有这些不同的线程彼此独立运行,调度决策是在线程级别做出的,而不是在进程级别。线程可以同时运行(每个都在一个单独的内核上运行),也可以并行运行(每个在不同的内核上同时运行)。线程还维护自己的状态,以允许安全、本地和独立地执行它们的指令。

如果有可以执行的线程,操作系统调度程序负责确保内核不空闲。它还必须造成所有可以执行的线程都在同时执行的错觉。在创建这种错觉的过程中,调度程序需要运行具有较高优先级的线程而不是较低优先级的线程。但是,优先级较低的线程不能缺少执行时间。调度器还需要通过做出快速而明智的决策来尽可能地减少调度延迟。

为了实现这一目标,我们对算法进行了大量的研究,但幸运的是,该行业有几十年的工作和经验可以利用。 为了更好地理解这一切,最好描述和定义一些重要的概念。

执行指令 (Executing Instructions)

程序计数器 (PC),有时也称为指令指针 (IP),它允许线程跟踪要执行的下一条指令。在大多数处理器中,PC 指向下一条指令,而不是当前指令。

Figure 1
![[Pasted image 20220719104548.png]]
https://www.slideshare.net/JohnCutajar/assembly-language-8086-intermediate

如果您曾经看过 Go 程序的堆栈跟踪,您可能已经注意到每行末尾的这些小十六进制数字。在清单 1 中查找 +0x39 和 +0x72。

Listing 1

goroutine 1 [running]:
   main.example(0xc000042748, 0x2, 0x4, 0x106abae, 0x5, 0xa)
       stack_trace/example1/example1.go:13 +0x39                 <- LOOK HERE
   main.main()
       stack_trace/example1/example1.go:8 +0x72                  <- LOOK HERE

这些数字表示从相应函数顶部的 PC 值偏移量。 +0x39 PC 偏移值表示如果程序没有恐慌,线程将在示例函数中执行的下一条指令。如果控制碰巧回到该函数,则 0+x72 PC 偏移值是主函数中的下一条指令。更重要的是,该指针之前的指令告诉您正在执行什么指令。

看看下面清单 2 中的程序,它导致了清单 1 中的堆栈跟踪。

Listing 2

https://github.com/ardanlabs/gotraining/blob/master/topics/go/profiling/stack_trace/example1/example1.go

07 func main() {
08     example(make([]string, 2, 4), "hello", 10)
09 }

12 func example(slice []string, str string, i int) {
13    panic("Want stack trace")
14 }

十六进制数 +0x39 表示示例函数内指令的 PC 偏移量,该偏移量位于函数起始指令下方 57(以 10 为基数)字节处。在下面的清单 3 中,您可以从二进制文件中看到示例函数的 objdump。找到底部列出的第 12 条指令。请注意,该指令上方的代码行是对恐慌的调用。

Listing 3

$ go tool objdump -S -s "main.example" ./example1
TEXT main.example(SB) stack_trace/example1/example1.go
func example(slice []string, str string, i int) {
  0x104dfa0     65488b0c2530000000  MOVQ GS:0x30, CX
  0x104dfa9     483b6110        CMPQ 0x10(CX), SP
  0x104dfad     762c            JBE 0x104dfdb
  0x104dfaf     4883ec18        SUBQ $0x18, SP
  0x104dfb3     48896c2410      MOVQ BP, 0x10(SP)
  0x104dfb8     488d6c2410      LEAQ 0x10(SP), BP
    panic("Want stack trace")
  0x104dfbd     488d059ca20000  LEAQ runtime.types+41504(SB), AX
  0x104dfc4     48890424        MOVQ AX, 0(SP)
  0x104dfc8     488d05a1870200  LEAQ main.statictmp_0(SB), AX
  0x104dfcf     4889442408      MOVQ AX, 0x8(SP)
  0x104dfd4     e8c735fdff      CALL runtime.gopanic(SB)
  0x104dfd9     0f0b            UD2              <--- LOOK HERE PC(+0x39)

请记住:PC 是下一条指令,而不是当前指令。清单 3 是该 Go 程序的线程负责顺序执行的基于 amd64 指令的一个很好的示例。

线程状态 (Thread States)

另一个重要的概念是线程状态,它决定了调度程序在线程中所扮演的角色。线程可以处于以下三种状态之一:Waiting、Runnable、Executing。

Waiting: 这意味着线程被停止并等待某些东西才能继续。这可能是由于等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥体)等原因。这些类型的[延迟](https://en.wikipedia.org/wiki/Latency_(engineering)是性能不佳的根本原因。

Runnable: 这意味着线程需要在核心上的时间,这样它才能执行分配给它的机器指令。 如果你有很多线程需要时间,那么线程必须等待更长的时间来获得时间。 此外,由于更多的线程争夺时间,任何给定线程获得的单独时间量也会缩短。 这种类型的调度延迟也可能导致性能较差。

Executing: 这意味着线程已经被放置在一个核心上,并且正在执行它的机器指令。 与应用程序相关的工作已经完成。 这是每个线程都想要的。

工作类型(Types Of Work)

线程可以做两种类型的工作。 第一个称为CPU-Bound,第二个称为IO-Bound。

CPU-Bound: 这种工作不会造成线程处于等待状态的情况。 这是一项不断进行计算的工作。 一个线程计算Pi的n次方是受cpu限制的。

IO-Bound: 这是导致线程进入等待状态的工作。这项工作包括通过网络请求对资源的访问,或者对操作系统进行系统调用。需要访问数据库的线程是io密集型的。我将把导致线程等待的同步事件(互斥锁、原子事件)作为这个类别的一部分。

上下文切换(Context Swtiching)

如果您在 Linux、Mac 或 Windows 上运行,则您正在具有抢占式调度程序的操作系统上运行。这意味着一些重要的事情。首先,这意味着调度程序在任何给定时间选择运行哪些线程是不可预测的。线程优先级与事件(如在网络上接收数据)一起,使得无法确定调度程序将选择做什么以及何时执行。

其次,这意味着您必须永远不要基于某些您有幸体验到但不能保证每次都发生的感知行为来编写代码。 允许自己思考是很容易的,因为我已经看到同样的情况发生了1000次,这是保证行为。 如果在应用程序中需要确定性,则必须控制线程的同步和编排。

在核心上交换线程的物理行为称为上下文切换。 当调度器将一个正在执行的线程从核心中取出并替换为一个可运行线程时,就会发生上下文切换。 从运行队列中选择的线程进入执行状态。 被拉出的线程可以回到Runnable状态(如果它仍然有能力运行),或者进入Waiting状态(如果因为io密集类型的请求而被替换)。

上下文切换被认为是昂贵的,因为交换内核上的线程需要时间。上下文切换期间的延迟量取决于不同的因素,但需要大约 1000 到 1500 纳秒之间的延迟量并非不合理。考虑到硬件应该能够合理地(平均)在每个内核每纳秒执行12条指令,上下文切换可能会花费您约12k至~18k指令的延迟。从本质上讲,您的程序正在失去在上下文切换期间执行大量指令的能力。

如果您有一个专注于io密集型工作的程序,那么上下文切换将是一个优势。 一旦一个线程进入等待状态,另一个处于可运行状态的线程就会取代它。 这使得核心一直在工作。 这是日程安排中最重要的方面之一。 如果有工作(处于可运行状态的线程)要完成,不会允许一个核心空闲。

如果您的程序专注于 CPU 密集型工作,那么上下文切换将成为性能的噩梦。由于Thead总是有工作要做,因此上下文切换会阻止该工作的进展。这种情况与 IO 密集型工作负载发生的情况形成鲜明对比。

少即是多(Less Is More)

在处理器只有一个内核的早期,调度并不过分复杂。由于您有一个具有单个内核的单个处理器,因此在任何给定时间都只能执行一个 Thread。我们的想法是定义一个调度程序周期,并尝试在该时间段内执行所有可运行线程。没问题:采用调度周期并将其除以需要执行的线程数。

例如,如果将调度程序周期定义为 1000 毫秒(1 秒),并且有 10 个线程,则每个线程各得到 100 毫秒。如果您有 100 个线程,则每个线程各获得 10 毫秒。但是,当您有 1000 个线程时会发生什么?为每个 Thread 指定 1 毫秒的时间片是行不通的,因为你在上下文切换上花费的时间百分比与你花在应用程序工作上的时间量有很大的关系。

您需要做的是设置一个给定时间片可以有多小的限制。 在最后一个场景中,如果最小时间片是10ms,而您有1000个线程,那么调度程序周期需要增加到10000ms(10秒)。 如果有10,000个线程,现在您看到的调度程序周期是100000ms(100秒)。 在这个简单的示例中,如果每个线程使用它的完整时间片,那么在10,000个线程中,最小时间片为10ms,那么所有线程运行一次需要100秒。

请注意,这是一种非常简单的世界观。在做出调度决策时,调度程序需要考虑和处理更多的事情。您可以控制应用程序中使用的线程数量。当有更多的线程需要考虑,并且io密集型的工作发生时,就会出现更多的混乱和不确定性行为。计划和执行需要更长的时间。

这就是为什么游戏规则是“少即是多”。处于可运行状态的线程越少,意味着调度开销越少,每个线程的时间越长,时间越长。处于可运行状态的线程越多,意味着每个线程在一段时间内获得的时间越短。这意味着随着时间的推移,你完成的工作也会减少。

找到平衡点(Find The Balance)

您需要在拥有的内核数和为应用程序获得最佳吞吐量所需的线程数之间找到一个平衡点。在管理这种平衡时,线程池是一个很好的答案。我将在第二部分中向您展示,Go不再需要这样做。我认为这是Go为使多线程应用程序开发更容易而做的一件好事。

在用 Go 编写代码之前,我在 NT 上用 C++ 和 C# 编写代码。在该操作系统上,使用 IOCP(IO 完成端口)线程池对于编写多线程软件至关重要。作为工程师,您需要弄清楚需要多少个线程池以及任何给定池的最大线程数,以最大限度地提高给定内核数的吞吐量。

当编写与数据库通信的web服务时,每个核3个线程的神奇数字似乎总是在NT上提供最好的吞吐量。换句话说,每个核3个线程最小化了上下文切换的延迟成本,同时最大化了核心上的执行时间。当创建IOCP线程池时,我知道从我在主机上识别的每个核心的最小1个线程和最大3个线程开始。

如果我使用每个核2个线程,那么完成所有的工作需要更长的时间,因为我有空闲时间,而我本可以完成工作。 如果我使用每个核4个线程,它也需要更长的时间,因为我在上下文切换中有更多的延迟。 每个核3个线程的平衡,无论出于什么原因,似乎总是在NT上是一个神奇的数字。

如果你的服务涉及很多不同类型的工作呢? 这可能会产生不同且不一致的延迟。 也许它还会创建许多需要处理的不同系统级事件。 对于所有不同的工作负载,要找到一个始终有效的神奇数字可能是不可能的。 当使用线程池来调优服务的性能时,找到正确的一致配置可能会变得非常复杂。

高速缓存线(Cache Lines)

从主存访问数据有很高的延迟成本(~100到~300个时钟周期),因此处理器和核心使用本地缓存来保持数据接近需要它的硬件线程。 从缓存中访问数据的成本要低得多(大约3到40个时钟周期),这取决于所访问的缓存。 今天,性能的一个方面是如何有效地将数据存入处理器,以减少这些数据访问延迟。 编写改变状态的多线程应用程序需要考虑缓存系统的机制。

Figure 2
![[Pasted image 20220719121526.png]]

数据通过cache lines在处理器和主存之间交换。 缓存线是一个64字节的内存块,它在主存和缓存系统之间交换。 每个核心都有它需要的缓存线的副本,这意味着硬件使用value semantics。 这就是为什么在多线程应用程序中改变内存会造成性能噩梦的原因。

当并行运行的多个线程访问相同的数据值甚至彼此靠近的数据值时,它们将访问同一缓存行上的数据。在任何内核上运行的任何线程都将获得同一缓存行的副本。

Figure 3
![[Pasted image 20220719142646.png]]

如果某个内核上的一个线程修改了它缓存行的副本,那么通过硬件的魔法,同一缓存行的所有其他副本都必须被标记为dirty。当一个线程试图对一个脏缓存线进行读写访问时,需要访问主存(~100到~300个时钟周期)来获得一个新的缓存线副本。

也许在2核处理器上这并不是什么大问题,但是在32核处理器上并行运行32个线程并在同一缓存线上访问和修改数据呢? 如果系统有两个物理处理器,每个处理器有16个核,会怎么样呢? 这将变得更糟,因为处理器到处理器通信增加了延迟。 应用程序将会在内存中来回穿梭,性能将变得非常糟糕,而且您很可能无法理解其中的原因。

这被称为缓存一致性( cache-coherency problem )问题,并且还引入了错误共享等问题。在编写将改变共享状态的多线程应用程序时,必须考虑缓存系统。

调度决策场景(Scheduling Decision Scenario)

想象一下,我要求你根据我给你的高级信息编写操作系统调度程序。考虑一下您必须考虑的这种情况。请记住,这是调度程序在做出调度决定时必须考虑的许多有趣的事情之一。

你启动你的应用程序,主线程被创建并在核心1上执行。 当线程开始执行它的指令时,由于需要数据,缓存行正在被检索。 线程现在决定为一些并发处理创建一个新线程。 问题来了。

一旦线程被创建并准备运行,调度程序应该:
1.  上下文切换主线程脱离核心1? 这样做可以提高性能,因为这个新线程需要已经缓存的相同数据的可能性非常大。 但是主线程并没有得到它的全时间片。
 2.  在主线程的时间片完成之前,线程是否要等待核心1可用? 线程没有运行,但一旦启动,读取数据的延迟将被消除。
 3. 线程是否等待下一个可用的核心? 这意味着所选核心的缓存行将被刷新、检索和复制,从而导致延迟。 然而,线程会更快地启动,主线程可以完成它的时间片。

还玩得开心吗?这些是 OS 调度程序在做出调度决策时需要考虑的有趣问题。幸运的是,我不是制作它们的人。我只能告诉你,如果有一个空闲的核心,它就会被使用。您希望线程在可以运行时运行。

结论

这篇文章的第一部分提供了关于线程和操作系统调度器在编写多线程应用程序时必须考虑的一些见解。这些也是Go调度器要考虑的事情。在下一篇文章中,我将描述Go调度器的语义,以及它们是如何与这些信息相关联的。最后,通过运行几个程序,您将看到所有这些操作。


已发布

分类

来自

标签:

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注