上一章中对于golang的常用关键字说明如下:

接下来我们来对golang的并发编程进行说明,主要内容有:

  • 5 调度器
  • 6 网络轮询器
  • 7 系统监控

— — — — — — — — — — — — — — — — — — — — — — — — — — — —

Go 语言在并发编程方面有强大的能力,这离不开语言层面对并发编程的支持。本节会介绍 Go 语言运行时调度器的实现原理,其中包含调度器的设计与实现原理、演变过程以及与运行时调度相关的数据结构。

谈到 Go 语言调度器,我们绕不开的是操作系统、进程与线程这些概念,线程是操作系统调度时的最基本单元,而 Linux 在调度器并不区分进程和线程的调度,它们在不同操作系统上也有不同的实现,但是在大多数的实现中线程都属于进程:


图 - 进程和线程

多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。

虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1 兆以上的内存空间,在对线程进行切换时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁对应的资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间1,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销2。


图 - 线程与 Goroutine

Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。

5.1 设计原理

今天的 Go 语言调度器有着优异的性能,但是如果我们回头看 Go 语言的 0.x 版本的调度器就会发现最初的调度器不仅实现非常简陋,也无法支撑高并发的服务。调度器经过几个大版本的迭代才有今天的优异性能,几个不同版本的调度器引入了不同的改进,也存在不同的缺陷:

  • 单线程调度器 · 0.x
    • 只包含 40 多行代码;
    • 程序中只能存在一个活跃线程,由 G-M 模型组成;
  • 多线程调度器 · 1.0
    • 允许运行多线程的程序;
    • 全局锁导致竞争严重;
  • 任务窃取调度器 · 1.1
    • 引入了处理器 P,构成了目前的 G-M-P 模型;
    • 在处理器 P 的基础上实现了基于工作窃取的调度器;
    • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;
    • 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;
  • 抢占式调度器 · 1.2 ~ 至今
    • 基于协作的抢占式调度器 - 1.2 ~ 1.13
      • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;
      • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;
    • 基于信号的抢占式调度器 - 1.14 ~ 至今
      • 实现基于信号的真抢占式调度
      • 垃圾回收在扫描栈时会触发抢占调度;
      • 抢占的时间点不够多,还不能覆盖全部的边缘情况;
  • 非均匀存储访问调度器 · 提案
    • 对运行时的各种资源进行分区;
    • 实现非常复杂,到今天还没有提上日程;


除了多线程、任务窃取和抢占式调度器之外,Go 语言社区目前还有一个非均匀存储访问(Non-uniform memory access,NUMA)调度器的提案,Go 语言在未来也有实现该提案的可能。在这一节中,我们将依次介绍不同版本调度器的实现原理以及未来可能会实现的调度器提案。

单线程调度器

0.x 版本调度器只包含表示 Goroutine 的 G 和表示线程的 M 两种结构,全局也只有一个线程。我们可以在 clean up scheduler 提交中找到单线程调度器的源代码,在这时 Go 语言的调度器还是由 C 语言实现的,调度函数 也只包含 40 多行代码 :

该函数会遵循如下的过程调度 Goroutine:

m

虽然这个单线程调度器的唯一优点就是能运行,但是这次提交已经包含了 G 和 M 两个重要的数据结构,也建立了 Go 语言调度器的框架。

多线程调度器

Go 语言在 1.0 版本正式发布时就支持了多线程的调度器,与上一个版本几乎不可用的调度器相比,Go 语言团队在这一阶段实现了从不可用到可用的跨越。我们可以在 文件中找到 1.0.1 版本的调度器,多线程版本的调度函数 包含 70 多行代码,我们在这里保留了该函数的核心逻辑:

GOMAXPROCS

多线程调度器的主要问题是调度时的锁竞争会严重浪费资源,Scalable Go Scheduler Design Doc 中对调度器做的性能测试发现 14% 的时间都花费在 上3,该调度器有以下问题需要解决:

  1. 调度器和锁是全局资源,所有的调度状态都是中心化存储的,锁竞争问严重;
  2. 线程需要经常互相传递可运行的 Goroutine,引入了大量的延迟;
  3. 每个线程都需要处理内存缓存,导致大量的内存占用并影响数据局部性(Data locality);
  4. 系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销;

