调度系统设计精要 & 内存管理设计精要

系统设计精要是一系列深入研究系统设计方法的系列文章,文中不仅会分析系统设计的理论,还会分析多个实际场景下的具体实现。这是一个季更或者半年更的系列,如果你有想要了解的问题,可以在文章下面留言。

调度是一个非常广泛的概念,很多领域都会使用调度这个术语,在计算机科学中,调度就是一种将任务(Work)分配给资源的方法[^1]。任务可能是虚拟的计算任务,例如线程、进程或者数据流,这些任务会被调度到硬件资源上执行,例如:处理器 CPU 等设备。
图片
system-design-and-scheduler

图 1 - 调度系统设计精要

本文会介绍调度系统的常见场景以及设计过程中的一些关键问题,调度器的设计最终都会归结到一个问题上 — 如何对资源高效的分配和调度以达到我们的目的,可能包括对资源的合理利用、最小化成本、快速匹配供给和需求。

图片
mind-node

图 2 - 文章脉络和内容

 

除了介绍调度系统设计时会遇到的常见问题之外,本文还会深入分析几种常见的调度器的设计、演进与实现原理,包括操作系统的进程调度器,Go 语言的运行时调度器以及 Kubernetes 的工作负载调度器,帮助我们理解调度器设计的核心原理。

作者写这篇文章前前后后大概 2 个月的时间,全文大概 2w 字,建议收藏后阅读或者通过电脑阅读。

设计原理

调度系统其实就是调度器(Scheduler),我们在很多系统中都能见到调度器的身影,就像我们在上面说的,不止操作系统中存在调度器,编程语言、容器编排以及很多业务系统中都会存在调度系统或者调度模块。这些调度模块的核心作用就是对有限的资源进行分配以实现最大化资源的利用率或者降低系统的尾延迟,调度系统面对的就是资源的需求和供给不平衡的问题。

图片
scheduler-works-and-resources

图 3 - 调度器的任务和资源

我们在这一节中将从多个方面介绍调度系统设计时需要重点考虑的问题,其中包括调度系统的需求调研、调度原理以及架构设计。

需求调研

在着手构建调度系统之前,首要的工作就是进行详细的需求调研和分析,在这个过程中需要完成以下两件事:

  • 调研调度系统的应用场景,深入研究场景中待执行的任务(Work)和能用来执行任务的资源(Resource)的特性;
  • 分析调度系统的目的,可能是成本优先、质量优先、最大化资源的利用率等,调度目的一般都是动态的,会随着需求的变化而转变;

应用场景

调度系统应用的场景是我们首先需要考虑的问题,对应用场景的分析至关重要,我们需要深入了解当前场景下待执行任务和能用来执行任务的资源的特点。我们需要分析待执行任务的以下特征:

  • 任务是否有截止日期,必须在某个时间点之前完成;
  • 任务是否支持抢占,抢占的具体规则是什么;
  • 任务是否包含前置的依赖条件;
  • 任务是否只能在指定的资源上运行;
  • ...

而用于执行任务的资源也可能存在资源不平衡,不同资源处理任务的速度不一致的问题。

资源和任务特点的多样性决定了调度系统的设计,我们在这里举几个简单的例子帮助各位读者理解调度系统需求分析的过程。

图片
linux-banner

图 4 - Linux 操作系统

在操作系统的进程调度器中,待调度的任务就是线程,这些任务一般只会处于正在执行或者未执行(等待或者终止)的状态;而用于处理这些任务的 CPU 往往都是不可再分的,同一个 CPU 在同一时间只能执行一个任务,这是物理上的限制。简单总结一下,操作系统调度器的任务和资源有以下特性:

  • 任务 —— Thread
    • 状态简单:只会处于正在执行或者未被执行两种状态;
    • 优先级不同:待执行的任务可能有不同的优先级,在考虑优先级的情况下,需要保证不同任务的公平性;
  • 资源 —— CPU 时间
    • 资源不可再分:同一时间只能运行一个任务;

在上述场景中,待执行的任务是操作系统调度的基本单位 —— 线程,而可分配的资源是 CPU 的时间。Go 语言的调度器与操作系统的调度器面对的是几乎相同的场景,其中的任务是 Goroutine,可以分配的资源是在 CPU 上运行的线程。

图片
kubernetes-banner

图 5 - 容器编排系统 Kubernetes

除了操作系统和编程语言这种较为底层的调度器之外,容器和计算任务调度在今天也很常见,Kubernetes 作为容器编排系统会负责调取集群中的容器,对它稍有了解的人都知道,Kubernetes 中调度的基本单元是 Pod,这些 Pod 会被调度到节点 Node 上执行:

  • 任务 —— Pod
    • 优先级不同:Pod 的优先级可能不同,高优先级的系统 Pod 可以抢占低优先级 Pod 的资源;
    • 有状态:Pod 可以分为无状态和有状态,有状态的 Pod 需要依赖持久存储卷;
  • 资源 —— Node
    • 类型不同:不同节点上的资源类型不同,包括 CPU、GPU 和内存等,这些资源可以被拆分但是都属于当前节点;
    • 不稳定:节点可能由于突发原因不可用,例如:无网络连接、磁盘损坏等;

调度系统在生活和工作中都很常见,除了上述的两个场景之外,其他需要调度系统的场景包括 CDN 的资源调度、订单调度以及离线任务调度系统等。在不同场景中,我们都需要深入思考任务和资源的特性,它们对系统的设计起者指导作用。

调度目的

在深入分析调度场景后,我们需要理解调度的目的。我们可以将调度目的理解成机器学习中的成本函数(Cost function),确定调度目的就是确定成本函数的定义,调度理论一书中曾经介绍过常见的调度目的包含以下的内容[^2]:

  • 完成跨度(Makesapan) — 第一个到最后一个任务完成调度的时间跨度;
  • 最大延迟(Maximum Lateness) — 超过截止时间最长的任务;
  • 加权完成时间的和(Total weighted completion time)— 权重乘完成时间的总和;
  • ...

这些都是偏理论的调度的目的,多数业务调度系统的调度目的都是优化与业务联系紧密的指标 — 成本和质量。如何在成本和质量之间达到平衡是需要仔细思考和设计的,由于篇幅所限以及业务场景的复杂,本文不会分析如何权衡成本和质量,这往往都是需要结合业务考虑的事情,不具有足够的相似性。

调度原理

性能优异的调度器是实现特定调度目的前提,我们在讨论调度场景和目的时往往都会忽略调度的额外开销,然而调度器执行时的延时和吞吐量等指标在调度负载较重时是不可忽视的。本节会分析与调度器实现相关的一些重要概念,这些概念能够帮助我们实现高性能的调度器:

  • 协作式调度与抢占式调度;
  • 单调度器与多调度器;
  • 任务分享与任务窃取;

协作式与抢占式

协作式(Cooperative)与抢占式(Preemptive)调度是操作系统中常见的多任务运行策略。这两种调度方法的定义完全不同:

  • 协作式调度允许任务执行任意长的时间,直到任务主动通知调度器让出资源;
  • 抢占式调度允许任务在执行过程中被调度器挂起,调度器会重新决定下一个运行的任务;
图片
cooperative-and-preemptive

图 6 - 协作式调度与抢占式调度

任务的执行时间和任务上下文切换的额外开销决定了哪种调度方式会带来更好的性能。如下图所示,图 7 展示了一个协作式调度器调度任务的过程,调度器一旦为某个任务分配了资源,它就会等待该任务主动释放资源,图中 4 个任务尽管执行时间不同,但是它们都会在任务执行完成后释放资源,整个过程也只需要 4 次上下文的切换。

图片
cooperative-scheduling

图 7 - 协作式调度

图 8 展示了抢占式调度的过程,由于调度器不知道所有任务的执行时间,所以它为每一个任务分配了一段时间切片。任务 1 和任务 4 由于执行时间较短,所以在第一次被调度时就完成了任务;但是任务 2 和任务 3 因为执行时间较长,超过了调度器分配的上限,所以为了保证公平性会触发抢占,等待队列中的其他任务会获得资源。在整个调度过程中,一共发生了 6 次上下文切换。

图片
preemptive-scheduling

图 8 - 抢占式调度

如果部分任务的执行时间很长,协作式的任务调度会使部分执行时间长的任务饿死其他任务;不过如果待执行的任务执行时间较短并且几乎相同,那么使用协作式的任务调度能减少任务中断带来的额外开销,从而带来更好的调度性能。

因为多数情况下任务执行的时间都不确定,在协作式调度中一旦任务没有主动让出资源,那么就会导致其它任务等待和阻塞,所以调度系统一般都会以抢占式的任务调度为主,同时支持任务的协作式调度。

单调度器与多调度器

使用单个调度器还是多个调度器也是设计调度系统时需要仔细考虑的,多个调度器并不一定意味着多个进程,也有可能是一个进程中的多个调度线程,它们既可以选择在多核上并行调度、在单核上并发调度,也可以同时利用并行和并发提高性能。

图片
single-schedule

图 9 - 单调度器调度任务和资源

不过对于调度系统来说,因为它做出的决策会改变资源的状态和系统的上下文进而影响后续的调度决策,所以单调度器的串行调度是能够精准调度资源的唯一方法。单个调度器利用不同渠道收集调度需要的上下文,并在收到调度请求后会根据任务和资源情况做出当下最优的决策。

随着调度器的不断演变,单调度器的性能和吞吐量可能会受到限制,我们还是需要引入并行或者并发调度来解决性能上的瓶颈,这时我们需要将待调度的资源分区,让多个调度器分别负责调度不同区域中的资源。

图片
multi-scheduler

图 10 - 多调度器与资源分区

多调度器的并发调度能够极大提升调度器的整体性能,例如 Go 语言的调度器。Go 语言运行时会将多个 CPU 交给不同的处理器分别调度,这样通过并行调度能够提升调度器的性能。

上面介绍的两种调度方法都建立在需要精准调度的前提下,多调度器中的每一个调度器都会面对无关的资源,所以对于同一个分区的资源,调度还是串行的。

图片
multi-scheduler-with-coarse-grained

图 11 - 多调度器粗粒度调度

使用多个调度器同时调度多个资源也是可行的,只是可能需要牺牲调度的精确性 — 不同的调度器可能会在不同时间接收到状态的更新,这就会导致不同调度器做出不同的决策。负载均衡就可以看做是多线程和多进程的调度器,因为对任务和资源掌控的信息有限,这种粗粒度调度的结果很可能就是不同机器的负载会有较大差异,所以无论是小规模集群还是大规模集群都很有可能导致某些实例的负载过高。

工作分享与工作窃取

这一小节将继续介绍在多个调度器间重新分配任务的两个调度范式 — 工作分享(Work Sharing)和工作窃取(Work Stealing)[^3]。独立的调度器可以同时处理所有的任务和资源,所以它不会遇到多调度器的任务和资源的不平衡问题。在多数的调度场景中,任务的执行时间都是不确定的,假设多个调度器分别调度相同的资源,由于任务的执行时间不确定,多个调度器中等待调度的任务队列最终会发生差异 — 部分队列中包含大量任务,而另外一些队列不包含任务,这时就需要引入任务再分配策略。

工作分享和工作窃取是完全不同的两种再分配策略。在工作分享中,当调度器创建了新任务时,它会将一部分任务分给其他调度器;而在工作窃取中,当调度器的资源没有被充分利用时,它会从其他调度器中窃取一些待分配的任务,如下图所示:

图片
work-stealing-scheduler

图 12 - 工作窃取调度器

