图 8 - 抢占式调度
如果部分任务的执行时间很长,协作式的任务调度会使部分执行时间长的任务饿死其他任务;不过如果待执行的任务执行时间较短并且几乎相同,那么使用协作式的任务调度能减少任务中断带来的额外开销,从而带来更好的调度性能。
因为多数情况下任务执行的时间都不确定,在协作式调度中一旦任务没有主动让出资源,那么就会导致其它任务等待和阻塞,所以调度系统一般都会以抢占式的任务调度为主,同时支持任务的协作式调度。
单调度器与多调度器
使用单个调度器还是多个调度器也是设计调度系统时需要仔细考虑的,多个调度器并不一定意味着多个进程,也有可能是一个进程中的多个调度线程,它们既可以选择在多核上并行调度、在单核上并发调度,也可以同时利用并行和并发提高性能。
图 9 - 单调度器调度任务和资源
不过对于调度系统来说,因为它做出的决策会改变资源的状态和系统的上下文进而影响后续的调度决策,所以单调度器的串行调度是能够精准调度资源的唯一方法。单个调度器利用不同渠道收集调度需要的上下文,并在收到调度请求后会根据任务和资源情况做出当下最优的决策。
随着调度器的不断演变,单调度器的性能和吞吐量可能会受到限制,我们还是需要引入并行或者并发调度来解决性能上的瓶颈,这时我们需要将待调度的资源分区,让多个调度器分别负责调度不同区域中的资源。
图 10 - 多调度器与资源分区
多调度器的并发调度能够极大提升调度器的整体性能,例如 Go 语言的调度器。Go 语言运行时会将多个 CPU 交给不同的处理器分别调度,这样通过并行调度能够提升调度器的性能。
上面介绍的两种调度方法都建立在需要精准调度的前提下,多调度器中的每一个调度器都会面对无关的资源,所以对于同一个分区的资源,调度还是串行的。
图 11 - 多调度器粗粒度调度
使用多个调度器同时调度多个资源也是可行的,只是可能需要牺牲调度的精确性 — 不同的调度器可能会在不同时间接收到状态的更新,这就会导致不同调度器做出不同的决策。负载均衡就可以看做是多线程和多进程的调度器,因为对任务和资源掌控的信息有限,这种粗粒度调度的结果很可能就是不同机器的负载会有较大差异,所以无论是小规模集群还是大规模集群都很有可能导致某些实例的负载过高。
工作分享与工作窃取
这一小节将继续介绍在多个调度器间重新分配任务的两个调度范式 — 工作分享(Work Sharing)和工作窃取(Work Stealing)3。独立的调度器可以同时处理所有的任务和资源,所以它不会遇到多调度器的任务和资源的不平衡问题。在多数的调度场景中,任务的执行时间都是不确定的,假设多个调度器分别调度相同的资源,由于任务的执行时间不确定,多个调度器中等待调度的任务队列最终会发生差异 — 部分队列中包含大量任务,而另外一些队列不包含任务,这时就需要引入任务再分配策略。
工作分享和工作窃取是完全不同的两种再分配策略。在工作分享中,当调度器创建了新任务时,它会将一部分任务分给其他调度器;而在工作窃取中,当调度器的资源没有被充分利用时,它会从其他调度器中窃取一些待分配的任务,如下图所示:
图 12 - 工作窃取调度器
这两种任务再分配的策略都为系统增加了额外的开销,与工作分享相比,工作窃取只会在当前调度器的资源没有被充分利用时才会触发,所以工作窃取引入的额外开销更小。工作窃取在生产环境中更加常用,Linux 操作系统和 Go 语言都选择了工作窃取策略。
架构设计
本节将从调度器内部和外部两个角度分析调度器的架构设计,前者分析调度器内部多个组件的关系和做出调度决策的过程;后者分析多个调度器应该如何协作,是否有其他的外部服务可以辅助调度器做出更合理的调度决策。
调度器内部
当调度器收到待调度任务时,会根据采集到的状态和待调度任务的规格(Spec)做出合理的调度决策,我们可以从下图中了解常见调度系统的内部逻辑。
图 13 - 调度器做出调度决策
常见的调度器一般由两部分组成 — 用于收集状态的状态模块和负责做决策的决策模块。
状态模块
状态模块会从不同途径收集尽可能多的信息为调度提供丰富的上下文,其中可能包括资源的属性、利用率和可用性等信息。根据场景的不同,上下文可能需要存储在 MySQL 等持久存储中,一般也会在内存中缓存一份以减少调度器访问上下文的开销。
决策模块
决策模块会根据状态模块收集的上下文和任务的规格做出调度决策,需要注意的是做出的调度决策只是在当下有效,在未来某个时间点,状态的改变可能会导致之前做的决策不符合任务的需求,例如:当我们使用 Kubernetes 调度器将工作负载调度到某些节点上,这些节点可能由于网络问题突然不可用,该节点上的工作负载也就不能正常工作,即调度决策失效。
调度器在调度时都会通过以下的三个步骤为任务调度合适的资源:
通过优先级、任务创建时间等信息确定不同任务的调度顺序;
通过过滤和打分两个阶段为任务选择合适的资源;
不存在满足条件的资源时,选择牺牲的抢占对象;
图 14 - 调度框架
上图展示了常见调度器决策模块执行的几个步骤,确定优先级、对闲置资源进行打分、确定抢占资源的牺牲者,上述三个步骤中的最后一个往往都是可选的,部分调度系统不需要支持抢占式调度的功能。
调度器外部
如果我们将调度器看成一个整体,从调度器外部看架构设计就会得到完全不同的角度 — 如何利用外部系统增强调度器的功能。在这里我们将介绍两种调度器外部的设计,分别是多调度器和反调度器(Descheduler)。
多调度器
串行调度与并行调度一节已经分析了多调度器的设计,我们可以将待调度的资源进行分区,让多个调度器线程或者进程分别负责各个区域中资源的调度,充分利用多和 CPU 的并行能力。
反调度器
反调度器是一个比较有趣的概念,它能够移除决策不再正确的调度,降低系统中的熵,让调度器根据当前的状态重新决策。
图 15 - 调度器与反调度器
反调度器的引入使得整个调度系统变得更加健壮。调度器负责根据当前的状态做出正确的调度决策,反调度器根据当前的状态移除错误的调度决策,它们的作用看起来相反,但是目的都是为任务调度更合适的资源。
反调度器的使用没有那么广泛,实际的应用场景也比较有限。作者第一次发现这个概念是在 Kubernetes 孵化的 descheduler 项目4中,不过因为反调度器移除调度关系可能会影响正在运行的线上服务,所以 Kubernetes 也只会在特定场景下使用。
操作系统
调度器是操作系统中的重要组件,操作系统中有进程调度器(Process Scheduler)、网络调度器(Network Scheduler)和 I/O 调度器(I/O Scheduler)等组件,本节介绍的是操作系统中的进程调度器。
有一些读者可能会感到困惑,操作系统调度的最小单位不是线程么,为什么这里使用的是进程调度。在 Linux 操作系统中,调度器调度的不是进程也不是线程,它调度的是 task_struct
结构体,该结构体既可以表示线程,也可以表示进程,而调度器会将进程和线程都看成任务,我们在这里先说明这一问题,避免读者感到困惑5。我们会使用进程调度器这个术语,但是一定要注意 Linux 调度器中并不区分线程和进程。
Linux incorporates process and thread scheduling by treating them as one in the same. A process can be viewed as a single thread, but a process can contain multiple threads that share some number of resources (code and/or data).
接下来,本节会研究操作系统中调度系统的类型以及 Linux 进程调度器的演进过程。
调度系统类型
操作系统会将进程调度器分成三种不同的类型,即长期调度器、中期调度器和短期调度器。这三种不同类型的调度器分别提供了不同的功能,我们将在这一节中依次介绍它们。
长期调度器
长期调度器(Long-Term Scheduler)也被称作任务调度器(Job Scheduler),它能够决定哪些任务会进入调度器的准备队列。当我们尝试执行新的程序时,长期调度器会负责授权或者延迟该程序的执行。长期调度器的作用是平衡同时正在运行的 I/O 密集型或者 CPU 密集型进程的任务数量:
如果 I/O 密集型任务过多,就绪队列中就不存在待调度的任务,短期调度器不需要执行调度,CPU 资源就会面临闲置;
如果 CPU 密集型任务过多,I/O 等待队列中就不存在待调度的任务,I/O 设备就会面临闲置;
长期调度器能平衡同时正在运行的 I/O 密集型和 CPU 密集型任务,最大化的利用操作系统的 I/O 和 CPU 资源。
中期调度器
中期调度器会将不活跃的、低优先级的、发生大量页错误的或者占用大量内存的进程从内存中移除,为其他的进程释放资源。
图 16 - 中期调度器
当正在运行的进程陷入 I/O 操作时,该进程只会占用计算资源,在这种情况下,中期调度器就会将它从内存中移除等待 I/O 操作完成后,该进程会重新加入就绪队列并等待短期调度器的调度。
短期调度器
短期调度器应该是我们最熟悉的调度器,它会从就绪队列中选出一个进程执行。进程的选择会使用特定的调度算法,它会同时考虑进程的优先级、入队时间等特征。因为每个进程能够得到的执行时间有限,所以短期调度器的执行十分频繁。
设计与演进
本节将重点介绍 Linux 的 CPU 调度器,也就是短期调度器。Linux 的 CPU 调度器并不是从设计之初就是像今天这样复杂的,在很长的一段时间里(v0.01 ~ v2.4),Linux 的进程调度都由几十行的简单函数负责,我们先了解一下不同版本调度器的历史:
初始调度器 · v0.01 ~ v2.4
由几十行代码实现,功能非常简陋;
同时最多处理 64 个任务;
O(n)O(n)调度器 · v2.4 ~ v2.6
调度时需要遍历全部任务;
当待执行的任务较多时,同一个任务两次执行的间隔很长,会有比较严重的饥饿问题;
O(1)O(1)调度器 · v2.6.0 ~ v2.6.22
通过引入运行队列和优先数组实现 O(1)O(1)的时间复杂度;
使用本地运行队列替代全局运行队列增强在对称多处理器的扩展性;
引入工作窃取保证多个运行队列中任务的平衡;
完全公平调度器 · v2.6.23 ~ 至今
引入红黑树和运行时间保证调度的公平性;
引入调度类实现不同任务类型的不同调度策略;
这里会详细介绍从最初的调度器到今天复杂的完全公平调度器(Completely Fair Scheduler,CFS)的演变过程。
初始调度器
Linux 最初的进程调度器仅由 sched.h
和 sched.c
两个文件构成。你可能很难想象 Linux 早期版本使用只有几十行的 schedule
函数负责了操作系统进程的调度6:
C
无论是进程还是线程,在 Linux 中都被看做是 task_struct
结构体,所有的调度进程都存储在上限仅为 64 的数组中,调度器能够处理的进程上限也只有 64 个。
图 17 - 最初的进程调度器
上述函数会先唤醒获得信号的可中断进程,然后从队列倒序查找计数器 counter
最大的可执行进程,counter
是进程能够占用的时间切片数量,该函数会根据时间切片的值执行不同的逻辑:
如果最大的
counter
时间切片大于 0,调用汇编语言的实现的switch_to
切换进程;如果最大的
counter
时间切片等于 0,意味着所有进程的可执行时间都为 0,那么所有进程都会获得新的时间切片;
Linux 操作系统的计时器会每隔 10ms 触发一次 do_timer
将当前正在运行进程的 counter
减一,当前进程的计数器归零时就会重新触发调度。
O(n) 调度器
O(n)O(n)调度器是 Linux 在 v2.4 ~ v2.6 版本使用的调度器,由于该调取器在最坏的情况下会遍历所有的任务,所以它调度任务的时间复杂度就是 O(n)O(n)。Linux 调度算法将 CPU 时间分割成了不同的时期(Epoch),也就是每个任务能够使用的时间切片。
我们可以在 sched.h
和 sched.c
两个文件中找到 O(n)O(n)调度器的源代码。与上一个版本的调度器相比,O(n)O(n)调度器的实现复杂了很多,该调度器会在 schedule
函数中遍历运行队列中的所有任务并调用 goodness
函数分别计算它们的权重获得下一个运行的进程7:
Go
在每个时期开始时,上述代码都会为所有的任务计算时间切片,因为需要执行 n 次,所以调度器被称作 O(n)O(n)调度器。在默认情况下,每个任务在一个周期都会分配到 200ms 左右的时间切片,然而这种调度和分配方式是 O(n)O(n)调度器的最大问题:
每轮调度完成之后就会陷入没有任务需要调度的情况,需要提升交互性能的场景会受到严重影响,例如:在桌面拖动鼠标会感觉到明显的卡顿;
每次查找权重最高的任务都需要遍历数组中的全部任务;
调度器分配的平均时间片大小为 210ms8,当程序中包含 100 个进程时,同一个进程被运行两次的间隔是 21s,这严重影响了操作系统的可用性;
正是因为 O(n)O(n)调度器存在了上述的问题,所以 Linux 内核在两个版本后使用新的 O(1)O(1)调度器替换该实现。
O(1) 调度器
O(1)O(1)调度器在 v2.6.0 到 v2.6.22 的 Linux 内核中使用了四年的时间,它能够在常数时间内完成进程调度,你可以在 sched.h
和 sched.c
中查看 O(1)O(1)调度器的源代码。因为实现和功能复杂性的增加,调度器的代码行数从 O(n)O(n)的 2100 行增加到 5000 行,它在 O(n)O(n)调度器的基础上进行了如下的改进9:
调度器支持了 O(1)O(1)时间复杂度的调度;
调度器支持了对称多处理(Symmetric multiprocessing,SMP)的扩展性;
调度器优化了对称多处理的亲和性;
数据结构
调度器通过运行队列 runqueue
和优先数组 prio_array
两个重要的数据结构实现了 O(1)O(1)的时间复杂度。每一个运行队列都持有两个优先数组,分别存储活跃的和过期的进程数组:
C
优先数组中的 nr_active
表示活跃的进程数,而 bitmap
和 list_head
共同组成了如下图所示的数据结构:
图 18 - 优先数组
优先数组的 bitmap
总共包含 140 位,每一位都表示对应优先级的进程是否存在。图 17 中的优先数组包含 3 个优先级为 2 的进程和 1 个优先级为 5 的进程。每一个优先级的标志位都对应一个 list_head
数组中的链表。O(1)O(1)调度器使用上述的数据结构进行如下所示的调度:
调用
sched_find_first_bit
按照优先级分配 CPU 资源;调用
schedule
从链表头选择进程执行;通过
schedule
轮询调度同一优先级的进程,该函数在每次选中待执行的进程后,将进程添加到队列的末尾,这样可以保证同一优先级的进程会依次执行(Round-Robin);计时器每隔 1ms 会触发一次
scheduler_tick
函数,如果当前进程的执行时间已经耗尽,就会将其移入过期数组;当活跃队列中不存在待运行的进程时,
schedule
会交换活跃优先数组和过期优先数组;
上述的这些规则是 O(1)O(1)调度器运行遵守的主要规则,除了上述规则之外,调度器还需要支持抢占、CPU 亲和等功能,不过在这里就不展开介绍了。
本地运行队列
全局的运行队列是 O(n)O(n)调度器难以在对称多处理器架构上扩展的主要原因。为了保证运行队列的一致性,调度器在调度时需要获取运行队列的全局锁,随着处理器数量的增加,多个处理器在调度时会导致更多的锁竞争,严重影响调度性能。O(1)O(1)调度器通过引入本地运行队列解决这个问题,不同的 CPU 可以通过 this_rq
获取绑定在当前 CPU 上的运行队列,降低了锁的粒度和冲突的可能性。
C
图 19 - 全局运行队列和本地运行队列
多个处理器由于不再需要共享全局的运行队列,所以增强了在对称对处理器架构上的扩展性,当我们增加新的处理器时,只需要增加新的运行队列,这种方式不会引入更多的锁冲突。
优先级和时间切片
调度器中包含两种不同的优先级计算方式,一种是静态任务优先级,另一种是动态任务优先级。在默认情况下,任务的静态任务优先级都是 0,不过我们可以通过系统调用 nice
改变任务的优先级;O(1)O(1)调度器会奖励 I/O 密集型任务并惩罚 CPU 密集型任务,它会通过改变任务的静态优先级来完成优先级的动态调整,因为与用户交互的进程时 I/O 密集型的进程,这些进程由于调度器的动态策略会提高自身的优先级,从而提升用户体验。
完全公平调度器
完全公平调度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入内核的调度器,也是内核的默认进程调度器,它的目的是最大化 CPU 利用率和交互的性能10。Linux 内核版本 v2.6.23 中的 CFS 由以下的多个文件组成:
通过 CFS 的名字我们就能发现,该调度器的能为不同的进程提供完全公平性。一旦某些进程受到了不公平的待遇,调度器就会运行这些进程,从而维持所有进程运行时间的公平性。这种保证公平性的方式与『水多了加面,面多了加水』有一些相似:
调度器会查找运行队列中受到最不公平待遇的进程,并为进程分配计算资源,分配的计算资源是与其他资源运行时间的差值加上最小能够运行的时间单位;
进程运行结束之后发现运行队列中又有了其他的进程受到了最不公平的待遇,调度器又会运行新的进程;
…
调度器算法不断计算各个进程的运行时间并依次调度队列中的受到最不公平对待的进程,保证各个进程的运行时间差不会大于最小运行的时间单位。
数据结构
虽然我们还是会延用运行队列这一术语,但是 CFS 的内部已经不再使用队列来存储进程了,cfs_rq
是用来管理待运行进程的新结构体,该结构体会使用红黑树(Red-black tree)替代链表:
C
红黑树(Red-black tree)是平衡的二叉搜索树11,红黑树的增删改查操作的最坏时间复杂度为 O(logn)O(logn),也就是树的高度,树中最左侧的节点 rb_leftmost
运行的时间最短,也是下一个待运行的进程。
注:在最新版本的 CFS 实现中,内核使用虚拟运行时间 vruntime 替代了等待时间,但是基本的调度原理和排序方式没有太多变化。
调度过程
CFS 的调度过程还是由 schedule 函数完成的,该函数的执行过程可以分成以下几个步骤:
关闭当前 CPU 的抢占功能;
如果当前 CPU 的运行队列中不存在任务,调用
idle_balance
从其他 CPU 的运行队列中取一部分执行;调用
pick_next_task
选择红黑树中优先级最高的任务;调用
context_switch
切换运行的上下文,包括寄存器的状态和堆栈;重新开启当前 CPU 的抢占功能;
CFS 的调度过程与 O(1)O(1)调度器十分类似,当前调度器与前者的区别只是增加了可选的工作窃取机制并改变了底层的数据结构。
调度类
CFS 中的调度类是比较有趣的概念,调度类可以决定进程的调度策略。每个调度类都包含一组负责调度的函数,调度类由如下所示的 sched_class
结构体表示:
C
调度类中包含任务的初始化、入队和出队等函数,这里的设计与面向对象中的设计稍微有些相似。内核中包含 SCHED_NORMAL
、SCHED_BATCH
、SCHED_IDLE
、SCHED_FIFO
和 SCHED_RR
调度类,这些不同的调度类分别实现了 sched_class
中的函数以提供不同的调度行为。
小结
本节介绍了操作系统调度器的设计原理以及演进的历史,从 2007 年合入 CFS 到现在已经过去了很长时间,目前的调度器12也变得更加复杂,社区也在不断改进进程调度器。
我们可以从 Linux 调度器的演进的过程看到主流系统架构的变化,最初几十行代码的调度器就能完成基本的调度功能,而现在要使用几万行代码来完成复杂的调度,保证系统的低延时和高吞吐量。
由于篇幅有限,我们很难对操作系统的调度器进行面面俱到的分析,你可以在 这里 找到作者使用的 Linux 源代码,亲自动手分析不同版本的进程调度器。
延伸阅读
What is long term scheduler, short term scheduler and mid term term scheduler in OS?
A brief history of the Linux Kernel’s process scheduler: The very first scheduler, v0.01
Go 语言
Go 语言是诞生自 2009 年的编程语言,相信很多人对 Go 语言的印象都是语法简单,能够支撑高并发的服务。语法简单是编程语言的顶层设计哲学,而语言的高并发支持依靠的是运行时的调度器(Runtime Scheduler),这也是本节将要研究的内容。
对 Go 语言稍微有了解的人都知道,通信顺序进程(Communicating sequential processes,CSP)13影响着 Go 语言的并发模型,其中的 Goroutine 和 Channel 分别表示实体和用于通信的媒介。
图 20 - Go 和 Erlang 的并发模型
『不要通过共享内存来通信,我们应该使用通信来共享内存』不只是 Go 语言鼓励的设计哲学,更为古老的 Erlang 语言其实也遵循了同样的设计,但是 Erlang 选择使用了 Actor 模型14,我们在这里就不介绍 CSP 和 Actor 的区别和联系的,感兴趣的读者可以在推荐阅读和应引用中找到相关资源。
设计与演进
今天的 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 语言实现的,调度函数 runtime.schedule
也只包含 40 多行代码 :
C
该函数会遵循如下的过程调度 Goroutine:
获取调度器的全局锁;
调用
runtime.gosave
保存栈寄存器和程序计数器;调用
runtime.nextgandunlock
获取下一个需要运行的 Goroutine 并解锁调度器;修改全局线程
m
上要执行的 Goroutine;调用
runtime.gogo
函数运行最新的 Goroutine;
虽然这个单线程调度器的唯一优点就是能运行,但是这次提交已经包含了 G 和 M 两个重要的数据结构,也建立了 Go 语言调度器的框架。
多线程调度器
Go 语言在 1.0 版本正式发布时就支持了多线程的调度器,与上一个版本几乎不可用的调度器相比,Go 语言团队在这一阶段实现了从不可用到可用的跨越。我们可以在 pkg/runtime/proc.c
文件中找到 1.0.1 版本的调度器,多线程版本的调度函数 runtime.schedule
包含 70 多行代码,我们在这里保留了该函数的核心逻辑:
C
整体的逻辑与单线程调度器没有太多区别,因为我们的程序中可能同时存在多个活跃线程,所以多线程调度器引入了 GOMAXPROCS
变量帮助我们灵活控制程序中的最大处理器数,即活跃线程数。
多线程调度器的主要问题是调度时的锁竞争会严重浪费资源,Scalable Go Scheduler Design Doc 中对调度器做的性能测试发现 14% 的时间都花费在 runtime.futex
上15,该调度器有以下问题需要解决:
调度器和锁是全局资源,所有的调度状态都是中心化存储的,锁竞争问严重;
线程需要经常互相传递可运行的 Goroutine,引入了大量的延迟;
每个线程都需要处理内存缓存,导致大量的内存占用并影响数据局部性(Data locality);
系统调用频繁阻塞和解除阻塞正在运行的线程,增加了额外开销;
这里的全局锁问题和 Linux 操作系统调度器在早期遇到的问题比较相似,解决的方案也都大同小异。
任务窃取调度器
2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:
在当前的 G-M 模型中引入了处理器 P,增加中间层;
在处理器 P 的基础上实现基于工作窃取的调度器;
基于任务窃取的 Go 语言调度器使用了沿用至今的 G-M-P 模型,我们能在 runtime: improved scheduler 提交中找到任务窃取调度器刚被实现时的源代码,调度器的 runtime.schedule
函数在这个版本的调度器中反而更简单了:
Go
如果当前运行时在等待垃圾回收,调用
runtime.gcstopm
函数;调用
runtime.runqget
和runtime.findrunnable
从本地或者全局的运行队列中获取待执行的 Goroutine;调用
runtime.execute
函数在当前线程 M 上运行 Goroutine;
当前处理器本地的运行队列中不包含 Goroutine 时,调用 findrunnable
函数会触发工作窃取,从其它的处理器的队列中随机获取一些 Goroutine。
运行时 G-M-P 模型中引入的处理器 P 是线程和 Goroutine 的中间层,我们从它的结构体中就能看到处理器与 M 和 G 的关系:
C
处理器持有一个由可运行的 Goroutine 组成的运行队列 runq
,还反向持有一个线程。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系。
图 21 - G-M-P 模型
基于工作窃取的多线程调度器将每一个线程绑定到了独立的 CPU 上,这些线程会被不同处理器管理,不同的处理器通过工作窃取对任务进行再分配实现任务的平衡,也能提升调度器和 Go 语言程序的整体性能,今天所有的 Go 语言服务都受益于这一改动。
抢占式调度器
对 Go 语言并发模型的修改提升了调度器的性能,但是 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源才能触发调度。Go 语言的调度器在 1.2 版本16中引入基于协作的抢占式调度解决下面的问题17:
某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间18,导致整个程序无法工作;
1.2 版本的抢占式调度虽然能够缓解这个问题,但是它实现的抢占式调度是基于协作的,在之后很长的一段时间里 Go 语言的调度器都有一些无法被抢占的边缘情况,例如:for 循环或者垃圾回收长时间占用线程,这些问题中的一部分直到 1.14 才被基于信号的抢占式调度解决。
基于协作的抢占式调度
我们可以在 pkg/runtime/proc.c
文件中找到引入基于协作的抢占式调度后的调度器。Go 语言会在分段栈的机制上实现抢占调度,利用编译器在分段栈上插入的函数,所有 Goroutine 在函数调用时都有机会进入运行时检查是否需要执行抢占。Go 团队通过以下的多个提交实现该特性:
为 Goroutine 引入
stackguard0
字段,该字段被设置成StackPreempt
意味着当前 Goroutine 发出了抢占请求;引入抢占函数
runtime.preemptone
和runtime.preemptall
,这两个函数会改变 Goroutine 的stackguard0
字段发出抢占请求;定义抢占请求
StackPreempt
;在
runtime.stoptheworld
中调用runtime.preemptall
设置所有处理器上正在运行的 Goroutine 的stackguard0
为StackPreempt
;在
runtime.newstack
函数中增加抢占的代码,当stackguard0
等于StackPreempt
时触发调度器抢占让出线程;在系统监控中,如果一个 Goroutine 的运行时间超过 10ms,就会调用
runtime.retake
和runtime.preemptone
;修复 Goroutine 因为周期性执行非阻塞的 CGO 或者系统调用不会被抢占的问题;
上面的多个提交实现了抢占式调度,但是还缺少最关键的一个环节 — 编译器如何在函数调用前插入函数,我们能在非常古老的提交 runtime: stack growth adjustments, cleanup 中找到编译器插入函数的出行,最新版本的 Go 语言会通过 cmd/internal/obj/x86.stacksplit
插入 runtime.morestack
函数,该函数可能会调用 runtime.newstack
触发抢占。从上面的多个提交中,我们能归纳出基于协作的抢占式调度的工作原理:
编译器会在调用函数前插入
runtime.morestack
;Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求
StackPreempt
;当发生函数调用时,可能会执行编译器插入的
runtime.morestack
函数,它调用的runtime.newstack
会检查 Goroutine 的stackguard0
字段是否为StackPreempt
;如果
stackguard0
是StackPreempt
,就会触发抢占让出当前线程;
这种实现方式虽然增加了运行时的复杂度,但是实现相对简单,也没有带来过多的额外开销,总体来看还是比较成功的实现,也在 Go 语言中使用了 10 几个版本。因为这里的抢占是通过编译器插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度。
基于信号的抢占式调度
基于协作的抢占式调度虽然实现巧妙,但是并不完备,我们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:
Go 语言在 1.14 版本中实现了非协作的抢占式调度,在实现的过程中我们重构已有的逻辑并为 Goroutine 增加新的状态和字段来支持抢占。Go 团队通过下面的一系列提交实现了这一功能,我们可以按时间顺序分析相关提交理解它的工作原理:
挂起 Goroutine 的过程是在垃圾回收的栈扫描时完成的,我们通过
runtime.suspendG
和runtime.resumeG
两个函数重构栈扫描这一过程;调用
runtime.suspendG
函数时会将处于运行状态的 Goroutine 的preemptStop
标记成true
;调用
runtime.preemptPark
函数可以挂起当前 Goroutine、将其状态更新成_Gpreempted
并触发调度器的重新调度,该函数能够交出线程控制权;在 x86 架构上增加异步抢占的函数
runtime.asyncPreempt
和runtime.asyncPreempt2
;支持通过向线程发送信号的方式暂停运行的 Goroutine;
在
runtime.sighandler
函数中注册SIGURG
信号的处理函数runtime.doSigPreempt
;实现
runtime.preemptM
函数,它可以通过SIGURG
信号向线程发送抢占请求;修改
runtime.preemptone
函数的实现,加入异步抢占的逻辑;
目前的抢占式调度也只会在垃圾回收扫描任务时触发,我们可以梳理一下上述代码实现的抢占式调度过程:
程序启动时,在
runtime.sighandler
函数中注册SIGURG
信号的处理函数runtime.doSigPreempt
;在触发垃圾回收的栈扫描时会调用
runtime.suspendG
挂起 Goroutine,该函数会执行下面的逻辑:将
_Grunning
状态的 Goroutine 标记成可以被抢占,即将preemptStop
设置成true
;调用
runtime.preemptM
触发抢占;runtime.preemptM
会调用runtime.signalM
向线程发送信号SIGURG
;操作系统会中断正在运行的线程并执行预先注册的信号处理函数
runtime.doSigPreempt
;runtime.doSigPreempt
函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用runtime.sigctxt.pushCall
;runtime.sigctxt.pushCall
会修改寄存器并在程序回到用户态时执行runtime.asyncPreempt
;汇编指令
runtime.asyncPreempt
会调用运行时函数runtime.asyncPreempt2
;runtime.preemptPark
会修改当前 Goroutine 的状态到_Gpreempted
并调用runtime.schedule
让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;
上述 9 个步骤展示了基于信号的抢占式调度的执行过程。除了分析抢占的过程之外,我们还需要讨论一下抢占信号的选择,提案根据以下的四个原因选择 SIGURG
作为触发异步抢占的信号19;
该信号需要被调试器透传;
该信号不会被内部的 libc 库使用并拦截;
该信号可以随意出现并且不触发任何后果;
我们需要处理多个平台上的不同信号;
STW 和栈扫描是一个可以抢占的安全点(Safe-points),所以 Go 语言会在这里先加入抢占功能20。基于信号的抢占式调度只解决了垃圾回收和栈扫描时存在的问题,它到目前为止没有解决全部问题,但是这种真抢占式调度时调度器走向完备的开始,相信在未来我们可以会更多的地方触发抢占。
非均匀内存访问调度器
非均匀内存访问(Non-uniform memory access,NUMA)调度器现在只是 Go 语言的提案21,因为该提案过于复杂,而目前的调度器的性能已经足够优异,所以我们暂时没有实现该提案。该提案的原理就是通过拆分全局资源,让各个处理器能够就近获取,减少锁竞争并增加数据的局部性。
在目前的运行时中,线程、处理器、网络轮询器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局资源。运行时没有保证本地化,也不清楚系统的拓扑结构,部分结构可以提供一定的局部性,但是从全局来看没有这种保证。
图 22 - Go 语言 NUMA 调度器
如上图所示,堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮询器和计时器会由单独的处理器持有。这种方式虽然能够利用局部性提高调度器的性能,但是本身的实现过于复杂,所以 Go 语言团队还没有着手实现这一提案。
小结
Go 语言的调度器在最初的几个版本中迅速迭代,但是从 1.2 版本之后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度才解决了自 1.2 以来一直存在的问题。在可预见的未来,Go 语言的调度器还会进一步演进,增加触发抢占式调度的时间点以减少存在的边缘情况。
本文转载自 Draveness 技术网站。
原文链接:https://draveness.me/system-design-scheduler
评论