这里的全局锁问题和 Linux 操作系统调度器在早期遇到的问题比较相似,解决的方案也都大同小异。

任务窃取调度器

2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:

  1. 在当前的 G-M 模型中引入了处理器 P,增加中间层;
  2. 在处理器 P 的基础上实现基于工作窃取的调度器;

基于任务窃取的 Go 语言调度器使用了沿用至今的 G-M-P 模型,我们能在 runtime: improved scheduler 提交中找到任务窃取调度器刚被实现时的源代码,调度器的 函数在这个版本的调度器中反而更简单了:

当前处理器本地的运行队列中不包含 Goroutine 时,调用 函数会触发工作窃取,从其它的处理器的队列中随机获取一些 Goroutine。

运行时 G-M-P 模型中引入的处理器 P 是线程和 Goroutine 的中间层,我们从它的结构体中就能看到处理器与 M 和 G 的关系:

runq


图 - G-M-P 模型

基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上,这些线程会被不同处理器管理,不同的处理器通过工作窃取对任务进行再分配实现任务的平衡,也能提升调度器和 Go 语言程序的整体性能,今天所有的 Go 语言服务都受益于这一改动。

抢占式调度器

对 Go 语言并发模型的修改提升了调度器的性能,但是 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本4中引入基于协作的抢占式调度解决下面的问题5:

  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间6,导致整个程序无法工作;

1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。

基于协作的抢占式调度

我们可以在 文件中找到引入基于协作的抢占式调度后的调度器。Go 语言会在分段栈的机制上实现抢占调度,利用编译器在分段栈上插入的函数,所有 Goroutine 在函数调用时都有机会进入运行时检查是否需要执行抢占。Go 团队通过以下的多个提交实现该特性:


上面的多个提交实现了抢占式调度,但是还缺少最关键的一个环节 — 编译器如何在函数调用前插入函数,我们能在非常古老的提交 runtime: stack growth adjustments, cleanup 中找到编译器插入函数的出行,最新版本的 Go 语言会通过 插入 函数,该函数可能会调用 触发抢占。从上面的多个提交中,我们能归纳出基于协作的抢占式调度的工作原理:

StackPreemptstackguard0StackPreemptstackguard0StackPreempt

这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,总体来看还是比较成功的实现,也在 Go 语言中使用了 10 几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

基于信号的抢占式调度

基于协作的抢占式调度虽然实现巧妙,但是并不完备,我们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:

Go 语言在 1.14 版本中实现了非协作的抢占式调度,在实现的过程中我们重构已有的逻辑并为 Goroutine 增加新的状态和字段来支持抢占。Go 团队通过下面的一系列提交实现了这一功能,我们可以按时间顺序分析相关提交理解它的工作原理:


目前的抢占式调度也只会在垃圾回收扫描任务时触发,我们可以梳理一下上述代码实现的抢占式调度过程:


SIGURG
  1. 该信号需要被调试器透传;
  2. 该信号不会被内部的 libc 库使用并拦截;
  3. 该信号可以随意出现并且不触发任何后果;
  4. 我们需要处理多个平台上的不同信号;

STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言会在这里先加入抢占功能8。基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决全部问题,但是这种真抢占式调度时调度器走向完备的开始,相信在未来我们可以会更多的地方触发抢占。

非均匀内存访问调度器

非均匀内存访问(Non-uniform memory access,NUMA)调度器现在只是 Go 语言的提案9,因为该提案过于复杂,而目前的调度器的性能已经足够优异,所以我们暂时没有实现该提案。该提案的原理就是通过拆分全局资源,让各个处理器能够就近获取,减少锁竞争并增加数据的局部性。

在目前的运行时中,线程、处理器、网络轮询器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局资源。运行时没有保证本地化,也不清楚系统的拓扑结构,部分结构可以提供一定的局部性,但是从全局来看没有这种保证。


图 - - Go 语言 NUMA 调度器

如上图所示,堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮询器和计时器会由单独的处理器持有。这种方式虽然能够利用局部性提高调度器的性能,但是本身的实现过于复杂,所以 Go 语言团队还没有着手实现这一提案。

小结

Go 语言的调度器在最初的几个版本中迅速迭代,但是从 1.2 版本之后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度才解决了自 1.2 以来一直存在的问题。在可预见的未来,Go 语言的调度器还会进一步演进,增加触发抢占式调度的时间点以减少存在的边缘情况。

