潦草白纸

[翻译] 深入浅出Go语言调度器:第一部分 - 系统调度器

译者序


能力有限顶多比机翻强些,或许还不如。在翻译的过程中精读,无法做到信雅达,但求别混淆大家视听。之前开过Mysql的坑,一直没填上,想来也应该不会去填了。

Golang从语法上来说并不难,一些开源的组件用着还行不错,相对Java而言成熟度欠佳。
之前学习Java内存回收学得比较懵懂,看了一大堆内容没能抓住重点。导致常常看了没过两个月又忘了又去看。

这次打算精读,读完之后带着问题来整理。

原文地址


本篇是三篇系列文章的开篇,将会深入浅出的阐述Go语言调度器的机制与语义。将会着重于操作系统调度。

系列文章导引:
1)深入浅出Go语言调度器:第一部分 - 系统调度器
2)深入浅出Go语言调度器:第二部分 - Go 调度器
3)深入浅出Go语言调度器:第三部分 - 并发

介绍


Go 调度器的设计与实现,使得它在多线程领域表现得更高效、运行得更稳定。这主要在于Go 调度器为代码提供了相较操作系统调度器更好的硬件协作方式。不过代码的设计与实现是否完美的与硬件协作,以及Go 调度器如何工作,并不是本篇讨论的重点。文章将会着重探讨系统调度器和Go 调度器,从而为你的多线程软件设计提供帮助。

这一系列文章将会聚焦于调度器的高级机制和语义。我会阐述足够的细节,从而让你更形象的了解它是如何工作的,使得你在代码设计时做出更好的决策。尽管在并发编程中有很多知识需要了解,但调度器机制和语义仍然是比较基础的部分。

系统调度器


操作系统调度器是一个复杂的程序。它们必须考虑运行时硬件结构和设置,这包括但不限于多处理器核心、CPU 缓存和非统一内存访问架构(NUMA)。脱离这些知识,系统调度器无法达到如此高效。值得庆幸的是高屋建瓴宏观阔谈即可,无需深入研究这一些话题。

你的程序就是一系列机器指令,按照顺序一个接一个的执行。为了实现这一点,操作系统采用了线程(Thread)的概念。线程的职责是执行分配给它的指令,一直执行到没有更多指令需要执行为止。这就是为什么,我把线程称作“执行路径”。

你的每个程序都会创建一个进程,每个进程都会初始化线程,而后线程又可以创建更多线程。所有线程相互独立地运行,并且调度器在这一层面工作,而不是进程级别。线程能够并发(轮流使用一个单独内核),或是并行(同时使用多个不同的内核)。线程同时维护他们自身的状态,以确保安全、隔离、独立的执行它们的指令。

系统调度器的责任是一旦有线程可以执行,确保内核不闲着。它必须构建一个所有线程同时都在运行的错觉。在此过程中,调度器需要根据线程优先级策略先后排序,高优先级在前,低优先级在后。当然低优先级的也不会完全被饿死。调度器同时需要快速且机智做出决策,以最小化调度延迟。

实现以上描述的调度算法很多,不过幸运的是,业内已积累了数十年的经验。为了更好的理解这些,接下来我们看几个重要的概念。

执行指令


程序计数器(program counter 简称PC),有时也被称作指令指针(instruction pointer 简称IP),被用做跟踪线程下一个要执行的指令。在多数处理器中,程序计数器指向下一个指令,而不是当前指令。

  • 译者注
    程序计数器是一块较小的内存空间,它的作用可以看做是当前线程所执行代码行号指示器,以及下一个指令指针提示。工作时通过计数器的值来选取下一条需要执行的指令指针,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

Figure 1


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

如果你曾经看过Go程序的堆栈跟踪(stack trace),你可能注意到每行末尾哟一个十六进制数字。注意观察Listing 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

这些数字代表着程序计数器中记录的数值与相应函数顶部的偏移量。+0x39则是代表在程序没有崩溃的情况下,example函数所指向的下一个指令。当执行回到main函数,+0x72则是指向的接下来的一个指令。更重要的是,这些指针之前的语句告诉你什么指令正在被执行。

下面则是上述stack trace的源代码。

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表示example函数内的一条指令的程序计数器偏移量,该指令位于函数的起始指令后面第57条(10进制)。接下来,我们用 objdump 来看一下汇编指令。找到第57条指令,注意gopanic那一行。

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)

注意:程序计数器是记录的下一个指令,而不是当前指令。上面是基于amd64的汇编指令,演示了Go程序的线程执行顺序。

线程状态


另一个重要的概念则是线程状态,它决定了线程在调度器中的角色。一个线程可以处于三种状态之一:阻塞态(Waiting)、就绪态(Runnable)或运行态(Executing)。

  • 译者注
    实际上线程状态不止三种。不过如作者所说,只对此高谈阔论,不做太过深入讨论。

阻塞态:线程已经停止,此时等待某些事件结束后继续。这些事件包括硬件等待(磁盘、网络),系统调用,或者同步等待(原子操作、互斥)。这些事件通常是导致性能下降的根本原因。