这两种任务再分配的策略都为系统增加了额外的开销,与工作分享相比,工作窃取只会在当前调度器的资源没有被充分利用时才会触发,所以工作窃取引入的额外开销更小。工作窃取在生产环境中更加常用,Linux 操作系统和 Go 语言都选择了工作窃取策略。

架构设计

本节将从调度器内部和外部两个角度分析调度器的架构设计,前者分析调度器内部多个组件的关系和做出调度决策的过程;后者分析多个调度器应该如何协作,是否有其他的外部服务可以辅助调度器做出更合理的调度决策。

调度器内部

当调度器收到待调度任务时,会根据采集到的状态和待调度任务的规格(Spec)做出合理的调度决策,我们可以从下图中了解常见调度系统的内部逻辑。

图片
how-scheduler-works

图 13 - 调度器做出调度决策

常见的调度器一般由两部分组成 — 用于收集状态的状态模块和负责做决策的决策模块。

状态模块

状态模块会从不同途径收集尽可能多的信息为调度提供丰富的上下文,其中可能包括资源的属性、利用率和可用性等信息。根据场景的不同,上下文可能需要存储在 MySQL 等持久存储中,一般也会在内存中缓存一份以减少调度器访问上下文的开销。

决策模块

决策模块会根据状态模块收集的上下文和任务的规格做出调度决策,需要注意的是做出的调度决策只是在当下有效,在未来某个时间点,状态的改变可能会导致之前做的决策不符合任务的需求,例如:当我们使用 Kubernetes 调度器将工作负载调度到某些节点上,这些节点可能由于网络问题突然不可用,该节点上的工作负载也就不能正常工作,即调度决策失效。

调度器在调度时都会通过以下的三个步骤为任务调度合适的资源:

  1. 通过优先级、任务创建时间等信息确定不同任务的调度顺序;
  2. 通过过滤和打分两个阶段为任务选择合适的资源;
  3. 不存在满足条件的资源时,选择牺牲的抢占对象;
图片
scheduling-framework

图 14 - 调度框架

上图展示了常见调度器决策模块执行的几个步骤,确定优先级、对闲置资源进行打分、确定抢占资源的牺牲者,上述三个步骤中的最后一个往往都是可选的,部分调度系统不需要支持抢占式调度的功能。

调度器外部

如果我们将调度器看成一个整体,从调度器外部看架构设计就会得到完全不同的角度 — 如何利用外部系统增强调度器的功能。在这里我们将介绍两种调度器外部的设计,分别是多调度器和反调度器(Descheduler)。

多调度器

串行调度与并行调度一节已经分析了多调度器的设计,我们可以将待调度的资源进行分区,让多个调度器线程或者进程分别负责各个区域中资源的调度,充分利用多和 CPU 的并行能力。

反调度器

反调度器是一个比较有趣的概念,它能够移除决策不再正确的调度,降低系统中的熵,让调度器根据当前的状态重新决策。

图片
scheduler-and-descheduler

图 15 - 调度器与反调度器

反调度器的引入使得整个调度系统变得更加健壮。调度器负责根据当前的状态做出正确的调度决策,反调度器根据当前的状态移除错误的调度决策,它们的作用看起来相反,但是目的都是为任务调度更合适的资源。

反调度器的使用没有那么广泛,实际的应用场景也比较有限。作者第一次发现这个概念是在 Kubernetes 孵化的 descheduler 项目[^4]中,不过因为反调度器移除调度关系可能会影响正在运行的线上服务,所以 Kubernetes 也只会在特定场景下使用。

操作系统

调度器是操作系统中的重要组件,操作系统中有进程调度器、网络调度器和 I/O 调度器等组件,本节介绍的是操作系统中的进程调度器。

有一些读者可能会感到困惑,操作系统调度的最小单位不是线程么,为什么这里使用的是进程调度。在 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 资源。

中期调度器

中期调度器会将不活跃的、低优先级的、发生大量页错误的或者占用大量内存的进程从内存中移除,为其他的进程释放资源。

图片
mid-term-scheduler

图 16 - 中期调度器

当正在运行的进程陷入 I/O 操作时,该进程只会占用计算资源,在这种情况下,中期调度器就会将它从内存中移除等待 I/O 操作完成后,该进程会重新加入就绪队列并等待短期调度器的调度。

短期调度器

短期调度器应该是我们最熟悉的调度器,它会从就绪队列中选出一个进程执行。进程的选择会使用特定的调度算法,它会同时考虑进程的优先级、入队时间等特征。因为每个进程能够得到的执行时间有限,所以短期调度器的执行十分频繁。

设计与演进

本节将重点介绍 Linux 的 CPU 调度器,也就是短期调度器。Linux 的 CPU 调度器并不是从设计之初就是像今天这样复杂的,在很长的一段时间里(v0.01 ~ v2.4),Linux 的进程调度都由几十行的简单函数负责,我们先了解一下不同版本调度器的历史:

  • 初始调度器 · v0.01 ~ v2.4
    • 由几十行代码实现,功能非常简陋;
    • 同时最多处理 64 个任务;
  •  调度器 · v2.4 ~ v2.6
    • 调度时需要遍历全部任务;
    • 当待执行的任务较多时,同一个任务两次执行的间隔很长,会有比较严重的饥饿问题;
  •  调度器 · v2.6.0 ~ v2.6.22
    • 通过引入运行队列和优先数组实现  的时间复杂度;
    • 使用本地运行队列替代全局运行队列增强在对称多处理器的扩展性;
    • 引入工作窃取保证多个运行队列中任务的平衡;
  • 完全公平调度器 · v2.6.23 ~ 至今
    • 引入红黑树和运行时间保证调度的公平性;
    • 引入调度类实现不同任务类型的不同调度策略;

这里会详细介绍从最初的调度器到今天覆杂的完全公平调度器(Completely Fair Scheduler,CFS)的演变过程。

初始调度器

Linux 最初的进程调度器仅由 sched.h 和 sched.c 两个文件构成。你可能很难想象 Linux 早期版本使用只有几十行的 schedule 函数负责了操作系统进程的调度[^6]:

void schedule(void) {
	int i,next,c;
	struct task_struct ** p;
	for(p = &LAST_TASK ; p > &FIRST_TASK ; --p) {
	   ...
	}
	while (1) {
		c = -1;
		next = 0;
		i = NR_TASKS;
		p = &task[NR_TASKS];
		while (--i) {
			if (!*--p) continue;
			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
				c = (*p)->counter, next = i;
		}
		if (c) break;
		for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
			if (*p)
				(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
	}
	switch_to(next);
}

无论是进程还是线程,在 Linux 中都被看做是 task_struct 结构体,所有的调度进程都存储在上限仅为 64 的数组中,调度器能够处理的进程上限也只有 64 个。

图片
linux-initial-scheduler

图 17 - 最初的进程调度器

上述函数会先唤醒获得信号的可中断进程,然后从队列倒序查找计数器 counter 最大的可执行进程,counter 是进程能够占用的时间切片数量,该函数会根据时间切片的值执行不同的逻辑:

  • 如果最大的 counter 时间切片大于 0,调用汇编语言的实现的 switch_to 切换进程;
  • 如果最大的 counter 时间切片等于 0,意味着所有进程的可执行时间都为 0,那么所有进程都会获得新的时间切片;

Linux 操作系统的计时器会每隔 10ms 触发一次 do_timer 将当前正在运行进程的 counter 减一,当前进程的计数器归零时就会重新触发调度。

O(n) 调度器

 调度器是 Linux 在 v2.4 ~ v2.6 版本使用的调度器,由于该调取器在最坏的情况下会遍历所有的任务,所以它调度任务的时间复杂度就是 。Linux 调度算法将 CPU 时间分割成了不同的时期(Epoch),也就是每个任务能够使用的时间切片。

我们可以在 sched.h 和 sched.c 两个文件中找到  调度器的源代码。与上一个版本的调度器相比, 调度器的实现复杂了很多,该调度器会在 schedule 函数中遍历运行队列中的所有任务并调用 goodness 函数分别计算它们的权重获得下一个运行的进程[^7]:

asmlinkage void schedule(void)
{
	...
still_running_back:
	list_for_each(tmp, &runqueue_head) {
		p = list_entry(tmp, struct task_struct, run_list);
		if (can_schedule(p, this_cpu)) {
			int weight = goodness(p, this_cpu, prev->active_mm);
			if (weight > c)
				c = weight, next = p;
		}
	}
	...
}

在每个时期开始时,上述代码都会为所有的任务计算时间切片,因为需要执行 n 次,所以调度器被称作  调度器。在默认情况下,每个任务在一个周期都会分配到 200ms 左右的时间切片,然而这种调度和分配方式是  调度器的最大问题:

  • 每轮调度完成之后就会陷入没有任务需要调度的情况,需要提升交互性能的场景会受到严重影响,例如:在桌面拖动鼠标会感觉到明显的卡顿;
  • 每次查找权重最高的任务都需要遍历数组中的全部任务;
  • 调度器分配的平均时间片大小为 210ms[^8],当程序中包含 100 个进程时,同一个进程被运行两次的间隔是 21s,这严重影响了操作系统的可用性;

正是因为  调度器存在了上述的问题,所以 Linux 内核在两个版本后使用新的  调度器替换该实现。

O(1) 调度器

 调度器在 v2.6.0 到 v2.6.22 的 Linux 内核中使用了四年的时间,它能够在常数时间内完成进程调度,你可以在 sched.h 和 sched.c 中查看  调度器的源代码。因为实现和功能复杂性的增加,调度器的代码行数从  的 2100 行增加到 5000 行,它在  调度器的基础上进行了如下的改进[^9]:

  • 调度器支持了  时间复杂度的调度;
  • 调度器支持了对称多处理(Symmetric multiprocessing,SMP)的扩展性;
  • 调度器优化了对称多处理的亲和性;
数据结构

调度器通过运行队列 runqueue 和优先数组 prio_array 两个重要的数据结构实现了  的时间复杂度。每一个运行队列都持有两个优先数组,分别存储活跃的和过期的进程数组:

struct runqueue {
	...
	prio_array_t *active, *expired, arrays[2];
	...
}

struct prio_array {
	unsignedint nr_active;
	unsignedlong bitmap[BITMAP_SIZE];
	struct list_head queue[MAX_PRIO];
};

优先数组中的 nr_active 表示活跃的进程数,而 bitmap 和 list_head 共同组成了如下图所示的数据结构:

图片
runqueue-and-prio-array

图 18 - 优先数组

优先数组的 bitmap 总共包含 140 位,每一位都表示对应优先级的进程是否存在。图 17 中的优先数组包含 3 个优先级为 2 的进程和 1 个优先级为 5 的进程。每一个优先级的标志位都对应一个 list_head 数组中的链表。 调度器使用上述的数据结构进行如下所示的调度:

  1. 调用 sched_find_first_bit 按照优先级分配 CPU 资源;
  2. 调用 schedule 从链表头选择进程执行;
  3. 通过 schedule 轮训调度同一优先级的进程,该函数在每次选中待执行的进程后,将进程添加到队列的末尾,这样可以保证同一优先级的进程会依次执行(Round-Robin);
  4. 计时器每隔 1ms 会触发一次 scheduler_tick 函数,如果当前进程的执行时间已经耗尽,就会将其移入过期数组;
  5. 当活跃队列中不存在待运行的进程时,schedule 会交换活跃优先数组和过期优先数组;

上述的这些规则是  调度器运行遵守的主要规则,除了上述规则之外,调度器还需要支持抢占、CPU 亲和等功能,不过在这里就不展开介绍了。

本地运行队列

全局的运行队列是  调度器难以在对称多处理器架构上扩展的主要原因。为了保证运行队列的一致性,调度器在调度时需要获取运行队列的全局锁,随着处理器数量的增加,多个处理器在调度时会导致更多的锁竞争,严重影响调度性能。 调度器通过引入本地运行队列解决这个问题,不同的 CPU 可以通过 this_rq 获取绑定在当前 CPU 上的运行队列,降低了锁的粒度和冲突的可能性。

#define this_rq()		(&__get_cpu_var(runqueues))
图片
global-runqueue-and-local-runqueue

图 19 - 全局运行队列和本地运行队列

多个处理器由于不再需要共享全局的运行队列,所以增强了在对称对处理器架构上的扩展性,当我们增加新的处理器时,只需要增加新的运行队列,这种方式不会引入更多的锁冲突。

优先级和时间切片

调度器中包含两种不同的优先级计算方式,一种是静态任务优先级,另一种是动态任务优先级。在默认情况下,任务的静态任务优先级都是 0,不过我们可以通过系统调用 nice 改变任务的优先级; 调度器会奖励 I/O 密集型任务并惩罚 CPU 密集型任务,它会通过改变任务的静态优先级来完成优先级的动态调整,因为与用户交互的进程时 I/O 密集型的进程,这些进程由于调度器的动态策略会提高自身的优先级,从而提升用户体验。

完全公平调度器

完全公平调度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入内核的调度器,也是内核的默认进程调度器,它的目的是最大化 CPU 利用率和交互的性能[^10]。Linux 内核版本 v2.6.23 中的 CFS 由以下的多个文件组成:

  • include/linux/sched.h
  • kernel/sched_stats.h
  • kernel/sched.c
  • kernel/sched_fair.c
  • kernel/sched_idletask.c
  • kernel/sched_rt.c

通过 CFS 的名字我们就能发现,该调度器的能为不同的进程提供完全公平性。一旦某些进程受到了不公平的待遇,调度器就会运行这些进程,从而维持所有进程运行时间的公平性。这种保证公平性的方式与『水多了加面,面多了加水』有一些相似:

  1. 调度器会查找运行队列中受到最不公平待遇的进程,并为进程分配计算资源,分配的计算资源是与其他资源运行时间的差值加上最小能够运行的时间单位;
  2. 进程运行结束之后发现运行队列中又有了其他的进程受到了最不公平的待遇,调度器又会运行新的进程;
  3. ...

调度器算法不断计算各个进程的运行时间并依次调度队列中的受到最不公平对待的进程,保证各个进程的运行时间差不会大于最小运行的时间单位。

数据结构

虽然我们还是会延用运行队列这一术语,但是 CFS 的内部已经不再使用队列来存储进程了,cfs_rq 是用来管理待运行进程的新结构体,该结构体会使用红黑树(Red-black tree)替代链表:

struct cfs_rq {
	struct load_weight load;
	unsignedlong nr_running;

	s64 fair_clock;
	u64 exec_clock;
	s64 wait_runtime;
	u64 sleeper_bonus;
	unsignedlong wait_runtime_overruns, wait_runtime_underruns;

	struct rb_root tasks_timeline;
	struct rb_node *rb_leftmost;
	struct rb_node *rb_load_balance_curr;

	struct sched_entity *curr;
	struct rq *rq;
	struct list_head leaf_cfs_rq_list;
};

红黑树(Red-black tree)是平衡的二叉搜索树[^11],红黑树的增删改查操作的最坏时间复杂度为 ,也就是树的高度,树中最左侧的节点 rb_leftmost 运行的时间最短,也是下一个待运行的进程。

注:在最新版本的 CFS 实现中,内核使用虚拟运行时间 vruntime 替代了等待时间,但是基本的调度原理和排序方式没有太多变化。

调度过程

CFS 的调度过程还是由 schedule 函数完成的,该函数的执行过程可以分成以下几个步骤:

  1. 关闭当前 CPU 的抢占功能;
  2. 如果当前 CPU 的运行队列中不存在任务,调用 idle_balance 从其他 CPU 的运行队列中取一部分执行;
  3. 调用 pick_next_task 选择红黑树中优先级最高的任务;
  4. 调用 context_switch 切换运行的上下文,包括寄存器的状态和堆栈;
  5. 重新开启当前 CPU 的抢占功能;

CFS 的调度过程与  调度器十分类似,当前调度器与前者的区别只是增加了可选的工作窃取机制并改变了底层的数据结构。

调度类

CFS 中的调度类是比较有趣的概念,调度类可以决定进程的调度策略。每个调度类都包含一组负责调度的函数,调度类由如下所示的 sched_class 结构体表示:

struct sched_class {
	struct sched_class *next;

	void (*enqueue_task) (struct rq *rq, struct task_struct *p, int wakeup);
	void (*dequeue_task) (struct rq *rq, struct task_struct *p, int sleep);
	void (*yield_task) (struct rq *rq, struct task_struct *p);

	void (*check_preempt_curr) (struct rq *rq, struct task_struct *p);

	struct task_struct * (*pick_next_task) (struct rq *rq);
	void (*put_prev_task) (struct rq *rq, struct task_struct *p);

	unsigned long (*load_balance) (struct rq *this_rq, int this_cpu,
			struct rq *busiest,
			unsigned long max_nr_move, unsigned long max_load_move,
			struct sched_domain *sd, enum cpu_idle_type idle,
			int *all_pinned, int *this_best_prio);

	void (*set_curr_task) (struct rq *rq);
	void (*task_tick) (struct rq *rq, struct task_struct *p);
	void (*task_new) (struct rq *rq, struct task_struct *p);
};

调度类中包含任务的初始化、入队和出队等函数,这里的设计与面向对象中的设计稍微有些相似。内核中包含 SCHED_NORMALSCHED_BATCHSCHED_IDLESCHED_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
  • Understanding the Linux 2.6.8.1 CPU Scheduler
  • CFS Scheduler
  • Inside the Linux 2.6 Completely Fair Scheduler
  • The Linux desktop may soon be a lot faster
  • Modular Scheduler Core and Completely Fair Scheduler
  • The Linux Scheduler: A Decade of Wasted Cores

Go 语言

Go 语言是诞生自 2009 年的编程语言,相信很多人对 Go 语言的印象都是语法简单,能够支撑高并发的服务。语法简单是编程语言的顶层设计哲学,而语言的高并发支持依靠的是运行时的调度器,这也是本节将要研究的内容。

对 Go 语言稍微有了解的人都知道,通信顺序进程(Communicating sequential processes,CSP)[^13]影响着 Go 语言的并发模型,其中的 Goroutine 和 Channel 分别表示实体和用于通信的媒介。

图片
go-and-erlang

图 20 - Go 和 Erlang 的并发模型

『不要通过共享内存来通信,我们应该使用通信来共享内存』不只是 Go 语言鼓励的设计哲学,更为古老的 Erlang 语言其实也遵循了同样的设计,但是 Erlang 选择使用了 Actor 模型[^14],我们在这里就不介绍 CSP 和 Actor 的区别和联系的,感兴趣的读者可以在推荐阅读和应引用中找到相关资源。

设计与演进

今天的 Go 语言调度器有着非常优异的性能,但是如果我们回过头重新看 Go 语言的 v0.x 版本的调度器就会发现最初的调度器非常简陋,也无法支撑高并发的服务。整个调度器经过几个大版本的迭代才有了今天的优异性能。

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

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

单线程调度器

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

static void scheduler(void) {
	G* gp;
	lock(&sched);

	if(gosave(&m->sched)){
		lock(&sched);
		gp = m->curg;
		switch(gp->status){
		case Grunnable:
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		...
		}
		notewakeup(&gp->stopped);
	}

	gp = nextgandunlock();
	noteclear(&gp->stopped);
	gp->status = Grunning;
	m->curg = gp;
	g = gp;
	gogo(&gp->sched);
}

该函数会遵循如下所示的过程执行:

  1. 获取调度器的全局锁;
  2. 调用 gosave 保存栈寄存器和程序计数器;
  3. 调用 nextgandunlock 获取下一个线程 M 需要运行的 Goroutine 并解锁调度器;
  4. 修改全局线程 m 上要执行的 Goroutine;
  5. 调用 gogo 函数运行最新的 Goroutine;

这个单线程调度器的唯一优点就是能跑,不过从这次提交中我们能看到 G 和 M 两个重要的数据结构,它建立了 Go 语言调度器的框架。

多线程调度器

Go 语言 1.0 版本在正式发布时就支持了多线程的调度器,与上一个版本完全不可用的调度器相比,Go 语言团队在这一阶段完成了从不可用到可用。我们可以在 proc.c 中找到 1.0.1 版本的调度器,多线程版本的调度函数 schedule 包含 70 多行代码,我们在这里保留了其中的核心逻辑:

static void schedule(G *gp) {
	schedlock();
	if(gp != nil) {
		gp->m = nil;
		uint32 v = runtime·xadd(&runtime·sched.atomic, -1<<mcpuShift);
		if(atomic_mcpu(v) > maxgomaxprocs)
			runtime·throw("negative mcpu in scheduler");

		switch(gp->status){
		case Grunning:
			gp->status = Grunnable;
			gput(gp);
			break;
		case ...:
		}
	} else {
		...
	}
	gp = nextgandunlock();
	gp->status = Grunning;
	m->curg = gp;
	gp->m = m;
	runtime·gogo(&gp->sched, 0);
}

整体的逻辑与单线程调度器没有太多区别,多线程调度器引入了 GOMAXPROCS 变量帮助我们控制程序中的最大线程数,这样我们的程序中就可能同时存在多个活跃线程。

多线程调度器的主要问题是调度时的锁竞争,Scalable Go Scheduler Design Doc 中对调度器做的性能测试发现 14% 的时间都花费在 runtime.futex 函数上[^15],目前的调度器实现有以下问题需要解决:

  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 提交中找到任务窃取调度器刚被实现时的源代码,调度器的 schedule 函数到现在反而更简单了:

static void schedule(void) {
    G *gp;
 top:
    if(runtime·gcwaiting) {
        gcstopm();
        goto top;
    }

    gp = runqget(m->p);
    if(gp == nil)
        gp = findrunnable();

    ...

    execute(gp);
}
  1. 如果当前运行时在等待垃圾回收,调用 gcstopm 函数;
  2. 调用 runqget 和 findrunnable 从本地的或者全局的运行队列中获取待执行的 Goroutine;
  3. 调用 execute 函数在当前线程 M 上运行 Goroutine;

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

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

struct P {
	Lock;

	uint32	status;  // one of Pidle/Prunning/...
	P*	link;
	uint32	tick;   // incremented on every scheduler or system call
	M*	m;	// back-link to associated M (nil if idle)
	MCache*	mcache;

	G**	runq;
	int32	runqhead;
	int32	runqtail;
	int32	runqsize;

	G*	gfree;
	int32	gfreecnt;
};

处理器 P 持有一个运行队列 runq,这是由可运行的 Goroutine 组成的数组,它还反向持有一个线程 M 的指针。调度器在调度时会从处理器的队列中选择队列头的 Goroutine 放到线程 M 上执行。如下所示的图片展示了 Go 语言中的线程 M、处理器 P 和 Goroutine 的关系。

图片
golang-gmp

图 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 语言的调度器都包含一些无法被强占的边缘情况,直到 1.14 才实现了基于信号的真抢占式调度解决部分问题。

基于协作的抢占式调度

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

  • runtime: mark runtime.goexit as nosplit
  • runtime: add stackguard0 to G
    • 为 Goroutine 引入 stackguard0 字段,当该字段被设置成 StackPreempt 时,Goroutine 会被抢占;
  • runtime: introduce preemption function (not used for now)
    • 引入抢占函数 preemptone 和 preemptall,这两个函数会设置 Goroutine 的 StackPreempt
    • 引入抢占请求 StackPreempt
  • runtime: preempt goroutines for GC
    • 在垃圾回收调用的 runtime·stoptheworld 中调用 preemptall 函数设置所有处理器上 Goroutine 的 StackPreempt
    • 在 runtime·newstack 函数中增加抢占的代码,当 stackguard0 等于 StackPreempt 时触发调度器的抢占;
  • runtime: preempt long-running goroutines
    • 在系统监控中,如果一个 Goroutine 的运行时间超过 10ms,就会调用 retake 和 preemptone
  • runtime: more reliable preemption
    • 修复 Goroutine 因为周期性执行非阻塞的 CGO 或者系统调用不会被抢占的问题;

从上述一系列的提交中,我们会发现 Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时提出抢占请求 StackPreempt;因为编译器会在函数调用中插入 runtime.newstack,所以函数调用时会通过 runtime.newstack 检查 Goroutine 的 stackguard0 是否为 StackPreempt 进而触发抢占让出当前线程。

这种做法没有带来运行时的过多额外开销,实现也相对比较简单,不过增加了运行时的复杂度,总体来看还是一种比较成功的实现。因为上述的抢占是通过编译器在特定时机插入函数实现的,还是需要函数调用作为入口才能触发抢占,所以这是一种协作式的抢占式调度

基于信号的抢占式调度

协作的抢占式调度实现虽然巧妙,但是留下了很多的边缘情况,我们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:

  • runtime: tight loops should be preemptible #10958
  • An empty for{} will block large slice allocation in another goroutine, even with GOMAXPROCS > 1 ? #17174
  • runtime: tight loop hangs process completely after some time #15442
  • ...

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

  • runtime: add general suspendG/resumeG
    • 挂起 Goroutine 的过程是在栈扫描时完成的,我们通过 runtime.suspendG 和 runtime.resumeG 两个函数重构栈扫描这一过程;
    • 调用 runtime.suspendG 函数时会将运行状态的 Goroutine 的 preemptStop 标记成 true
    • 调用 runtime.preemptPark 函数可以挂起当前 Goroutine、将其状态更新成 _Gpreempted 并触发调度器的重新调度,该函数能够交出线程控制权;
  • runtime: asynchronous preemption function for x86
    • 在 x86 架构上增加异步抢占的函数 runtime.asyncPreempt 和 runtime.asyncPreempt2
  • runtime: use signals to preempt Gs for suspendG
    • 支持通过向线程发送信号的方式暂停运行的 Goroutine;
    • 在 runtime.sighandler 函数中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt
    • runtime.preemptM 函数可以向线程发送抢占请求;
  • runtime: implement async scheduler preemption
    • 修改 runtime.preemptone 函数的实现,加入异步抢占的逻辑;

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

  1. 程序启动时,在 runtime.sighandler 函数中注册了 SIGURG 信号的处理函数 runtime.doSigPreempt
  2. 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 函数挂起 Goroutine;
    1. 将 _Grunning 状态的 Goroutine 标记成可以被抢占,即 preemptStop 设置成 true
    2. 调用 runtime.preemptM 函数触发抢占;
  3. runtime.preemptM 函数会调用 runtime.signalM 向线程发送信号 SIGURG
  4. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt
  5. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall
  6. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时从 runtime.asyncPreempt 开始执行;
  7. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2
  8. runtime.asyncPreempt2 会调用 runtime.preemptPark 函数;
  9. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其他的 Goroutine 继续执行;

上述 9 个步骤展示了基于信号的抢占式调度的执行过程。我们还需要讨论一下该过程中信号的选择,提案根据以下的四个原因选择 SIGURG 作为触发异步抢占的信号[^19];

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

目前的抢占式调度也没有解决所有潜在的问题,因为 STW 和栈扫描时更可能出现问题,也是一个可以抢占的安全点(Safe-points),所以我们会在这里先加入抢占功能[^20],在未来可能会加入更多抢占时间点。

非均匀内存访问调度器

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

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

图片
go-numa-scheduler-architecture

图 22 - Go 语言 NUMA 调度器

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

小结

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

本节内容选择《Go 语言设计与实现》一书中的 Go 语言调度器实现原理,你可以点击链接了解更多与 Go 语言设计与实现原理相关的内容。

延伸阅读

  • How Erlang does scheduling
  • Analysis of the Go runtime scheduler
  • Go's work-stealing scheduler
  • cmd/compile: insert scheduling checks on loop backedges
  • runtime: clean up async preemption loose ends
  • Proposal: Non-cooperative goroutine preemption
  • Proposal: Conservative inner-frame scanning for non-cooperative goroutine preemption
  • NUMA-aware scheduler for Go
  • The Go scheduler
  • Why goroutines are not lightweight threads?
  • Scheduling In Go : Part I - OS Scheduler
  • Scheduling In Go : Part II - Go Scheduler
  • Scheduling In Go : Part III - Concurrency
  • The Go netpoller
  • System Calls Make the World Go Round
  • Linux Syscall Reference

Kubernetes

Kubernetes 是生产级别的容器调度和管理系统,在过去的一段时间中,Kubernetes 迅速占领市场,成为容器编排领域的实施标准。

图片
container-orchestration

图 23 - 容器编排系统演进

Kubernetes 是希腊语『舵手』的意思,它最开始由 Google 的几位软件工程师创立,深受公司内部 Borg 和 Omega 项目[^22]的影响,很多设计都是从 Borg 中借鉴的,同时也对 Borg 的缺陷进行了改进,Kubernetes 目前是 Cloud Native Computing Foundation (CNCF) 的项目,也是很多公司管理分布式系统的解决方案[^23]。

调度器是 Kubernetes 的核心组件,它的主要功能是为待运行的工作负载 Pod 绑定运行的节点 Node。与其他调度场景不同,虽然资源利用率在 Kubernetes 中也非常重要,但是这只是 Kubernetes 关注的一个因素,它需要在容器编排这个场景中支持非常多并且复杂的业务需求,除了考虑 CPU 和内存是否充足,还需要考虑其他的领域特定场景,例如:两个服务不能占用同一台机器的相同端口、几个服务要运行在同一台机器上,根据节点的类型调度资源等。

这些复杂的业务场景和调度需求使 Kubernetes 调度器的内部设计与其他调度器完全不同,但是作为用户应用层的调度器,我们却能从中学到很多有用的模式和设计。接下来,本节将介绍 Kubernetes 中调度器的设计以及演变。

设计与演进

Kubernetes 调度器的演变过程比较简单,我们可以将它的演进过程分成以下的两个阶段:

  • 基于谓词和优先级的调度器 · v1.0.0 ~ v1.14.0
  • 基于调度框架的调度器 · v1.15.0 ~ 至今

Kubernetes 从 v1.0.0 版本发布到 v1.14.0,总共 15 个版本一直都在使用谓词和优先级来管理不同的调度算法,知道 v1.15.0 开始引入调度框架(Alpha 功能)来重构现有的调度器。我们在这里将以 v1.14.0 版本的谓词和优先级和 v1.17.0 版本的调度框架分析调度器的演进过程。

谓词和优先级算法

谓词(Predicates)和优先级(Priorities)调度器是从 Kubernetes v1.0.0 发布时就存在的模式,v1.14.0 的最后实现与最开始的设计也没有太多区别。然而从 v1.0.0 到 v1.14.0 期间也引入了很多改进:

  • 调度器扩展 · v1.2.0 - Scheduler extension
    • 通过调用外部调度器扩展的方式改变调度器的决策;
  • Map-Reduce 优先级算法 · v1.5.0 - MapReduce-like scheduler priority functions
    • 为调度器的优先级算法支持 Map-Reduce 的计算方式,通过引入可并行的 Map 阶段优化调度器的计算性能;
  • 调度器迁移 · v1.10.0 - Move scheduler code out of plugin directory
    • 从 plugin/pkg/scheduler 移到 pkg/scheduler
    • kube-scheduler 成为对外直接提供的可执行文件;

谓词和优先级都是 Kubernetes 在调度系统中提供的两个抽象,谓词算法使用 FitPredicate 类型,而优先级算法使用 PriorityMapFunction 和 PriorityReduceFunction 两个类型:

type FitPredicate func(pod *v1.Pod, meta PredicateMetadata, nodeInfo *schedulernodeinfo.NodeInfo) (bool, []PredicateFailureReason, error)

type PriorityMapFunction func(pod *v1.Pod, meta interface{}, nodeInfo *schedulernodeinfo.NodeInfo) (schedulerapi.HostPriority, error)
type PriorityReduceFunction func(pod *v1.Pod, meta interface{}, nodeNameToInfo map[string]*schedulernodeinfo.NodeInfo, result schedulerapi.HostPriorityList) error

因为 v1.14.0 也是作者刚开始参与 Kubernetes 开发的第一个版本,所以对当时的设计印象也非常深刻,v1.14.0 的 Kubernetes 调度器会使用 PriorityMapFunction 和 PriorityReduceFunction 这种 Map-Reduce 的方式计算所有节点的分数并从其中选择分数最高的节点。下图展示了,v1.14.0 版本中调度器的执行过程:

图片
predicates-and-priorities-scheduling

图 24 - 谓词和优先级算法

如上图所示,我们假设调度器中存在一个谓词算法和一个 Map-Reduce 优先级算法,当我们为一个 Pod 在 6 个节点中选择最合适的一个时,6 个节点会先经过谓词的筛选,图中的谓词算法会过滤掉一半的节点,剩余的 3 个节点经过 Map 和 Reduce 两个过程分别得到了 5、10 和 5 分,最终调度器就会选择分数最高的 4 号节点。

genericScheduler.Schedule 是 Kubernetes 为 Pod 选择节点的方法,我们省略了该方法中用于检查边界条件以及打点的代码:

func (g *genericScheduler) Schedule(pod *v1.Pod, nodeLister algorithm.NodeLister) (result ScheduleResult, err error) {
	nodes, err := nodeLister.List()
	if err != nil {
		return result, err
	}
	iflen(nodes) == 0 {
		return result, ErrNoNodesAvailable
	}

	filteredNodes, failedPredicateMap, err := g.findNodesThatFit(pod, nodes)
	if err != nil {
		return result, err
	}
	...
	priorityList, err := PrioritizeNodes(pod, g.nodeInfoSnapshot.NodeInfoMap, ..., g.prioritizers, filteredNodes, g.extenders)
	if err != nil {
		return result, err
	}
	
	host, err := g.selectHost(priorityList)
	return ScheduleResult{
		SuggestedHost:  host,
		EvaluatedNodes: len(filteredNodes) + len(failedPredicateMap),
		FeasibleNodes:  len(filteredNodes),
	}, err
}
  1. 从 NodeLister 中获取当前系统中存在的全部节点;
  2. 调用 genericScheduler.findNodesThatFit 方法并行执行全部的谓词算法过滤节点;
    1. 谓词算法会根据传入的 Pod 和 Node 对节点进行过滤,这时会过滤掉端口号冲突、资源不足的节点;
    2. 调用所有调度器扩展的 Filter 方法辅助过滤;
  3. 调用 PrioritizeNodes 函数为所有的节点打分;
    1. 以 Pod 和 Node 作为参数并发执行同一优先级的 PriorityMapFunction;
    2. 以 Pod 和优先级返回的 Node 到分数的映射为参数调用 PriorityReduceFunction 函数;
    3. 调用所有调度器扩展的 Prioritize 方法;
    4. 将所有分数按照权重相加后返回从 Node 到分数的映射;
  4. 调用 genericScheduler.selectHost 方法选择得分最高的节点;

这就是使用谓词和优先级算法时的调度过程,我们在这里省略了调度器的优先队列中的排序,出现调度错误时的抢占以及 Pod 持久存储卷绑定到 Node 上的过程,只保留了核心的调度逻辑。

调度框架

Kubernetes 调度框架是 Babak Salamat 和 Jonathan Basseri 2018 年提出的最新调度器设计[^24],这个提案明确了 Kubernetes 中的各个调度阶段,提供了设计良好的基于插件的接口。调度框架认为 Kubernetes 中目前存在调度(Scheduling)和绑定(Binding)两个循环:

  • 调度循环在多个 Node 中为 Pod 选择最合适的 Node;
  • 绑定循环将调度决策应用到集群中,包括绑定 Pod 和 Node、绑定持久存储等工作;

除了两个大循环之外,调度框架中还包含 QueueSortPreFilterFilterPostFilterScoreReservePermitPreBindBindPostBind 和 Unreserve 11 个扩展点(Extension Point),这些扩展点会在调度的过程中触发,它们的运行顺序如下:

图片
kubernetes-scheduling-queue

图 25 - Kubernetes 调度框架

我们可以将调度器中的 Scheduler.scheduleOne 方法作为入口分析基于调度框架的调度器实现,每次调用该方法都会完成一遍为 Pod 调度节点的全部流程,我们将该函数的执行过程分成调度和绑定两个阶段,首先是调度器的调度阶段:

func (sched *Scheduler) scheduleOne(ctx context.Context) {
	fwk := sched.Framework

	podInfo := sched.NextPod()
	pod := podInfo.Pod

	state := framework.NewCycleState()
	scheduleResult, _ := sched.Algorithm.Schedule(schedulingCycleCtx, state, pod)
	assumedPod := podInfo.DeepCopy().Pod

	allBound, _ := sched.VolumeBinder.Binder.AssumePodVolumes(assumedPod, scheduleResult.SuggestedHost)
	if err != nil {
		return
	}

	if sts := fwk.RunReservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost); !sts.IsSuccess() {
		return
	}

	if err := sched.assume(assumedPod, scheduleResult.SuggestedHost); err != nil {
		fwk.RunUnreservePlugins(schedulingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
		return
	}
	...
}
  1. 调用内部优先队列的 MakeNextPodFunc 返回的函数从队列中获取下一个等待调度的 Pod,用于维护等待 Pod 的队列会执行 QueueSort 插件;
  2. 调用 genericScheduler.Schedule 函数选择节点,该过程会执行 PreFilterFilterPostFilterScore 四个扩展点的插件;
  3. 调用 framework.RunReservePlugins 函数运行 Reserve 插件用于保留资源并进入绑定阶段(绑定阶段运行时间较长,避免资源被抢占);
  • 如果运行失败执行,调用 framework.RunUnreservePlugins 函数运行 Unreserve 插件;

因为每一次调度决策都会改变上下文,所以该阶段 Kubernetes 需要串行执行。而绑定阶段就是实现调度的过程了,我们会创建一个新的 Goroutine 并行执行绑定循环:

func (sched *Scheduler) scheduleOne(ctx context.Context) {
	...
	gofunc() {
		bindingCycleCtx, cancel := context.WithCancel(ctx)
		defer cancel()

		fwk.RunPermitPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
		if !allBound {
			 sched.bindVolumes(assumedPod)
		}
		fwk.RunPreBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)

		if err := sched.bind(bindingCycleCtx, assumedPod, scheduleResult.SuggestedHost, state); err != nil {
			fwk.RunUnreservePlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
		} else {
			fwk.RunPostBindPlugins(bindingCycleCtx, state, assumedPod, scheduleResult.SuggestedHost)
		}
	}()
}
  1. 启动一个 Goroutine 并调用 framework.RunPermitPlugin 异步运行 Permit 插件,这个阶段可以用来实现批调度器;
  2. 调用 Scheduler.bindVolumes 将卷先绑定到 Node 上;
  3. 调用 Scheduler.bind 函数将 Pod 绑定到 Node 上完成调度,绑定的过程会执行 PreBindBind 和 PostBind 三个扩展点的插件;