5.2 数据结构

相信各位读者已经对 Go 语言调度相关的数据结构已经非常熟悉了,但是我们在一些还是要回顾一下运行时调度器的三个重要组成部分 — 线程 M、Goroutine G 和处理器 P:


图 Go 语言调度器
  1. G — 表示 Goroutine,它是一个待执行的任务;
  2. M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
  3. P — 表示处理器,它可以被看做运行在线程上的本地调度器;

我们会在这一节中分别介绍不同的结构体,详细介绍它们的作用、数据结构以及在运行期间可能处于的状态。

G

Gorotuine 就是 Go 语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。

Goroutine 只存在于 Go 语言的运行时,它是 Go 语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的 CPU。

Goroutine 在 Go 语言运行时使用私有结构体 表示。这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,我们在这里也不会介绍全部字段,而是会挑选其中的一部分进行介绍,首先是与栈相关的两个字段:

stack[stack.lo, stack.hi)stackguard0stackguard0
deferpanicdeferpanic

最后,我们再节选一些作者认为比较有趣或者重要的字段:

matomicstatusschedgoid
sched
sppcgret

这些内容会在调度器保存或者恢复上下文的时候用到,其中的栈指针和程序计数器会用来存储或者恢复寄存器中的值,改变程序即将执行的代码。

atomicstatus
block" datta-size="
上述状态中比较常见是 _Grunnable、_Grunning、_Gsyscall、_Gwaiting 和 _Gpreempted 五个状态,我们会重点介绍这几个状态,Goroutine 的状态迁移是一个复杂的过程,触发 Goroutine 状态迁移的方法也很多,在这里我们也没有办法介绍全部的迁移线路,我们会从中选择一些进行介绍。


图 - Goroutine 的状态
虽然 Goroutine 在运行时中定义的状态非常多而且复杂,但是我们可以将这些不同的状态聚合成最终的三种:等待中、可运行、运行中,在运行期间我们会在这三种不同的状态来回切换:
等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 几个状态;可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;运行中:Goroutine 正在某个线程上运行,即 _Grunning;


图 - Goroutine 的常见状态迁移
上图展示了 Goroutine 状态迁移的常见路径,其中包括创建 Goroutine 到 Goroutine 被执行、触发系统调用或者抢占式调度器的状态迁移过程。
M
Go 语言并发模型中的 M 是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以使用 runtime.GOMAXPROCS 来改变程序中最大的线程数。


图 - CPU 和活跃线程
在默认情况下,一个四核机器上会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。
在大多数情况下,我们都会使用 Go 的默认设置,也就是线程数等于 CPU 个数,在这种情况下不会触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由 Go 语言调度器触发,能够减少非常多的额外开销。
操作系统线程在 Go 语言中会使用私有结构体 runtime.m 来表示,这个结构体中也包含了几十个私有的字段,我们依然对其进行了删减,先来了解几个与 Goroutine 直接相关的字段:
其中 g0 是持有调度栈的 Goroutine,curg 是在当前线程上运行的用户 Goroutine,这也是操作系统线程唯一关心的两个 Goroutine。


图 - 调度 Goroutine 和运行 Goroutine
g0 是一个运行时中比较特殊的 Goroutine,它会深度参与运行时的调度过程,包括 Goroutine 的创建、大内存分配和 CGO 函数的执行。在后面的小节中,我们会经常看到 g0 的身影。runtime.m 结构体中还存在着三个处理器字段,它们分别表示正在运行代码的处理器 p、暂存的处理器 nextp 和执行系统调用之前的使用线程的处理器 oldp:
除了在上面介绍的字段之外,runtime.m 中还包含大量与线程状态、锁、调度、系统调用有关的字段,我们会在分析调度过程时详细介绍。
P
调度器中的处理器 P 是线程和 Goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器 P 的调度,每一个内核线程都能够执行多个 Goroutine,它能在 Goroutine 进行一些 I/O 操作时及时切换,提高线程的利用率。
因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以 Go 语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行 Goroutine。
runtime.p 是处理器的运行时表示,作为调度器的内部实现,它包含的字段也非常多,其中包括与性能追踪、垃圾回收和计时器相关的字段,这些字段也非常重要,但是在这里就不一一展示了,我们主要关注处理器中的线程和运行队列:
反向存储的线程维护着线程与处理器之间的关系,而 runhead、runqtail 和 runq 三个字段表示处理器持有的运行队列,其中存储着待执行的 Goroutine 列表,runnext 中是线程下一个需要执行的 Goroutine。
runtime.p 结构体中的状态 status 字段会是以下五种中的一种:
le datle" data-tyle="nor
_Prunning_Psyscall