就绪态:这意味着线程等待调度到内核上,因为它已经准备好执行分配的指令。如果你有大量的线程等待调度,那么线程将会等待较长的时间。并且,因为大量的线程竞争,分配给每个线程的时间也会缩短。这种导致的调度延迟也是降低性能的元凶之一。

运行态:线程已经被调度到了内核上,并且执行着相应的指令。与应用程序相关的任务正在被执行。这正是大家所想要的。

任务侧重


目前可以把线程任务划分两个侧重方向。首先是计算密集型,其次这是读写密集型。

计算密集型:这种任务侧重方向不会将线程陷入阻塞态。持续不断推动计算。譬如线程计算π到N位之后就是典型的计算密集型。

读写密集型:这种任务则会使得线程陷入阻塞态。这类任务包括请求网络资源,或系统调用。线程需要访问数据库则是典型的读写密集型。我同时也将同步事件(互斥,原子操作)纳入此类,因为它们同样会导致阻塞等待。

上下文切换


诸如Linux,Mac和Windows操作系统,都是抢占式调度器操作系统。这就意味着一些值得关注的事情。
首先,无法预测哪一个线程将会被调度器选中,然后分配 CPU 时间片运行。线程优先级和事件(像是接收网络传输数据)纠结在一起,使得我们无法确定调度器何时选择执行。

其次,因为每一次运行所产生的行为都不确定,所以意味着你无法凭借曾经的幸运经历来编写代码。人都是凭借经验自以为是的,我已历尽千帆,看破如是编程。如果你真想要控制你的应用,完全按照你的意图执行,那么必须要使用同步,编排管理线程。

在物理内核上切换线程,被称作上下文切换。其操作为,调度器将一个运行态线程拉出内核,切换另外一个就绪态线程。当前线程被选中,可能从就绪队列进入运行态,或是被拉回到到就绪态(当依然有能力继续运行),抑或进入阻塞态(当因为读写阻塞被切换)。

上下文切换通常被认为是代价高昂,因为线程在内核切换极为耗时。一次上下文切换耗时通常取决于不同的潜在因素,一般为~1000 到 ~1500 纳秒之间。假设从硬件层面单核每纳秒执行12条指令,一次上下文切换将导致 一万两千次 到 一万八千次 的指令延迟。

  • 译者注
    “一次上下文切换将导致 一万两千次 到 一万八千次 的指令延迟”,是怎么算出来的?
    指令延迟数 = 每纳秒指令执行数 X 上下文切换耗时,则为1000 X 12 = 12K,然后1500 X 12 = 18K。本质上来说,你的程序在做上下文切换时,丧失了大量的指令执行机会。

如果你有一个程序聚焦于读写密集型工作,那么上下文切换将会是一个优势。每当一个线程进入阻塞态,另一个就绪态线程则替换其位置。这套机制使得内核持续保持工作。它是调度器最重要的一个特性。在工作结束前(有线程处于就绪态),不让内核闲着。

如果你的程序是聚焦于计算密集型工作,那么上下文切换则变成了性能噩梦。因为线程常常在运行态,被上下文切换给强制中断。这种情况与读写密集型工作,恰好形成了鲜明的对比。

少即是多


早期的进程只在单核 CPU 上运行,调度器并不复杂。那时只有一个进程和一个内核,而且只有一个线程运行着。在那基础上发展出了调度周期,在周期内划分时间片调度就绪态线程运行。简单来说:用待运行线程数除以调度周期时间,得到每个线程可执行时间,然后以此时间周期去执行就行。

举例:定义调度周期为10毫秒。
假如有 2 个线程待运行,那么每个线程将各自分配 5毫秒
假如有 5 个线程待运行,那么每个线程将各自分配 2毫秒
以此类推,假如当你有 100 个线程的时候呢?然后分配 10微妙的时间片么!那只会被上下文切换给拐瘸了腿。

由上个例子可知,调度器需要限制合理的时间片长度。在之前提到的那个场景中,最小时间片为 2毫秒,且拥有100 个线程,则调度周期需要调整为 2秒。
再假如此时有1000 个线程,此时调度周期则调整为 20秒。在这个场景中,如果每个线程都使用相同时长的时间片,所有线程执行一轮则需要 20秒。

要知道提到的两个例子只是最简单调度场景。在考虑调度策略时需要考虑的事情还很多。解决问题可以考虑控制程序中的线程数。当执行读写密集型任务,并且大量线程执行的情况下,会出现很多混乱和不确定行为。此时业务代码需要从长计议。

所以游戏规则就是『少即是多』,越少的就绪态线程意味着越少的调度切换延迟,每个线程就会得到更多的时间。越多的线程陷入就绪态做调度等待,意味着每个线程会得到越少的时间,同时也就意味着你能完成的工作越少。

寻找平衡


你需要在硬件内核数与应用线程数之间寻找平衡,以达到最大的吞吐。当需要考虑维持平衡时,线程池是个极好的解决方案。我将在第二章中向你展示,Go语言根本不需要线程池。我认为Go语言的优点之一,则是它使得并发编程变得简单。

