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

时间:2023-01-21 07:46:27

译者序


能力有限顶多比机翻强些,或许还不如。在翻译的过程中精读,无法做到信雅达,但求别混淆大家视听。之前开过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

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

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。本质上来说,你的程序在做上下文切换时,丧失了大量的指令执行机会。

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

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

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

少即是多


早期的进程只在单核 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

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

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

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

Figure 3

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

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

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

调度器的调度场景决策


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

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

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

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

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

总结


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

下集预告:

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

[翻译] 深入浅出Go语言调度器:第一部分 - 系统调度器的更多相关文章

  1. &lbrack;翻译&rsqb;Go语言调度器

    Go语言调度器 译序 本文翻译 Daniel Morsing 的博文 The Go scheduler.个人认为这篇文章把Go Routine和调度器的知识讲的浅显易懂.作为一篇介绍性的文章.非常不错 ...

  2. go语言调度器源代码情景分析之一:开篇语

    专题简介 本专题以精心设计的情景为线索,结合go语言最新1.12版源代码深入细致的分析了goroutine调度器实现原理. 适宜读者 go语言开发人员 对线程调度器工作原理感兴趣的工程师 对计算机底层 ...

  3. Go语言调度器之主动调度&lpar;20&rpar;

    本文是<Go语言调度器源代码情景分析>系列的第20篇,也是第五章<主动调度>的第1小节. Goroutine的主动调度是指当前正在运行的goroutine通过直接调用runti ...

  4. Go语言调度器之盗取goroutine&lpar;17&rpar;

    本文是<Go语言调度器源代码情景分析>系列的第17篇,也是第三章<Goroutine调度策略>的第2小节. 上一小节我们分析了从全局运行队列与工作线程的本地运行队列获取goro ...

  5. Go语言调度器之调度main goroutine(14)

    本文是<Go语言调度器源代码情景分析>系列的第14篇,也是第二章的第4小节. 上一节我们通过分析main goroutine的创建详细讨论了goroutine的创建及初始化流程,这一节我们 ...

  6. Go语言调度器之创建main goroutine&lpar;13&rpar;

    本文是<Go语言调度器源代码情景分析>系列的第13篇,也是第二章的第3小节. 上一节我们分析了调度器的初始化,这一节我们来看程序中的第一个goroutine是如何创建的. 创建main g ...

  7. 详解Go语言调度循环源码实现

    转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/448 本文使用的go的源码15.7 概述 提到"调度&q ...

  8. java SE 入门之语言与环境&lpar;第一篇&rpar;

    Javase的语言与开发环境Keke2016年03月08日 Java属于-Oracle公司(甲骨文)创始人:Gosling1995年诞生1998年12月发布jdk1.22002年2月发布:jdk1.4 ...

  9. flask之web网关、三件套、配置、路由(参数、转化器及自定义转化器)、cbv、模板语言、session

    目录 1.wsgiref.py 2.werzeug.py 3.三件套 4.配置文件 5.路由本质 6.cbv.py 7.路由转化器 8.自定义转化器 9.模板语言 10.session原理 11.te ...

随机推荐

  1. Web前端开发高手进阶

     Web前端开发高手进阶 js框架+Ajax技术01.初识javascript及其语言基础(一)02.初识javascript及其语言基础(二)03.初识javascript及其语言基础(三)及js原 ...

  2. &lbrack;Android Pro&rsqb; AndroidStudio导出jar包

    reference :  http://blog.csdn.net/beijingshi1/article/details/38681281 不像在Eclipse,可以直接导出jar包.Android ...

  3. Flutter 即学即用系列博客——05 StatelessWidget vs StatefulWidget

    前言 上一篇我们对 Flutter UI 有了一个基本的了解. 这一篇我们通过自定义 Widget 来了解下如何写一个 Widget? 然而 Widget 有两个,StatelessWidget 和 ...

  4. 以最简单的方式讲HashMap

      以最简单的方式讲HashMap HashMap可以说是面试中最常出现的名词,这次头条的一面,第一个问的问题就是HashMap.所以就让我们来探讨下HashMap吧. 实验环境:JDK1.8 首先先 ...

  5. PHP filter 函数FILTER&lowbar;CALLBACK 过滤数据

    <?php function convertSpace($string) { return str_replace(" ", "_", $string); ...

  6. version &grave;GLIBC&lowbar;2&period;14&&num;39&semi; not found 解决方法&period;

    from http://blog.csdn.net/force_eagle/article/details/8684669 version `GLIBC_2.14' not found 解决方法. 一 ...

  7. C&plus;&plus;11奇怪的语法

    1. istream_iterator 简而言之,istream_iterator像操作容器一样操作istream.例如下面代码,从std::cin构造std::istream_iteream< ...

  8. ExcelHelper----根据指定样式的数据,生成excel(一个sheet1页)文件流

    /// <summary> /// Excel导出类 /// </summary> public class ExcelHelper { /// <summary> ...

  9. 【BZOJ1061】【NOI2008】志愿者招募

    [BZOJ1061][NOI2008]志愿者招募 题面 BZOJ 题解 我们设每类志愿者分别招募了\(B[i]\)个 那么,我们可以得到一系列的方程 \[\sum_{S[i]\leq x\leq T[ ...

  10. 简单谈谈Docker镜像的使用方法&lowbar;docker

    在上篇文章(在Docker中搭建Nginx服务器)中,我们已经介绍了如何快速地搭建一个实用的Nginx服务器.这次我们将围绕Docker镜像(Docker Image),介绍其使用方法.包括三部分: ...