小结

我们在这一小节简单介绍了 Go 语言调度器中常见的数据结构,包括线程 M、处理器 P 和 Goroutine G,它们在 Go 语言运行时中分别使用不同的私有结构体表示,我们在下面会深入分析 Go 语言调度器的实现原理。

5.3 调度器启动

调度器的启动过程是我们平时比较难以接触的过程,不过作为程序启动前的准备工作,理解调度器的启动过程对我们理解调度器的实现原理很有帮助,运行时通过 函数初始化调度器:

maxmcountGOMAXPROCS
GOMAXPROCS
allpnewallp[0]allpallp[0]_Pidle

调用 就是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的 Goroutine 并为 Goroutine 调度处理器资源。

6.5.4 创建 Goroutine

go
gofuncval
g
  1. 获取或者创建新的 Goroutine 结构体;
  2. 将传入的参数移到 Goroutine 的栈上;
  3. 更新 Goroutine 调度相关的属性;
  4. 将 Goroutine 加入处理器的运行队列;

首先是 Goroutine 结构体的创建过程:

gFree
fnargpnarg
_Grunnable

在最后,该函数会将初始化好的 Goroutine 加入处理器的运行队列并在满足条件时调用 函数唤醒新的处理执行 Goroutine:

我们在分析 函数的过程中,省略了两个比较重要的过程,分别是用于获取结构体的 、 函数、将 Goroutine 加入运行队列的 以及调度信息的设置过程。

初始化结构体

通过两种不同的方式获取新的 结构体:


图 - 获取 Goroutine 结构体的三种方法
gFree
gFree
gFreegFree
allgs

简单总结一下, 会从处理器或者调度器的缓存中获取新的结构体,也可以调用 函数创建新的结构体。

运行队列

函数会将新创建的 Goroutine 运行队列上,这既可能是全局的运行队列,也可能是处理器本地的运行队列:

nexttruerunnextnextfalserunqputslow

处理器本地的运行队列是一个使用数组构成的环形链表,它最多可以存储 256 个待执行任务。


图 - 全局和本地运行队列

简单总结一下,Go 语言中有两个运行队列,其中一个是处理器本地的运行队列,另一个是调度器持有的全局运行队列,只有在本地运行队列没有剩余空间时才会使用全局队列。

调度信息

运行时创建 Goroutine 时会通过下面的代码设置调度相关的信息,前两行代码会分别将程序计数器和 Goroutine 设置成 函数和新创建的 Goroutine:

sched
sppcpcpcspsp

5.5 调度循环

stackguard0stackguard1

函数的会从不同地方查找待执行的 Goroutine:

schedtick

函数的实现非常复杂,这个 300 多行的函数通过以下的过程获取可运行的 Goroutine:

  1. 从本地运行队列、全局运行队列中查找;
  2. 从网络轮询器中查找是否有 Goroutine 等待运行;
  3. 通过 函数尝试从其他随机的处理器中窃取待运行的 Goroutine,在该过程中还可能窃取处理器中的计时器;

因为函数的实现过于复杂,上述执行过程是经过大量简化的,总而言之,当前函数一定会返回一个可执行的 Goroutine,如果当前不存在就会阻塞等待。

接下来由 函数执行获取的 Goroutine,做好准备工作后,它会通过 将 Goroutine 调度到当前线程上。

在不同处理器架构上的实现都不同,但是不同的实现也都大同小异,下面是该函数在 386 架构上的实现:

该函数的实现非常巧妙,它从 中取出了 的程序计数器和待执行函数的程序计数器,其中:

  • 的程序计数器被放到了栈 SP 上;
  • 待执行函数的程序计数器被放到了寄存器 BX 上;
CALL
CALL


图 - runtime.gogo 栈内存
JMP
_GdeadgFree