在用Go 编码之前,我使用 C++ 和 C# 在 Windows NT系统上开发。在这操作系统上,使用IOCP线程池对于编写多线程软件至关重要。作为工程师,您需要考虑所需线程数目,为了契合您所拥有的硬件设备CPU 内核数目,以达到最大吞吐量。

当编写与数据库通信的Web服务时,似乎每粒CPU 内核对应3 个线程,总是能在Windows NT上达到最佳的吞吐量。换句话说,每粒CPU 内核分配 3个线程,将使得上下文切换耗时最小化,从而最大化CPU 上的业务执行时间。当创建IOCP 线程池时,我知道启动至少分配1 至3 个线程,到主机上每粒CPU 内核上。

如果每粒CPU 内核分配2 个线程,那将会需要更长的时间来完成任务。因为在我完成某些任务时,总会会有些许的CPU 闲置时间;
如果每粒CPU 内核分配4 个线程 ,同样也会需要更长的时间。将会因为上下文切换而延迟。
每粒CPU 内核分配3 个线程,似乎在Windows NT上总是神奇数字

假如您的服务正在执行多种不同类型的任务,有会如何? 这将使得延迟变得复杂、不一致(译者注:同样都是读写型任务或计算型,相似的执行时间和读写等待,相较来说容易规划)。或许还会带来大量系统级事件需要被执行。难以找到那个神奇数字来全程应对多种任务负载。当使用线程池来优化服务性能时,难以发掘出正确一致的配置。

缓存行


从主内存访问数据是高延迟的操作(~100 到~300 时钟周期),因此处理器和CPU 内核会有本地缓存,使得数据靠近线程所在的内核。
从缓存访问数据延迟成本相对较低(~3 到 ~40 时钟周期)。
现而今,性能优化的一个导向在于处理器有效的获取数据,减少数据访问延迟。写并行应用程序常常在改变数据状态时,需要考虑系统的缓存机制。

Figure 2

数据在处理器与主内存之间,通过缓存行进行交换(译者注:此处链接指向YouTube,读者请自行翻出院子,不能翻 者。请点击这里,下载PDF)。缓存行是在主内存和缓存系统之间的64 字节内存块。每粒CPU 内核都有一份自己所需数据的副本,这就意味着硬件使用的是值传递。这就是为什么修改内存,在多线程应用程序中会带来性能噩梦。

当多个并行运行的线程访问相同数据,甚至相临近的数据内容时,它们将访问同一缓存行。任何线程运行在无论哪一粒CPU 内核,它们都将从同一缓存行获取自己的数据副本。

Figure 3

当某个线程修改了它所在CPU 内核的缓存行副本,此时神奇的硬件,会将其他所有的缓存行副本标记为脏数据。当某个线程尝试访问脏缓存行数据做读写时,将会访问主内存(耗时~100 到~300 时钟周期),重新加载新的副本数据到缓存行。

也许在双核处理器上这并不是太大的代价,但是假如在32 核处理器上运行32 个线程,并行访问、修改相同缓存行的数据呢?再假如一个系统运行在双处理器,且每个处理器有16 粒内核之上呢?这将带来更坏的情况,因为增加了处理器之间的通信延迟。应用程序会出现内存颠簸现象;并且应用性能将会变得糟糕,最有可能你还不知道是为什么。

调度器的调度场景决策


试想一下基于现有给您提供的高谈阔论,编写操作系统调度器。根据当前场景做出决策。谨记,这只是设计调度决策,所需要考虑的有意思的场景之一。

假设当前您的应用启动,创建主线程并在内核 1上运行。执行它所属指令,缓存行被装填以提供必要的支持。此时线程决定创建新线程,来并行处理业务。那么问题来了。

一旦线程被创建,且准备就绪,调度器该做这些:

  1. 通过上下文切换,直接将主线程内核 1调度取出?这么做有益于性能,因为新线程需要同样的数据,而这些数据已经恰好存放到了缓存行。但主线程无法获得全部时间片。
  2. 是否让线程挂起,等待主线程完成内核 1上分配的时间片任务?线程没有立刻运行,但一旦启动则规避了数据加载所带来的延迟。
  3. 是否让线程等待另一个可用内核?这将意味着内核缓存行失效,从而刷新、检索和复制,带来延迟。然而,线程启动更快,主线程可以完成他的时间片。

有趣么?这就是系统调度器,在做调度决策时时,需要考虑的一个有趣问题。答案是,如果有空闲内核,那就直接使用。我们的目标是,不要让CPU 闲着。

总结


本系列的第一部分,为您展示了编写多线程应用时,关于线程和系统调度器需要考虑的一些事情。这些也是Go 调度器所需要考虑的问题。

下集预告:
我将描述Go 调度器的实现,以及它是如何将这些信息关联起来的。在最后,给您通过一些示例项目来展示。

posted on 2019-10-14 18:44  潦草白纸  阅读(456)  评论(0编辑  收藏  举报

导航