目前的调度框架在 Kubernetes v1.17.0 版本中还是 Alpha 阶段,很多功能还不明确,为了支持更多、更丰富的场景,在接下来的几个版本还可能会做出很多改进,不过调度框架在很长的一段时间中都会是调度器的核心。

小结

本节介绍了 Kubernetes 调度器从 v1.0.0 到最新版本中的不同设计,Kubernetes 调度器中总共存在两种不同的设计,一种是基于谓词和优先级算法的调度器,另一种是基于调度框架的调度器。

很多的业务调度器也需要从多个选项中选出最优的选择,无论是成本最低还是质量最优,我们可以考虑将调度的过程分成过滤和打分两个阶段为调度器建立合适的抽象,过滤阶段会按照需求过滤掉不满足需求的选项,打分阶段可能会按照质量、成本和权重对多个选项进行排序,遵循这种设计思路可以解决很多类似问题。

目前的 Kubernetes 已经通过调度框架详细地支持了多个阶段的扩展方法,几乎是调度器内部实现的最终形态了。不过随着调度器功能的逐渐复杂,未来可能还会遇到更复杂的调度场景,例如:多租户的调度资源隔离、多调度器等功能,而 Kubernetes 社区也一直都在为构建高性能的调度器而努力。

延伸阅读

  • Borg, Omega, and Kubernetes
  • Scheduling Framework
  • Scheduling Framework #624
  • Create a custom Kubernetes scheduler
  • Scheduler extender Document