在最后 函数会重新调用 触发新的 Goroutine 调度,我们可以认为调度循环永远都不会返回。


图 - 调度循环

Go 语言中的运行时调度循环会从 函数开始,最终又回到 ;这里介绍的是 Goroutine 正常执行并退出的逻辑,实际情况会复杂得多,多数情况下 Goroutine 的执行的过程中都会经历协作式或者抢占式调度,这时会让出线程的使用权等待调度器的唤醒。

5.6 触发调度

调度器的 函数重新选择 Goroutine 在线程上执行,所以我们只要找到该函数的调用方就能找到所有触发调度的时间点,经过分析和整理,我们能得到如下的树形结构:


图 - 调度时间点

除了上图中可能触发调度的时间点,运行时还会在线程启动 和 Goroutine 执行结束 触发调度。我们在这里会重点介绍运行时触发调度的几个路径:

我们在这里介绍的调度时间点不是直接将线程的运行权交给其他任务,而是通过调度器的 重新调度。

主动挂起

是触发调度最常见的方法,该函数会将当前 Goroutine 暂停,被暂停的任务不会放回运行队列,我们来分析该函数的实现原理:

该函数会通过 在切换到 g0 的栈上调用 函数:

_Grunning_Gwaiting

当 Goroutine 等待的特定条件满足后,运行时会调用 将因为调用 而陷入休眠的 Goroutine 唤醒。

_Grunnable

系统调用

_Gsyscall
INVOKE_SYSCALL


图 - Go 语言系统调用

不过出于性能的考虑,如果这次系统调用不需要运行时参与,就会使用 简化这一过程,不再调用运行时函数。这里包含 Go 语言对 Linux 386 架构上不同系统调用的分类,我们会按需决定是否需要运行时的参与:

RawSyscallSYS_EPOLL_CREATESYS_EPOLL_WAITSYS_TIME

正常的系统调用过程相比之下比较复杂,接下来我们将分别介绍进入系统调用前的准备工作和系统调用结束后的收尾工作。

准备工作

函数会在获取当前程序计数器和栈位置之后调用 ,它会完成 Goroutine 进入系统调用前的准备工作:

_Gsyscall_Psyscall

需要注意的是 方法会使处理器和线程的分离,当前线程会陷入系统调用等待返回,当前线程上的锁被释放后,会有其他 Goroutine 抢占处理器资源。

恢复工作

当系统调用结束后,会调用退出系统调用的函数 为当前 Goroutine 重新分配资源,该函数有两个不同的执行路径:

exitsyscallfastexitsyscall0

这两种不同的路径会分别通过不同的方法查找一个用于执行当前 Goroutine 处理器 P,快速路径 中包含两个不同的分支:

_Psyscallwirepacquirep
_Grunnable
pidleget
schedule

协作式调度

我们在设计原理中介绍过了 Go 语言基于协作式和信号的两种抢占式调度,在这里我们介绍 Go 语言中的协作式调度。 就是主动让出处理器,允许其他 Goroutine 运行。该函数无法挂起 Goroutine,调度器会在自动调度当前 Goroutine:

_Grunnable

5.7 线程管理

Go 语言的运行时会通过调度器改变线程的所有权,它也提供了 和 让我们有能力绑定 Goroutine 和线程完成一些比较特殊的操作。Goroutine 应该在调用操作系统服务或者依赖线程状态的非 Go 语言库时调用 函数11,例如:C 语言图形库等。

会通过如下所示的代码绑定 Goroutine 和当前线程:

lockedglockedm

当 Goroutine 完成了特定的操作之后,就会调用以下函数 分离 Goroutine 和线程:

函数执行的过程与 正好相反。在多数的服务中,我们都用不到这一对函数,不过使用 CGO 或者经常与操作系统打交道的读者可能会见到它们的身影。

5.8 小结

Goroutine 和调度器是 Go 语言能够高效地处理任务并且最大化利用资源的基础,本节介绍了 Go 语言用于处理并发任务的 G - M - P 模型,我们不仅介绍了它们各自的数据结构以及常见状态,还通过特定场景介绍调度器的工作原理以及不同数据结构之间的协作关系,相信能够帮助各位读者理解调度器的实现。

全套教程点击下方链接直达: