【译】goroutine调度器,一:系统调度

[toc]

原文:Scheduling In Go : Part I - OS Scheduler

前言

本系列一共三篇文章,将提供对go调度程序背后的机制的理解。本文是第一篇,重点介绍操作系统调度程序。

简介

Go的调度机制设计,可以使你的go程序以多线程方式更加高效的运行。这要归功于Go的调度器与操作系统(OS)的调度器的协同运作方式,但是,如果你的go程序的多线程并不是按照这种方式设计,就无法发挥优势。因此,了解OS的调度机制与go语言的调度机制,对于帮助你学习如何正确进行多线程编程非常重要。

OS Scheduler

操作系统调度器有着复杂的设计,他们需要考虑兼容各种硬件,这里包括但不限与多个处理器与内核,CPU缓存和NUMA(非统一内存访问)
,没有这些知识,调度器将无法实现高效。好消息是你无需深入研究这些主题,你也依然可以了解系统调度器的工作原理。

你的程序只是一串依次执行的计算机指令,为了实现这点,操作系统提出了Thread(线程)的概念。线程的工作就是顺序执行分配给他们的指令集,一直执行到没有更多的命令为止。这就是我称Thread为“单一顺序控制流”的原因。

你运行的每个程序都会创建一个Process(进程),并为每个Process提供一个初始的Thread(线程)。每个线程也可以再创建更多的线程。所有这些不同的线程彼此独立运行,并且他们的调度在线程级别进行,而不是在进程级别上。线程可以并发运行(在单个和核心上轮转),也可以并行运行(每个线程在不同的核心上同时运行)。线程拥有自己的状态来保证安全,局部,独立执行指令。

系统调度器确保在有可执行线程的情况下,内核不会出现空闲状态。它还造成一个“假象”,即所有可以执行的线程在同时执行。在这种“假象”下,调度器会优先运行优先级高的线,但是优先级较低的线程不能缺乏执行时间,调度程序还需要通过快速,明智的决策尽快实现调度分配。

为实现这个目标,很多算法实现在不断优化,幸运的是这个行业有数十年的经验积累。为了更好的理解这些,我们还需要了解一些重要概念。

执行指令

程序计数器(PC),有时被称为指令指针(IP),保证线程来跟踪并执行下一条指令。在大多数处理器中,PC指向下一条指令而不是当前指令。

图1

92_figure1.jpeg

https://www.slideshare.net/JohnCutajar/assembly-language-8086-intermediate

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

代码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值。+0x39PC的偏移量代表的是如果这个方法没有抛出panic的话,下一个指令线程将在内部执行example的功能。在0+x72PC偏移值代表内部的下一个指令是main方法。更重要的是,该指针之前的指令会告诉你正在执行的指令。

代码段2中的程序,是产生代码1堆栈信息的代码

代码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表示example函数内部指令的PC偏移量,该指令比函数的起始指令低57(基数10)字节。在下面的代码3中,你可以看见example函数中的二进制objdump,找到底部的第12条指令。注意该指令上面的代码行是对panic的调用。

代码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是基于amd64的指令的一个很好的例子,该Go程序的Thread是顺序执行的。

线程状态

另一个重要的概念是线程状态,它规定了调度器对线程所采用的策略。线程可以处于以下三种状态之一:Waiting(等待), Runnable(可执行) or Executing(执行中)。

Waiting:这表示线程停止并在等待某些东西。这可能是出于等待硬件设备(磁盘,网络),操作系统(系统调用)或同步调用(原子,互斥)等原因。这些类型的延迟是性能不佳的根本原因。

Runnable:这表示线程需要内核的执行时间,以便它可以执行所分配的机器指令。如果你同时有很多线程处于此阶段,那么线程必须等待更长的时间才能获得时间。此外随着更多的线程争夺时间,那么每个线程获得的单独的时间也会随之缩短。这种调度产生的延迟也可能是性能不佳的原因。

Executing:这表示线程已被放置在内核上并正在执行其机器指令。与应用程序相关的工作即将完成。这是每个人所期望的结果。

工作类型

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

CPU-Bound(计算密集型):这种工作类型下的线程永远不会切换到等待状态,它在不断进行工作。比如计算π的第N位数,就是CPU-Bound。

IO-Bound(I/O密集型):这种工作类型下线程会切换进入等待状态,包括通过网络访问请求资源,或操作系统的系统调用。访问数据库资源的线程是I/O密集型的,系统同步事件(互斥,原子)操作,也会使线程进入等待。

上下文切换

如果你正在使用Linux,Mac或Windows系统,那么你的程序就正运行在抢占式调度的操作系统上。这里有几点重要的事情,首先,调度器在什么时间运行什么线程都是不可预测的。线程的优先级与事件(比如获取网络数据)相关,是的无法确定调度器什么时间执行什么操作。

其次,永远不要根据某一次的结果去理解并编写代码,因为那可能是运气而已,你无法保证同样的结果每次都能出现。只有当相同的结果出现1000次了,我才能理解这是有保障的输出结果。如果你需要运行的确定性,那么你必须能控制线程的同步执行与调度。

在内核上切换线程的行为称为上下文切换。调度器将一个Runnable态的线程替换掉一个Executing态的线程时,就发生了上下文切换。被选中的线程从队列中取出并进入Executing状态,被替换下来的线程会转换为Runnable状态(如果它仍然可以继续执行),或者也可能进行Waiting状态(如果是由于IO-Bound类型的请求)

上下文切换是一种非常昂贵的开销,因为在内核上切换线程需要花费很多时间。上下文切换造成的延迟取决于不同的因素,一般在50100纳秒是合理的。考虑到硬件层面上[每个核每纳秒可执行12条指令(平均)]("https://www.youtube.com/watch?v=jEG4Qyo_4Bc&feature=youtu.be&t=266"),上下文切换可能会浪费大约6001200纳秒的指令执行时间。因此实质上,在上下文切换期间,你的程序失去了大量的执行指令的能力。

如果你的程序是一个专于IO-Bound工作的程序,那么上线文切换是有好处的。一旦Thread进入Waiting状态,另一个处于Runnable状态的Thread就可以取而代之。这使得内核始终可以正常工作。这是调度的最重要方面之一。如果有工作(处于Runnable状态的线程),则不允许内核空闲。

如果你的程序是专注于CPU-Bound的工作,那么上下文切换将成为性能的噩梦。由于Thead一直有工作要做,但是上下文切换会终止程序的运行。这种情况与IO-Bound的负载情况形成鲜明的对比。

少即是多

在处理器只有一个核心的早期阶段,调度并没有多么复杂。因为你只有一个单核处理器,所以在任何给定时间只能执行一个Thread。主要的想法是定义一个调度周期,并尝试在这段时间内执行所有的Runnable线程。这是可以实现的,用调度周期除以需要执行的线程数。

例如,如果将调度周期定义为10毫秒并且您有2个线程,则每个线程各获得5毫秒。如果你有5个线程,每个线程各获得2ms。但是,当你有100个线程时会发生什么?你不能为每个线程仅提供10μs(微秒)的时间片,因为那将会在上下文切换中花费大量时间。

你需要限制时间片的最短时间。在最后一种情况下,如果最小时间片是2ms并且你有100个线程,则调度周期需要增加到2000ms或2s(秒)。如果有1000个线程呢?那么你的调度周期将会是20s,如果每个线程都要使用时间片,那么在这个简单的示例中,所有的线程都运行一次并完全使用了分配的时间片,需要20秒的时间。

一个简单的共识,调度器在进行调度决策的时候要考虑并处理更多的事情。你控制着你的应用程序中的线程数,当需要更多的线程并发生IO-Bound工作时,会出现更多的混乱和不确定行为,那么就需要更多的时间来进行调度并执行。

这就是为什么称为“少即是多”的原因。越少的Runnable状态线程意味着越少的调度开销,同时每个线程可以分到更长的时间片。越多的Runnable状态线程意味着每个线程分到的时间片更短,同时也意味着你的工作的完成时间也要向后推迟。

找到平衡

你需要在你拥有的机器核数与应用程序所需最佳线程数之间找到平衡点。为了实现这种平衡,线程池是一个很好的方案。在本系列第二篇文章中我会展示Go不再需要这些。我认为,Go使多线程应用的开发变得更简单,这是Go语言的优点之一。

在使用Go之前,我使用C++和C#在NT上进行编程。在这个操作系统上,学会使用IOCP线程池对于多线程编程至关重要。作为开发者,你需要确定线程池数量以及最大线程数,同时指定核心数量,来最大化性能。

在编写进行数据库通信的Web服务时,每个核心以神奇的3个线程,在NT系统上实现最佳调度。换言之,每个内核3个线程最小化了上下文切换的延迟,同时最大化利用了内核的执行时间。我了解,在创建IOCP线程池时,每个内核会运行至少1个线程,至多3个线程。

如果每个内核使用2个线程,那么完成所有工作所需要的时间要更长,因为我有空闲时间被浪费。如果我每个内核使用4个线程,那么它需要的时间也更长,因为在上下文切换过程中产生了更多的延迟。无论出于什么原因,每个核心3个线程,就是NT系统里的神奇的平衡点。

如果你的服务正在进行大量不同类型的工作呢?这可能会产生不同的且不一致的延迟,也许还会创建许多需要处理的不同级别的系统事件。当使用线程池来调整服务的性能时,想正确找到一个负载的配置非常困难,可能无法找到一个适用于所有不同工作的平衡点。

Cache Line

由于直接从主存储器内访问并获取数据具有非常高的延迟(大约100~300个时钟周期),处理器和内核会使用本地高速缓存来保持线程对数据的访问速度,
从高速缓存访问数据的成本要低很多(大约3~40个时钟周期),具体要取决于使用的高速缓存。如今,性能的一个主要方面就体现在如何高效将数据导入处理器来减少访问延迟,因此在编写会切换状态的多线程应用程序时,需要考虑操作系统缓存的机制。

图2

92_figure2.png

数据通过Cache line在处理器与主存之间交换。Cache line是主存与缓存系统之间64字节的内存块,每个核心都有自己需要的cache line副本,这意味着硬件层面使用的是值传递,这也就是为什么多线程应用中内存突变会导致性能骤降的原因。

当运行一个并行运行的多线程应用时,多个线程访问相同的数据或十分接近的数值时,他们会访问同一个缓存的上的数据,任何线程在任何内核上都能得到自己cache line的复制。

图3

92_figure3.png

如果给定核心上的一个线程对其cache line的副本进行了更改,那么通过硬件的传输,同一cache line的所有其他副本都必须标记为脏数据。当线程尝试对脏cache line进行读写访问的时候,需要去访问主存(约100~300个时钟周期)来获取新副本

也许在2核处理器上这不是什么大问题,但是并行运行32个线程的32核处理器在同一个cache line上访问和改变数据呢?如果让一个两核处理器的机器,每个物理内核虚拟16个核呢?此时情况会变得很糟糕,应用程序将会遭遇内存颠簸,性能会急剧下降,而且很可能你无法理解这是为什么。

这称为缓存一致性问题。在编写会更新共享状态的多线程应用程序时,必须考虑缓存系统。

调度决策

想象一下,我要求你根据我给你的信息编写OS调度程序,那么现在你必须考虑一下这种情况,一个在调度器作出调度决策时必须考虑很多有趣的事情之一。

你的应用程序启动,创建了一个主线程并在核1上执行。当线程执行指令的时候,因为需要数据,开始检索cache line。主线程现在要为某些并发处理创建一个新线程,这时问题来了。

一旦创建了Thread并准备好了,调度器应该是:

  1. 在核1上对主线程进行上下文切换?这样做有助于提升性能,因为新线程需要的相同数据已经被缓存的可能性很大。但是主线程并没有得到足够时间片。
  2. 让新线程在主线程的时间片用完之前变为可用状态?此时线程未运行,但是一旦启动可以消除获取数据的延迟。
  3. 让新线程等待下一个可用的核心?这意味着需要刷新,检索并复制核1的cache line,从而导致延迟。但是线程可以更快的启动,主线程也可以获得足够的时间片

结论

此系列的第一部分主要阐述了在编写多线程应用程序的时候对OS调度与线程之间必须考虑的内容与见解。这也是Go的调度器所需考虑的事情。在下一部分,我将描述Go的调度器是如何与以上内容产生关联的,在最后你将通过运行几个程序来看到上述所有问题的答案。

推荐阅读更多精彩内容