总结

从操作系统、编程语言到应用程序,我们在这篇文章中分析了 Linux、Go 语言和 Kubernetes 调度器的设计与实现原理,这三个不同的调度器其实有相互依赖的关系:

图片
schedulers

图 26 - 三层调度器

如上图所示,Kubernetes 的调度器依赖于 Go 语言的运行时调度器,而 Go 语言的运行时调度器也依赖于 Linux 的进程调度器,从上到下离用户越来越远,从下到上越来越关注具体业务。我们在最后通过两个比较分析一下这几个调度器的异同:

  1. Linux 进程调度器与 Go 语言调度器;
  2. 系统级调度器(Linux 和 Go)与业务调度器(Kubernetes);

这是两种不同层面的比较,相信通过不同角度的比较能够让我们对调度器的设计有更深入的认识。

Linux 和 Go

首先是 Linux 和 Go 语言调度器,这两个调度器的场景都非常相似,它们最终都是要充分利用机器上的 CPU 资源,所以在实现和演进上有很多相似之处:

  • 调度器的初始版本都非常简单,甚至很简陋,只能支持协作式的调度;
  • 按照运行队列进行分区,通过工作窃取的方式平衡不同 CPU 或者线程上的运行队列;
  • 最终都通过某些方式实现了基于信号的抢占式调度,不过 Go 语言的实现并不完善;

因为场景非常相似,所以它们的目的也非常相似,只是它们调度的任务粒度会有不同,Linux 进程调度器的最小调度单位是线程,而 Go 语言是 Goroutine,与 Linux 进程调度器相比,Go 语言在用户层建立新的模型,实现了另一个调度器,为使用者提供轻量级的调度单位来增强程序的性能,但是它也引入了很多组件来处理系统调用、网络轮训等线程相关的操作,同时组合多个不同粒度的任务导致实现相对复杂。

Linux 调度器的最终设计引入了调度类的概念,让不同任务的类型分别享受不同的调度策略以此来调和低延时和实时性这个在调度上两难的问题。

Go 语言的调度器目前刚刚引入了基于信号的抢占式调度,还有很多功能都不完善。除了抢占式调度之外,复杂的 NUMA 调度器提案也可能是未来 Go 语言的发展方向。

系统和业务

如果我们将系统调度器和业务调度器进行对比的话,你会发现两者在设计差别非常大,毕竟它们处于系统的不同层级。系统调度器考虑的是极致的性能,所以它通过分区的方式将运行队列等资源分离,通过降低锁的粒度来降低系统的延迟;而业务调度器关注的是完善的调度功能,调度的性能虽然十分重要,但是一定要建立在满足特定调度需求之上,而因为业务上的调度需求往往都是比较复杂,所以只能做出权衡和取舍。

正是因为需求的不同,我们会发现不同调度器的演进过程也完全不同。系统调度器都会先充分利用资源,降低系统延时,随后在性能无法优化时才考虑加入调度类等功能满足不同场景下的调度,而 Kubernetes 调度器更关注内部不同调度算法的组织,如何同时维护多个复杂的调度算法,当设计了良好的抽象之后,它才会考虑更加复杂的多调度器、多租户等场景。

总结的总结

这种研究历史变化带来的快乐是很不同的,当我们发现代码发生变化的原因时也会感到欣喜,这让我们站在今天重新见证了历史上的决策,本文中的相应章节已经包含了对应源代码的链接,各位读者可以自行阅读相应内容,也衷心希望各位读者能够有所收获。

系统设计精要是一系列深入研究系统设计方法的系列文章,文中不仅会分析系统设计的理论,还会分析多个实际场景下的具体实现。这是一个季更或者半年更的系列,如果你有想要了解的问题,可以在文章下面留言。

延伸阅读

  • Cooperative vs. Preemptive: a quest to maximize concurrency power
  • Randomized Work Stealing versus Sharing in Large-scale Systems with Non-exponential Job Sizes
  • Scalable work stealing

内存管理设计精要 (qq.com)

系统设计精要是一系列深入研究系统设计方法的系列文章,文中不仅会分析系统设计的理论,还会分析多个实际场景下的具体实现。这是一个季更或者半年更的系列,如果你有想要了解的问题,可以在文章下面留言。

持久存储的磁盘在今天已经不是稀缺的资源了,但是 CPU 和内存仍然是相对比较昂贵的资源,作者在 调度系统设计精要 中曾经介绍操作系统和编程语言对 CPU 资源的调度策略和原理,本文将会介绍计算机中常见的另一个稀缺资源 — 内存,是如何管理的。

图片
图 1 - 内存系统设计精要

内存管理系统和模块在操作系统以及编程语言中都占有着重要的地位,任何资源的使用都离不开申请和释放两个动作,内存管理中的两个重要过程就是内存分配和垃圾回收,内存管理系统如何利用有限的内存资源为尽可能多的程序或者模块提供服务是它的核心目标。

图片
图 2 - 文章脉络和内容

虽然多数系统都会将内存管理拆分成多个复杂的模块并引入一些中间层提供缓存和转换的功能,但是内存管理系统实际上都可以简化成两个模块,即内存分配器(Allocator)、垃圾收集器(Collector)。当然除了这两个模块之外,在研究内存管理时都会引入第三个模块 — 用户程序(Mutator),帮助我们理解整个系统的工作流程。

图片
图 3 - 内存管理系统模块
  • 用户程序(Mutator)- 可以通过分配器创建对象或者更新对象持有的指针;
  • 内存分配器(Allocator)— 处理用户程序的的内存分配请求;
  • 垃圾收集器(Collector)- 标记内存中的对象并回收不需要的内存;

上述的三个模块是内存管理系统中的核心,它们在应用程序运行期间可以维护管理内存达到相对平衡的状态,我们在介绍内存管理时也会围绕这三个不同的组件,本节将从基本概念、内存分配和垃圾回收三个方面详细介绍内存管理的相关理论。

基本概念

基本概念这一节将介绍内存管理中的基本问题,我们会简单介绍应用程序的内存布局、内存管理中的设计的常见概念以及广义上的几种不同内存管理方式,这里会帮助各位读者从顶层了解内存管理。

内存布局

操作系统会为在其上运行的应用程序分配一片巨大的虚拟内存,需要注意的是,与操作系统的主存和物理内存不一样,虚拟内存并不是在物理上真正存在的概念,它是操作系统构建的逻辑概念。应用程序的内存一般会分成以下几个不同的区域:

