前奏
这篇文章是三部曲系列文章中的第一篇,这个系列的文章将会对 Go 中调度器背后的机制和语义做深入的了解。本文主要关注操作系统调度器的部分。
Go 调度器系列文章:
简介
首先,Golang 调度器的设计和实现让我们的 Go 程序在多线程执行时效率更高,性能更好。这要归功于 Go 调度器与操作系统(OS)调度器的协同合作。不过在本篇文章中,多线程 Go 程序在设计和实现上是否与调度器的工作原理完全契合不是重点。重要的是对系统调度器和 Go 调度器,它们是如何正确地设计多线程程序,有一个全面且深入的理解。
本章多数内容将侧重于讨论调度器的高级机制和语义。我将展示一些细节,让你可以通过图像来理解它们是如何工作的,可以让你在写代码时做出更好的决策。因为原理和语义是必备的基础知识中的关键。
系统调度
操作系统调度器是一个复杂的程序。它们要考虑到运行时的硬件设计和设置,其中包括但不限于多处理器核心、CPU 缓存和 NUMA,只有考虑全面,调度器才能做到尽可能地高效。值得高兴的是,你不需要深入研究这些问题,就可以大致上了解操作系统调度器是如何工作的。
你的代码会被翻译成一系列机器指令,然后依次执行。为了实现这一点,操作系统使用线程(Thread)的概念。线程负责顺序执行分配给它的指令。一直执行没有指令为止。这就是我将线程称为“执行通路”的原因。
你运行的每个程序都会创建一个进程,每个进程都有一个初始线程。而后线程可以创建更多的线程。每个线程互相独立地运行着,调度是在线程级别而不是在进程级别做出的。线程可以并发运行(每个线程在单个内核上轮流运行),也可以并行运行(每个线程在不同的内核上同时运行)。线程还维护自己的状态,以便安全、本地和独立地执行它们的指令。
如果有线程可以执行,操作系统调度器就会调度它到空闲的 CPU 核心上去执行,保证 CPU 不闲着。它还必须模拟一个假象,即所有可以执行的线程都在同时地执行着。在这个过程中,调度器还会根据优先级不同选择线程执行的先后顺序,高优先级的先执行,低优先级的后执行。当然,低优先级的线程也不会被饿着。调度器还需要通过快速而明智的决策尽可能减少调度延迟。
为了实现这一目标,算法在其中做了很多工作,且幸运的是,这个领域已经积累了几十年经验。为了我们能更好地理解这一切,接下来我们来看几个重要的概念。
执行指令
程序计数器(PC),有时称为指令指针(IP),线程利用它来跟踪下一个要执行的指令。在大多数处理器中,PC指向的是下一条指令,而不是当前指令。
如果你之前看过 Go 程序的堆栈跟踪,那么你可能已经注意到了每行末尾的这些十六进制数字。如下:
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 偏移量。更重要的是,指针前面的指令是当前正在执行的指令。
下面是对应的代码
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进制)。接下来,我们用 objdump 来看一下汇编指令。找到第57条指令,注意,runtime.gopanic
那一行。
$ 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 <--- 这里是 PC(+0x39)
记住: PC 是下一个指令,而不是当前指令。上面是基于 amd64 的汇编指令的一个很好的例子,该 Go 程序的线程负责顺序执行。
线程状态
另一个重要的概念是线程状态,它描述了调度器在线程中的角色。
线程可以处于三种状态之一: 等待中(Waiting)
、待执行(Runnable)
或执行中(Executing)
。
等待中(Waiting)
:这意味着线程停止并等待某件事情以继续。这可能是因为等待硬件(磁盘、网络)、操作系统(系统调用)或同步调用(原子、互斥)等原因。这些类型的延迟是性能下降的根本原因。
待执行(Runnable)
:这意味着线程需要内核上的时间,以便执行它指定的机器指令。如果有很多线程都需要时间,那么线程需要等待更长的时间才能获得执行。此外,由于更多的线程在竞争,每个线程获得的单个执行时间都会缩短。这种类型的调度延迟也可能导致性能下降。
执行中(Executing)
:这意味着线程已经被放置在一个核心上,并且正在执行它的机器指令。与应用程序相关的工作正在完成。这是每个人都想要的。
工作类型
线程可以做两种类型的工作。第一个称为 CPU-Bound,第二个称为 IO-Bound。
CPU-Bound:这种工作类型永远也不会让线程处在等待状态,因为这是一项不断进行计算的工作。比如计算 π 的第 n 位,就是一个 CPU-Bound 线程。
IO-Bound:这是导致线程进入等待状态的工作类型。比如通过网络请求对资源的访问或对操作系统进行系统调用。
上下文切换
诸如 Linux、Mac、 Windows 是一个具有抢占式调度器的操作系统。这意味着一些重要的事情。首先,这意味着调度程序在什么时候选择运行哪些线程是不可预测的。线程优先级和事件混在一起(比如在网络上接收数据)使得无法确定调度程序将选择做什么以及什么时候做。
其次,这意味着你永远不能基于一些你层经历过但不能保证每次都发生的行为来编写代码。如果应用程序中需要确定性,则必须控制线程的同步和协调管理。
在核心上交换线程的物理行为称为上下文切换。当调度器将一个正在执行的线程从内核中取出并将其更改状态为一个可运行的线程时,就会发生上下文切换。
上下文切换的代价是高昂的,因为在核心上交换线程会话费很多时间。上下文切换的延迟取决于不同的因素,大概在在 50 到 100 纳秒之间。考虑到硬件应该能够合理地(平均)在每个核心上每纳秒执行 12 条指令,那么一次上下文切换可能会花费 600 到 1200 条指令的延迟时间。实际上,上下文切换占用了大量程序执行指令的时间。
如果你在执行一个 IO-Bound 程序,那么上下文切换将是一个优势。一旦一个线程更改到等待状态,另一个处于可运行状态的线程就会取而代之。这使得 CPU 总是在工作。这是调度器最重要的之一,最好不要让 CPU 闲下来。
而如果你在执行一个 CPU-Bound 程序,那么上下文切换将成为性能瓶颈的噩梦。由于线程总是有工作要做,所以上下文切换阻碍了工作的进展。这种情况与 IO-Bound 类型的工作形成了鲜明对比。
少即是多
在早期处理器只有一个核心的时代,调度相对简单。因为只有一个核心,所以物理上在任何时候都只有一个线程可以执行。其思想是定义一个调度程序周期,并尝试在这段时间内执行所有可运行线程。算法很简单:用调度周期除以需要执行的线程数。
例如,如果你将调度器周期定义为 10ms(毫秒),并且你有 2 个线程,那么每个线程将分别获得 5ms。如果你有 5 个线程,每个线程得到 2ms。但是,如果有 100 个线程,会发生什么情况呢?给每个线程一个时间片 10μs (微秒)?错了,这么干是愚蠢的,因为你会话费大量的时间在上下文切换上,而真正的工作却做不成。
你需要限制时间片的长度。在最后一个场景中,如果最小时间片是 2ms,并且有 100 个线程,那么调度器周期需要增加到 2s(秒)。如果有 1000 个线程,那么调度器周期就是 20s。在这个简单的例子中,如果每个线程使用它的全时间片,那么所有线程运行一次需要花费 20s。
要知道,这是一个非常简单的场景。在真正进行调度决策时,调度程序需要考虑和处理比这更多的事情。你可以控制应用程序中使用的线程数量。当有更多的线程要考虑,并且发生 IO-Bound 工作时,就会出现一些混乱和不确定的行为。任务需要更长的时间来调度和执行。
这就是为什么游戏规则是“少即是多”。处于可运行状态的线程越少,意味着调度开销越少,每个线程执行的时间越长。完成的工作会越多。如此,效率就越高。
寻找一个平衡
你需要在 CPU 核心数和为应用程序获得最佳吞吐量所需的线程数之间找到平衡。当涉及到管理这种平衡时,线程池是一个很好的解决方案。将在第二部分中为你解析,Go 不是这样做的。
CPU 缓存
从主存访问数据有很高的延迟成本(大约 100 到 300 个时钟周期),因此处理器核心使用本地高速缓存来将数据保存在需要的硬件线程附近。从缓存访问数据的成本要低得多(大约 3 到 40 个时钟周期),这取决于所访问的缓存。如今,提高性能的一个方面是关于如何有效地将数据放入处理器以减少这些数据访问延迟。编写多线程应用程序也需要考虑 CPU 缓存的机制。
数据通过cache lines
在处理器和主存储器之间交换。cache line
是在主存和高速缓存系统之间交换的 64 字节内存块。每个内核都有自己所需的cache line
的副本,这意味着硬件使用值语义。这就是为什么多线程应用程序中内存的变化会造成性能噩梦。
当并行运行的多个线程正在访问相同的数据值,甚至是相邻的数据值时,它们将访问同一cache line
上的数据。在任何核心上运行的任何线程都将获得同一cache line
的副本。
如果某个核心上的一个线程对其cache line
的副本进行了更改,那么同一cache line
的所有其他副本都必须标记为dirty
的。当线程尝试对dirty cache line
进行读写访问时,需要向主存访问(大约 100 到 300 个时钟周期)来获得cache line
的新副本。
也许在一个 2 核处理器上这不是什么大问题,但是如果一个 32 核处理器在同一cache line
上同时运行 32 个线程来访问和改变数据,那会发生什么?如果一个系统有两个物理处理器,每个处理器有16个核心,那又该怎么办呢?这将变得更糟,因为处理器到处理器的通信延迟更大。应用程序将会在主存中周转,性能将会大幅下降。
这被称为缓存一致性问题,还引入了错误共享等问题。在编写可能会改变共享状态的多线程应用程序时,必须考虑缓存系统。
调度决策场景
假设我要求你基于我给你的信息编写操作系统调度器。考虑一下这个你必须考虑的情况。记住,这是调度程序在做出调度决策时必须考虑的许多有趣的事情之一。
启动应用程序,创建主线程并在核心1
上执行。当线程开始执行其指令时,由于需要数据,正在检索cache line
。现在,线程决定为一些并发处理创建一个新线程。下面是问题:
- 进行上下文切换,切出
核心1
的主线程,切入新线程?这样做有助于提高性能,因为这个新线程需要的相同部分的数据很可能已经被缓存。但主线程没有得到它的全部时间片。 - 新线程等待
核心1
在主线程完成之前变为可用?线程没有运行,但一旦启动,获取数据的延迟将被消除。 - 线程等待下一个可用的核心?这意味着所选核心的
cache line
将被刷新、检索和复制,从而导致延迟。然而,线程将启动得更快,主线程可以完成它的时间片。
有意思吗?这些是系统调度器在做出调度决策时需要考虑的有趣问题。幸运的是,不是我做的。我能告诉你的就是,如果有一个空闲核心,它将被使用。你希望线程在可以运行时运行。
结论
本文的第一部分深入介绍了在编写多线程应用程序时需要考虑的关于线程和系统调度器的问题。这些是 Go 调度器也要考虑的事情。在下一篇文章中,我将解析 Go 调度器的语义以及它们如何与这些信息相关联,并通过一些示例程序来展示。
英文原文有疑问加站长微信联系(非本文作者)
![](https://static.golangjob.cn/static/img/footer.png?imageView2/2/w/280)