图片
图 4 - 内存布局
  • 栈区(Stack)— 存储程序执行期间的本地变量和函数的参数,从高地址向低地址生长;
  • 堆区(Heap)— 动态内存分配区域,通过 mallocnewfree 和 delete 等函数管理;
  • 未初始化变量区(BSS)— 存储未被初始化的全局变量和静态变量;
  • 数据区(Data)— 存储在源代码中有预定义值的全局变量和静态变量;
  • 代码区(Text)— 存储只读的程序执行代码,即机器指令;

上述五种不同段虽然存储着不同的数据,但是我们可以将它们分成三种不同的内存分配类型,也就是静态内存、栈内存和堆内存。

静态内存

静态内存可以最早追溯到 1960 年的 ALGOL 语言[^1],静态变量的生命周期可以贯穿整个程序。所有静态内存的布局都是在编译期间确认的,运行期间也不会分配新的静态内存,因为所有的静态内存都是在编译期间确认的,所以会为这些变量申请固定大小的内存空间,这些固定的内存空间也会导致静态内存无法支持函数的递归调用:

图片
图 5 - 静态内存的特性

因为编译器可以确定静态变量的地址,所以它们是程序中唯一可以使用绝对地址寻址的变量。当程序被加载到内存中时,静态变量会直接存储在程序的 BSS 区或者数据区,这些变量也会在程序退出时被销毁,正是因为静态内存的这些特性,我们并不需要在程序运行时引入静态内存的管理机制。

栈内存

栈是应用程序中常见的内存空间,它遵循后进先出的规则管理存储的数据[^2]。当应用程序调用函数时,它会将函数的参数加入栈顶,当函数返回时,它会将当前函数使用的栈全部销毁。栈内存管理的指令也都是由编译器生成的,我们会使用 BP 和 SP 这两个寄存器存储当前栈的相关信息,完全不需要工程师的参与,不过我们也只能在栈上分配大块固定的数据结构。

图片
图 6 - 栈内存的特性

因为栈内存的释放是动态的并且是线性的,所以它可以支持函数的递归调用,不过运行时动态栈分配策略的引入也会导致程序栈内存的溢出,如果我们在编程语言中使用的递归函数超出了程序内存的上限,会造成栈溢出错误。

堆内存

堆内存也是应用程序中的常见内存,与超过函数作用域会自动回收的栈内存相比,它能够让函数的被调用方向调用方返回内存并在内存的分配提供更大的灵活性,不过它提供的灵活性也带来了内存泄漏和悬挂指针等内存安全问题。

图片
图 7 - 堆内存的特性

因为堆上的内存是工程师手动申请的,所以需要在使用结束时释放,一旦用过的内存没有释放,就会造成内存泄漏,占用更多的系统内存;如果在使用结束前释放,会导致危险的悬挂指针,其他对象指向的内存已经被系统回收或者重新使用。虽然进程的内存可以划分成很多区域,但是当我们在谈内存管理时,一般指的都是堆内存的管理,也就是如何解决内存泄漏和悬挂指针的问题。

管理方式

我们可以将内存管理简单地分成手动管理和自动管理两种方式,手动管理内存一般是指由工程师在需要时通过 malloc 等函数手动申请内存并在不需要时调用 free 等函数释放内存;自动管理内存由编程语言的内存管理系统自动管理,在大多数情况下不需要工程师的参与,能够自动释放不再使用的内存。

图片
图 8 - 手动管理和自动管理

手动管理和自动管理只是内存管理的两种不同方式,本节将分别介绍两种内存管理的方式以及不同编程语言做出的不同选择。

手动管理

手动管理内存是一种比较传统的内存管理方式,C/C++ 这类系统级的编程语言不包含狭义上的自动内存管理机制,工程师需要主动申请或者释放内存。如果存在理想的工程师能够精准地确定内存的分配和释放时机,人肉的内存管理策略只要做到足够精准,使用手动管理内存的方式可以提高程序的运行性能,也不会造成内存安全问题,

但是这种理想的工程师往往不存在于现实中,人类因素(Human Factor)总会带来一些错误,内存泄漏和悬挂指针基本是 C/C++ 这类语言中最常出现的错误,手动的内存管理也会占用工程师的大量精力,很多时候都需要思考对象应该分配到栈上还是堆上以及堆上的内存应该何时释放,维护成本相对来说还是比较高的,这也是必然要做的权衡。

自动管理

自动管理内存基本是现代编程语言的标配,因为内存管理模块的功能非常确定,所以我们可以在编程语言的编译期或者运行时中引入自动的内存管理方式,最常见的自动内存管理机制就是垃圾回收,不过除了垃圾回收之外,一些编程语言也会使用自动引用计数辅助内存的管理。

自动的内存管理机制可以帮助工程师节省大量的与内存打交道的时间,让工程师将全部的精力都放在核心的业务逻辑上,提高开发的效率;在一般情况下,这种自动的内存管理机制都可以很好地解决内存泄漏和悬挂指针的问题,但是这也会带来额外开销并影响语言的运行时性能。

对象头

对象头是实现自动内存管理的关键元信息,内存分配器和垃圾收集器都会访问对象头以获取相关的信息。当我们通过 malloc 等函数申请内存时,往往都需要将内存按照指针的大小对齐(32 位架构上为 4 字节,64 位架构上为 8 字节),除了用于对齐的内存之外,每一个堆上的对象也都需要对应的对象头:

图片
图 9 - 对象头与对象

不同的自动内存管理机制会在对象头中存储不同的信息,使用垃圾回收的编程语言会存储标记位 MarkBit/MarkWord,例如:Java 和 Go 语言;使用自动引用计数的会在对象头中存储引用计数 RefCount,例如:Objective-C。

编程语言会选择将对象头与对象存储在一起,不过因为对象头的存储可能影响数据访问的局部性,所以有些编程语言可能会单独开辟一片内存空间来存储对象头并通过内存地址建立两者之间的隐式联系。

内存分配

内存分配器是内存管理系统中的重要组件,它的主要职责是处理用户程序的内存申请。虽然内存分配器的职责非常重要,但是内存的分配和使用其是一个增加系统中熵的过程,所以内存分配器的设计与工作原理相对比较简单,我们在这里介绍内存分配器的两种类型。

内存分配器只包含线性内存分配器(Sequential Allocator)和空闲链表内存分配器(Free-list Allocator)两种,内存管理机制中的所有内存分配器其实都是上述两种不同分配器的变种,它们的设计思路完全不同,同时也有着截然不同的应用场景和特性,我们在这里依次介绍这两种内存分配器的原理。

线性分配器

线性分配(Bump Allocator)是一种高效的内存分配方法,但是有较大的局限性。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域并修改指针在内存中的位置,即移动下图中的指针:

图片
图 10 - 线性分配器

根据线性分配器的原理,我们可以推测它有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器是无法重新利用红色的这部分内存的:

图片
图 11 - 线性分配器回收内存

正是因为线性分配器的这种特性,我们需要合适的垃圾回收算法配合使用。标记压缩(Mark-Compact)、复制回收(Copying GC)和分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。

因为线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以 C 和 C++ 等需要直接对外暴露指针的语言就无法使用该策略,我们会在下一节详细介绍常见垃圾回收算法的设计原理。

空闲链表分配器

空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:

图片
图 12 - 空闲链表分配器

因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度就是 O(n)。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的就是以下四种方式:

  • 首次适应(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
  • 循环首次适应(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
  • 最优适应(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
  • 隔离适应(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

上述四种策略的前三种就不过多介绍了,Go 语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:

图片
图 13 - 隔离适应策略

如上图所示,该策略会将内存分割成由 4、8、16、32 字节的内存块组成的链表,当我们向内存分配器申请 8 字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配的效率。

垃圾回收

垃圾回收是一种自动的内存管理形式[^3],垃圾收集器是内存管理系统的重要组件,内存分配器会负责在堆上申请内存,而垃圾收集器会释放不再被用户程序使用的对象。谈到垃圾回收,很多人的第一反应可能都是暂停程序(stop-the-world、STW)和垃圾回收暂停(GC Pause),垃圾回收确实会带来 STW,但是这不是垃圾回收的全部,本节将详细介绍垃圾回收以及垃圾收集器的相关概念和理论。

什么是垃圾

在深入分析垃圾回收之前,我们需要先明确垃圾回收中垃圾的定义,明确定义能够帮助我们更精确地理解垃圾回收解决的问题以及它的职责。计算机科学中的垃圾包括对象、数据和计算机系统中的其他的内存区域,这些数据不会在未来的计算中使用,因为内存资源是有限的,所以我们需要将这些垃圾占用的内存交还回堆并在未来复用[^4]。

图片
图 14 - 语义和语法垃圾

垃圾可以分成语义垃圾和语法垃圾两种,*语义垃圾(Semantic Garbage)*是计算机程序中永远不会被程序访问到的对象或者数据;*语法垃圾(Syntactic Garbage)*是计算机程序内存空间中从根对象无法达到(Unreachable)的对象或者数据。

语义垃圾是不会被使用的的对象,可能包括废弃的内存、不使用的变量,垃圾收集器无法解决程序中语义垃圾的问题,我们需要通过编译器来一部分语义垃圾。语法垃圾是在对象图中不能从根节点达到的对象,所以语法垃圾在一般情况下都是语义垃圾:

图片
图 15 - 无法达到的语法垃圾

垃圾收集器能够发现并回收的就是对象图中无法达到的语法垃圾,通过分析对象之间的引用关系,我们可以得到图中根节点不可达的对象,这些不可达的对象会在垃圾收集器的清理阶段被回收。

收集器性能

吞吐量(Throughput)和最大暂停时间(Pause time)是两个衡量垃圾收集器的主要指标,除了这两个指标之外,堆内存的使用效率和访问的局部性也是垃圾收集的常用指标,我们简单介绍以下这些指标对垃圾收集器的影响。

吞吐量

垃圾收集器的吞吐量其实有两种解释,一种解释是垃圾收集器在执行阶段的速度,也就是单位时间的标记和清理内存的能力,我们可以用堆内存除以 GC 使用的总时间来计算。

HEAP_SIZE / TOTAL_GC_TIME

另一种吞吐量计算方法是使用程序运行的总时间除以所有 GC 循环运行的总时间,GC 的时间对于整个应用程序来说是额外开销,这个指标能看出额外开销占用资源的百分比,从这一点,我们也能看出 GC 的执行效率。

最大暂停时间

由于在垃圾回收的某些阶段会触发 STW,所以用户程序是不能执行的,最长的 STW 时间会严重影响程序处理请求或者提供服务的尾延迟,所以这一点也是我们在测量垃圾收集器性能时需要考虑的指标。

图片
图 16 - 最大暂停时间

使用 STW 垃圾收集器的编程语言,用户程序在垃圾回收的全部阶段都不能执行。并发标记清除的垃圾收集器将可以与用户程序并发执行的工作全部并发执行,能够减少最大程序暂停时间,

堆使用效率

堆的使用效率也是衡量垃圾收集器的重要指标。为了能够标识垃圾,我们需要在内存空间中引入包含特定信息的对象头,这些对象头都是垃圾收集器带来的额外开销,正如网络带宽可能不是最终的下载速度,协议头和校验码的传输会占用网络带宽,对象头的大小最终也会影响堆内存的使用效率;除了对象头之外,堆在使用过程中出现的碎片也会影响内存的使用效率,为了保证内存的对齐,我们会在内存中留下很多缝隙,这些缝隙也是内存管理带来的开销。

访问局部性

访问的局部性是我们在讨论内存管理时不得不谈的话题,空间的局部性是指处理器在短时间内总会重复地访问同一片或者相邻的内存区域,操作系统会以内存页为单位管理内存空间,在理想情况下,合理的内存布局可以使得垃圾收集器和应用程序都能充分地利用空间局部性提高程序的执行效率。

收集器类型

垃圾收集器的类型在总体上可以分成直接(Direct)垃圾收集器和跟踪(Tracing)垃圾收集器。直接垃圾收集器包括引用计数(Refernce-Counting),跟踪垃圾收集器包含标记清理、标记压缩、复制垃圾回收等策略,而引用计数收集器却不是特别常见,少数编程语言会使用这种方式管理内存。

图片
图 17 - 垃圾收集器类型

除了直接和跟踪垃圾收集器这些相对常见的垃圾回收方法之外,也有使用所有权或者手动的方式管理内存,我们在本节中会介绍引用计数、标记清除、标记压缩和复制垃圾回收四种不同类型垃圾收集器的设计原理以及它们的优缺点。

引用计数

基于引用计数的垃圾收集器是直接垃圾收集器,当我们改变对象之间的引用关系时会修改对象之间的引用计数,每个对象的引用计数都记录了当前有多少个对象指向了该对象,当对象的引用计数归零时,当前对象就会被自动释放。在使用引用计数的编程语言中,垃圾收集是在用户程序运行期间实时发生的,所以在理论上也就不存在 STW 或者明显地垃圾回收暂停。

图片
图 18 - 对象的引用计数

如上图所示,基于引用计数的垃圾收集器需要应用程序在对象头中存储引用计数,引用计数就是该类型的收集器在内存中引入的额外开销。我们在这里举一个例子介绍引用计数的工作原理,如果在使用引用计数回收器的编程语言中使用如下所示赋值语句时:

obj.field = new_ref;
  1. 对象 obj 原来引用的对象 old_ref 的引用计数会减一
  2. 对象 obj 引用的新对象 new_ref 的引用计数会加一
  3. 如果 old_ref 对象的引用计数归零,我们会释放该对象回收它的内存;

这种类型的垃圾收集器会带来两个比较常见的问题,分别是递归的对象回收和循环引用:

  • 递归回收 — 每当对象的引用关系发生改变时,我们都需要计算对象的新引用计数,一旦对象被释放,我们就需要递归地访问所有该对象的引用并将被引用对象的计数器减一,一旦涉及到较多的对象就可能会造成 GC 暂停;
  • 循环引用 — 对象的相互引用在对象图中也非常常见,如果对象之间的引用都是强引用,循环引用会导致多个对象的计数器都不会归零,最终会造成内存泄漏;

递归回收是使用引用计数时不得不面对的问题,我们很难在工程上解决该问题;不过使用引用计数的编程语言却可以利用弱引用来解决循环引用的问题,弱引用也是对象之间的引用关系,建立和销毁弱引用关系都不会修改双方的引用计数,这就能避免对象之间的弱引用关系,不过这也需要工程师对引用关系作出额外的并且正确的判断。

图片
图 19 - 强引用与弱引用

除了弱引用之外,一些编程语言也会在引用计数的基础上加入标记清除技术,通过遍历和标记堆中不再被使用的对象解决循环引用的问题。

引用计数垃圾收集器是一种非移动(Non-moving)的垃圾回收策略,它在回收内存的过程中不会移动已有的对象,很多编程语言都会对工程师直接暴露内存的指针,所以 C、C++ 以及 Objective-C 等编程语言其实都可以使用引用计数来解决内存管理的问题。

标记清除

标记清除(Mark-Sweep)是最简单也最常见的垃圾收集策略,它的执行过程可以分成标记清除两个阶段,标记阶段会使用深度优先或者广度优先算法扫描堆中的存活对象,而清除阶段会回收内存中的垃圾。当我们使用该策略回收垃圾时,它会首先从根节点出发沿着对象的引用遍历堆中的全部对象,能够被访问到的对象是存活的对象,不能被访问到的对象就是内存中的垃圾。

如下图所示,内存空间中包含多个对象,我们从根对象出发依次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,即 A、C 和 D 三个对象,剩余的 B、E 和 F 三个对象因为从根节点不可达,所以会被当做垃圾:

图片
图 20 - 标记清除的标记阶段

标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中的所有对象,释放其中没有被标记的 B、E 和 F 三个对象并将新的空闲内存空间以链表的结构串联起来,方便内存分配器的使用。

图片
图 21 - 标记清除的收集阶段

使用标记清除算法的编程语言需要在对象头中加入表示对象存活的标记位(Mark Bit),标记位与操作系统的写时复制不兼容,因为即使内存页中的对象没有被修改,垃圾收集器也会修改内存页中对象相邻的标记位导致内存页的复制。我们可以使用位图(Bitmap)标记避免这种情况,表示对象存活的标记与对象分别存储,清理对象时也只需要遍历位图,能够降低清理过程的额外开销。

如上图所示,使用标记清除算法的垃圾收集器一般会使用基于空闲链表的分配器,因为对象在不被使用时会被就地回收,所以长时间运行的程序会出现很多内存碎片,这会降低内存分配器的分配效率,在实现上我们可以将空闲链表按照对象大小分成不同的区以减少内存中的碎片。

标记清除策略是一种实现简单的垃圾收集策略,但是它的内存碎片化问题也比较严重,简单的内存回收策略也增加了内存分配的开销和复杂度,当用户程序申请内存时,我们也需要在内存中找到足够大的块分配内存。

标记压缩

标记压缩(Mark-Compact)也是比较常见的垃圾收集算法,与标记清除算法类似,标记压缩的执行过程可以分成标记压缩两个阶段。该算法在标记阶段也会从根节点遍历对象,查找并标记所有存活的对象;在压缩阶段,我们会将所有存活的对象紧密排列,『挤出』存活对象之间的缝隙:

图片
图 22 - 标记压缩算法

因为在压缩阶段我们需要移动存活的对象,所以这一种 moving 收集器,如果编程语言支持使用指针访问对象,那么我们就无法使用该算法。标记的过程相对比较简单,我们在这里以 Lisp 2 压缩算法为例重点介绍该算法的压缩阶段:

  1. 计算当前对象迁移后的最终位置并将位置存储在转发地址(Forwarding Address)中;
  2. 根据当前对象子对象的转发地址,将引用指向新的位置;
  3. 将所有存活的对象移动到对象头中转发地址的位置;

从上述过程我们可以看出,使用标记压缩算法的编程语言不仅要在对象头中存储标记位,还需要存储当前对象的转发地址,这增加了对象在内存中的额外开销。

标记压缩算法的实现比较复杂,在执行的过程中需要遍历三次堆中的对象,作为 moving 的垃圾收集器,它不适用于 C、C++ 等编程语言;压缩算法的引入可以减少程序中的内存碎片,我们可以直接使用最简单的线性分配器为用户程序快速分配内存。

复制垃圾回收

复制垃圾回收(Copying GC)也是跟踪垃圾收集器的一种,它会将应用程序的堆分成两个大小相等的区域,如下图所示,其中左侧区域负责为用户程序分配内存空间,而右侧区域用于垃圾回收。

图片
图 23 - 复制垃圾回收

当用户程序使用的内存超过上图中的左侧区域就会出现内存不足(Out-of memory、OOM),垃圾收集器在这时会开启新的垃圾收集循环,复制垃圾回收的执行过程可以非常以下的四个阶段:

  1. 复制阶段 — 从 GC 根节点出发遍历内存中的对象,将发现的存活对象迁移到右侧的内存中;
  2. 转发阶段 — 在原始对象的对象头或者在原位置设置新对象的转发地址(Forwarding Address),如果其他对象引用了该对象可以从转发地址转到新的地址;
  3. 修复指针 — 遍历当前对象持有的引用,如果引用指向了左侧堆中的对象,回到第一步迁移发现的新对象;
  4. 交换阶段 — 当内存中不存在需要迁移的对象之后,交换左右两侧的内存区域;
图片
图 24 - 复制垃圾回收的复制阶段

如上图所示,当我们把 A 对象复制到右侧的区域后,会将原始的 A 对象指向新的 A 对象,这样其他引用 A 的对象可以快速找到它的新地址;因为 A 对象的复制是『像素级复制』,所以 A 对象仍然会指向左侧内存的 C 对象,这时需要将 C 对象复制到新的内存区域并修改 A 对象的指针。在最后,当不存在需要拷贝的对象时,我们可以直接交换两个内存区域的指针。

复制垃圾回收与标记压缩算法一样都会拷贝对象,能够减少程序中的内存碎片,我们可以使用线性的分配器快速为用户程序分配内存。因为只需要扫描一半的堆,遍历堆的次数也会减少,所以可以减少垃圾回收的时间,但是这也会降低内存的利用率。

高级垃圾回收

内存管理是一个相对比较大的话题,我们在上一小节介绍了垃圾回收的一些基本概念,其中包括常见的垃圾回收算法:引用计数、标记清除、标记压缩和复制垃圾回收,这些算法都是比较基本的垃圾回收算法,我们在这一节中将详细介绍一些高级的垃圾回收算法,它们会利用基本的垃圾回收算法和新的数据结构构建更复杂的收集器。

分代垃圾收集器

分代垃圾回收(Generational garbage collection)是在生产环境中比较常见的垃圾收集算法,该算法主要建立在弱分代假设(Weak Generational Hypothesis)上 —— 大多数的对象会在生成后马上变成垃圾,只有极少数的对象可以存活很久[^5]。根据该经验,分代垃圾回收会把堆中的对象分成多个代,不同代垃圾回收的触发条件和算法都完全不同。

图片
图 25 - 青年代和老年代

常见的分代垃圾回收会将堆分成青年代(Young、Eden)和老年代(Old、Tenured),所有的对象在刚刚初始化时都会进入青年代,而青年代触发 GC 的频率也更高;而老年代的对象 GC 频率相对比较低,只有青年代的对象经过多轮 GC 没有被释放才可能被晋升(Promotion)到老年代,晋升的过程与复制垃圾回收算法的执行过程相差无几。

青年代的垃圾回收被称作是 Minor GC 循环,而老年代的垃圾回收被称作 Major GC 循环,Full GC 循环一般是指整个堆的垃圾回收,需要注意的是很多时候我们都会混淆 Major GC 循环和 Full GC 循环,在讨论时一定要先搞清楚双方对这些名词的理解是否一致。

青年代的垃圾回收只会扫描整个堆的一部分,这能够减少一次垃圾回收需要的扫描的堆大小和程序的暂停时间,提高垃圾回收的吞吐量。然而分代也为垃圾回收引入了复杂度,其中最常见的问题是跨代引用(Intergenerational Pointer),即老年代引用了青年代的对象,如果堆中存在跨代引用,那么在 Minor GC 循环中我们不仅应该遍历垃圾回收的根对象,还需要从包含跨代引用的对象出发标记青年代中的对象。

图片
图 26 - 跨代引用

为了处理分代垃圾回收的跨代引用,我们需要解决两个问题,分别是如何识别堆中的跨代引用以及如何存储识别的跨代引用,在通常情况下我们会使用*写屏障(Write Barrier)识别跨代引用并使用卡表(Card Table)*存储相关的数据。

注意:卡表只是标记或者存储跨代引用的一种方式,除了卡表我们也可以使用记录集(Record Set)存储跨代引用的老年代对象或者使用页面标记按照操作系统内存页的维度标记老年代的对象。

写屏障是当对象之间的指针发生改变时调用的代码片段,这段代码会判断该指针是不是从老年代对象指向青年代对象的跨代引用。如果该指针是跨代引用,我们会在如下所示的卡表中标记老年代对象所在的区域:

图片
图 27 - 卡表

卡表与位图比较相似,它也由一系列的比特位组成,其中每一个比特位都对应着老年区中的一块内存,如果该内存中的对象存在指向青年代对象的指针,那么这块内存在卡表中就会被标记,当触发 Minor GC 循环时,除了从根对象遍历青年代堆之外,我们还会从卡表标记区域内的全部老年代对象开始遍历青年代。

分代垃圾回收基于弱分代假说,结合了复制垃圾回收、写屏障以及卡表等技术,将内存中的堆区分割成了青年代和老年代等区域,为不同的代使用不同的内存分配和垃圾回收算法,可以有效地减少 GC 循环遍历的堆大小和处理时间,但是写屏障技术也会带了额外开销,移动收集器的特性也使它无法在 C、C++ 等编程语言中使用,在部分场景下弱分代假说不一定会成立,如果大多数的对象都会活得很久,那么使用分代垃圾回收可能会起到反效果。

标记区域收集器

标记区域收集器(Mark-Region Garbage Collector)是 2008 年提出的垃圾收集算法[^6],这个算法也被称作混合垃圾回收(Immix GC),它结合了标记清除和复制垃圾回收算法,我们使用前者来追踪堆中的存活对象,使用后者减少内存中存在的碎片。

图片
图 28 - 标记区域收集器

Immix 垃圾回收算法包含两个组件,分别是用于标记区域的收集器和去碎片化机制[^7]。标记区域收集器与标记清除收集器比较类似,它将堆内存拆分成特定大小的内存块,再将所有的内存块拆分成特定大小的线。当用户程序申请内存时,它会在上述内存块中查找空闲的线并使用线性分配器快速分配内存;通过引入粗粒度的内存块和细粒度的线,可以更好地控制内存的分配和释放。

图片
图 29 - 线性分配器的光标

标记区域收集器与标记清除收集器比较类似,因为它们不会移动对象,所以都会面临内存碎片化的问题。如下图所示,标记区域收集器在回收内存时都是以块和线为单位进行回收的,所以只要当前内存线中包含存活对象,收集器就会保留该片内存区域,这会带来我们在上面提到的内存碎片。

Immix 引入的机会转移(Opportunistic Evacuation)机制能够有效地减少程序中的碎片化,当收集器在内存块中遇到可以被转移的对象,它就会使用复制垃圾回收算法将当前块中的存活对象移动到新的块中并释放原块中的内存。

标记区域收集器将堆内存分成了粗粒度的内存块和细粒度的内存线,结合了标记清除算法和复制垃圾回收几种基本垃圾收集器的特性,既能够提升垃圾收集器的吞吐量,还能够利用线性分配器提高内存的分配速度,但是该收集器的实现相对比较复杂。

增量并发收集器

相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行。

传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序,一旦触发垃圾收集,垃圾收集器就会抢占 CPU 的使用权占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的 STW。

图片
图 30 - 垃圾收集与暂停程序

远古时代的计算资源还没有今天这么丰富,今天的计算机往往都是多核的处理器,垃圾收集器一旦开始执行就会浪费大量的计算资源,为了减少应用程序暂停的最长时间和垃圾收集的总暂停时间,我们会使用下面的策略优化现代的垃圾收集器:

  • 增量垃圾收集 — 增量地标记和清除垃圾,降低应用程序暂停的最长时间;
  • 并发垃圾收集 — 利用多核的计算资源,在用户程序执行时并发标记和清除垃圾;

因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,应用程序也不能等到内存溢出时触发垃圾收集,因为当内存不足时,应用程序已经无法分配内存,这与直接暂停程序没有什么区别,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。

增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间:

图片
图 31 - 增量垃圾收集器

需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序对内存的修改都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只有优点的。

并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响:

图片
图 32 - 并发垃圾收集器

虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。

但是因为增量并发收集器的并发标记阶段会与用户程序一同或者交替运行,所以可能出现标记为垃圾的对象被用户程序中的其他对象重新引用,当垃圾回收的标记阶段结束后,被错误标记为垃圾的对象会被直接回收,这就会带来非常严重的问题,想要解决增量并发收集器的这个问题,我们需要了解三色抽象和屏障技术。

三色抽象

为了解决原始标记清除算法带来的长时间 STW,多数现代的追踪式垃圾收集器都会实现三色标记算法的变种以缩短 STW 的时间。三色标记算法将程序中的对象分成白色、黑色和灰色三类[^8]:

  • 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
  • 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
  • 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
图片
图 33 - 三色的对象

在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。

图片
图 34 - 三色标记垃圾收集器的执行过程

三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:

  1. 从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
  2. 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
  3. 重复上述两个步骤直到对象图中不存在灰色对象;

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象 D 为待回收的垃圾:

图片
图 35 - 三色标记后的堆

因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。

图片
图 36 - 三色标记与用户程序

本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误成为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性[^9],想要并发或者增量地标记对象还是需要使用屏障技术。

垃圾回收屏障

内存屏障技术是一种屏障指令,它可以让 CPU 或者编译器在执行内存相关操作时遵循特定的约束,目前的多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证代码对内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作[^10]。

想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种:

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径[^11];
图片
图 37 - 三色不变性

上图分别展示了遵循强三色不变性和弱三色不变性的堆内存,遵循上述两个不变性中的任意一个,我们都能保证垃圾收集算法的正确性,而屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。

垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。

我们在这里想要介绍的是以下几种写屏障技术,分别是 Dijkstra 提出的插入写屏障[^12]和 Yuasa 提出的删除写屏障[^13],这里会分析它们如何保证三色不变性和垃圾收集器的正确性。

插入写屏障

Dijkstra 在 1978 年提出了插入写屏障,通过如下所示的写屏障,用户程序和垃圾收集器可以在交替工作的情况下保证程序执行的正确性:

writePointer(slot, ptr):
    shade(ptr)
    *field = ptr

上述插入写屏障的伪代码非常好理解,每当我们执行类似 *slot = ptr 的表达式时,我们会执行上述写屏障通过 shade 函数尝试改变指针的颜色。如果 ptr 指针是白色的,那么该函数会将该对象设置成灰色,其他情况则保持不变。

图片
图 38 - Dijkstra 插入写屏障

假设我们在应用程序中使用 Dijkstra 提出的插入写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

  1. 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  2. 用户程序修改 A 对象的指针,将原本指向 B 对象的指针指向 C 对象,这时触发写屏障将 C 对象标记成灰色;
  3. 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

Dijkstra 的插入写屏障是一种相对保守的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性。在如上所示的垃圾收集过程中,实际上不再存活的 B 对象最后没有被回收;而如果我们在第二和第三步之间将指向 C 对象的指针改回指向 B,垃圾收集器仍然认为 C 对象是存活的,这些被错误标记的垃圾对象只有在下一个循环才会被回收。

插入式的 Dijkstra 写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有很明显的缺点。因为栈上的对象在垃圾收集中也会被认为是根对象,所以为了保证内存的安全,Dijkstra 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之前做出权衡。

删除写屏障

Yuasa 在 1990 年的论文 Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它就会保证开启写屏障时堆上所有对象的可达,所以也被称作快照垃圾收集(Snapshot GC)[^14]:

This guarantees that no objects will become unreachable to the garbage collector traversal all objects which are live at the beginning of garbage collection will be reached even if the pointers to them are overwritten.

该算法会使用如下所示的写屏障保证增量或者并发执行垃圾收集时程序的正确性:

writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

上述代码会在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就可以保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用。

图片
图 39 - Yuasa 删除写屏障

假设我们在应用程序中使用 Yuasa 提出的删除写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:

  1. 垃圾收集器将根对象指向 A 对象标记成黑色并将 A 对象指向的对象 B 标记成灰色;
  2. 用户程序将 A 对象原本指向 B 的指针指向 C,触发删除写屏障,但是因为 B 对象已经是灰色的,所以不做改变;
  3. 用户程序将 B 对象原本指向 C 的指针删除,触发删除写屏障,白色的 C 对象被涂成灰色
  4. 垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

上述过程中的第三步触发了 Yuasa 删除写屏障的着色,因为用户程序删除了 B 指向 C 对象的指针,所以 C 和 D 两个对象会分别违反强三色不变性和弱三色不变性:

  • 强三色不变性 — 黑色的 A 对象直接指向白色的 C 对象;
  • 弱三色不变性 — 垃圾收集器无法从某个灰色对象出发,经过几个连续的白色对象访问白色的 C 和 D 两个对象;

Yuasa 删除写屏障通过对 C 对象的着色,保证了 C 对象和下游的 D 对象能够在这一次垃圾收集的循环中存活,避免发生悬挂指针以保证用户程序的正确性。

总结

内存管理在今天仍然是十分重要的话题,当我们在讨论编程语言的性能和便利程度时,内存管理机制都是绕不开的。编程语言在设计内存管理机制时,往往需要在手动管理和自动管理之间进行抉择,现代的大多数编程语言为了减少工程师的负担,多数都会选择使用垃圾回收的方式自动管理内存,但是也有少数编程语言通过手动管理追求极致的性能。

想要在一篇文章中详尽展示内存管理的方方面面是不可能的,我们可能需要一本书或者几本书的厚度才能详细地展示内存管理的相关技术,这里更多侧重的还是垃圾回收,Rust 的所有权、生命周期以及 C++ 的智能指针等机制在文章中都没有提及,感兴趣的读者可以自行了解。

[^1]: Wikipedia: Static variable https://en.wikipedia.org/wiki/Static_variable

[^2]: Wikipedia: Stack-based memory allocation https://en.wikipedia.org/wiki/Stack-based_memory_allocation

[^3]: Wikipedia: Garbage collection (computer science) https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)

[^4]: Wikipedia: Garbage (computer science) https://en.wikipedia.org/wiki/Garbage_(computer_science)

[^5]: Garbage Collection in Java (1) - Heap Overview http://insightfullogic.com/2013/Feb/20/garbage-collection-java-1/

[^6]: Immix: A Mark-Region Garbage Collector with Space Efficiency, Fast Collection, and Mutator Performance. Stephen M. Blackburn. Kathryn S. McKinley. 2008. http://www.cs.utexas.edu/users/speedway/DaCapo/papers/immix-pldi-2008.pdf

[^7]: The CS 6120 Course Blog. Siqiu Yao. 2019. https://www.cs.cornell.edu/courses/cs6120/2019fa/blog/immix/

[^8]: "Tri-color marking" https://en.wikipedia.org/wiki/Tracing_garbage_collection#Tri-color_marking

[^9]: "Dangling pointer" https://en.wikipedia.org/wiki/Dangling_pointer

[^10]: "Wikpedia: Memory barrier" https://en.wikipedia.org/wiki/Memory_barrier

[^11]: P. P. Pirinen. Barrier techniques for incremental tracing. In ACM SIGPLAN Notices, 34(3), 20–25, October 1998. https://dl.acm.org/doi/10.1145/301589.286863

[^12]: E. W. Dijkstra, L. Lamport, A. J. Martin, C. S. Scholten, and E. F. Steffens. On-the-fly garbage collection: An exercise in cooperation. Communications of the ACM, 21(11), 966–975, 1978. https://www.cs.utexas.edu/users/EWD/transcriptions/EWD05xx/EWD520.html

[^13]: T. Yuasa. Real-time garbage collection on general-purpose machines. Journal of Systems and Software, 11(3):181–198, 1990. https://www.sciencedirect.com/science/article/pii/016412129090084Y

[^14]: Paul R Wilson. "Uniprocessor Garbage Collection Techniques" https://www.cs.cmu.edu/~fp/courses/15411-f14/misc/wilson94-gc.pdf

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章