导读:本文做者写这篇文章前先后后大概 2 个月的时间,全文大概 2w 字,建议收藏后阅读或者经过电脑阅读。node
调度是一个很是普遍的概念,不少领域都会使用调度这个术语,在计算机科学中,调度就是一种将任务(Work)分配给资源的方法。任务多是虚拟的计算任务,例如线程、进程或者数据流,这些任务会被调度到硬件资源上执行,例如:处理器 CPU 等设备。linux
图 1 - 调度系统设计精要算法
本文会介绍调度系统的常见场景以及设计过程当中的一些关键问题,调度器的设计最终都会归结到一个问题上 — 如何对资源高效的分配和调度以达到咱们的目的,可能包括对资源的合理利用、最小化成本、快速匹配供给和需求。编程
图 2 - 文章脉络和内容api
除了介绍调度系统设计时会遇到的常见问题以外,本文还会深刻分析几种常见的调度器的设计、演进与实现原理,包括操做系统的进程调度器,Go 语言的运行时调度器以及 Kubernetes 的工做负载调度器,帮助咱们理解调度器设计的核心原理。数组
调度系统其实就是调度器(Scheduler),咱们在不少系统中都能见到调度器的身影,就像咱们在上面说的,不止操做系统中存在调度器,编程语言、容器编排以及不少业务系统中都会存在调度系统或者调度模块。缓存
这些调度模块的核心做用就是对有限的资源进行分配,以实现最大化资源的利用率或者下降系统的尾延迟,调度系统面对的就是资源的需求和供给不平衡的问题。安全
图 3 - 调度器的任务和资源网络
咱们在这一节中将从多个方面介绍调度系统设计时须要重点考虑的问题,其中包括调度系统的需求调研、调度原理以及架构设计。数据结构
在着手构建调度系统以前,首要的工做就是进行详细的需求调研和分析,在这个过程当中须要完成如下两件事:
调度系统应用的场景是咱们首先须要考虑的问题,对应用场景的分析相当重要,咱们须要深刻了解当前场景下待执行任务和能用来执行任务的资源的特色。咱们须要分析待执行任务的如下特征:
而用于执行任务的资源也可能存在资源不平衡,不一样资源处理任务的速度不一致的问题。
资源和任务特色的多样性决定了调度系统的设计,咱们在这里举几个简单的例子帮助各位读者理解调度系统需求分析的过程。
图 4 - Linux 操做系统
在操做系统的进程调度器中,待调度的任务就是线程,这些任务通常只会处于正在执行或者未执行(等待或者终止)的状态;而用于处理这些任务的 CPU 每每都是不可再分的,同一个 CPU 在同一时间只能执行一个任务,这是物理上的限制。简单总结一下,操做系统调度器的任务和资源有如下特性:
在上述场景中,待执行的任务是操做系统调度的基本单位 —— 线程,而可分配的资源是 CPU 的时间。Go 语言的调度器与操做系统的调度器面对的是几乎相同的场景,其中的任务是 Goroutine,能够分配的资源是在 CPU 上运行的线程。
图 5 - 容器编排系统 Kubernetes
除了操做系统和编程语言这种较为底层的调度器以外,容器和计算任务调度在今天也很常见,Kubernetes 做为容器编排系统会负责调取集群中的容器,对它稍有了解的人都知道,Kubernetes 中调度的基本单元是 Pod,这些 Pod 会被调度到节点 Node 上执行:
调度系统在生活和工做中都很常见,除了上述的两个场景以外,其余须要调度系统的场景包括 CDN 的资源调度、订单调度以及离线任务调度系统等。在不一样场景中,咱们都须要深刻思考任务和资源的特性,它们对系统的设计起者指导做用。
在深刻分析调度场景后,咱们须要理解调度的目的。咱们能够将调度目的理解成机器学习中的成本函数(Cost function),肯定调度目的就是肯定成本函数的定义,调度理论一书中曾经介绍过常见的调度目的,包含如下内容:
这些都是偏理论的调度的目的,多数业务调度系统的调度目的都是优化与业务联系紧密的指标 — 成本和质量。如何在成本和质量之间达到平衡是须要仔细思考和设计的,因为篇幅所限以及业务场景的复杂,本文不会分析如何权衡成本和质量,这每每都是须要结合业务考虑的事情,不具备足够的类似性。
性能优异的调度器是实现特定调度目的前提,咱们在讨论调度场景和目的时每每都会忽略调度的额外开销,然而调度器执行时的延时和吞吐量等指标在调度负载较重时是不可忽视的。本节会分析与调度器实现相关的一些重要概念,这些概念可以帮助咱们实现高性能的调度器:
协做式(Cooperative)与抢占式(Preemptive)调度是操做系统中常见的多任务运行策略。这两种调度方法的定义彻底不一样:
图 6 - 协做式调度与抢占式调度
任务的执行时间和任务上下文切换的额外开销决定了哪一种调度方式会带来更好的性能。以下图所示,图 7 展现了一个协做式调度器调度任务的过程,调度器一旦为某个任务分配了资源,它就会等待该任务主动释放资源,图中 4 个任务尽管执行时间不一样,可是它们都会在任务执行完成后释放资源,整个过程也只须要 4 次上下文的切换。
图 7 - 协做式调度
图 8 展现了抢占式调度的过程,因为调度器不知道全部任务的执行时间,因此它为每个任务分配了一段时间切片。任务 1 和任务 4 因为执行时间较短,因此在第一次被调度时就完成了任务;可是任务 2 和任务 3 由于执行时间较长,超过了调度器分配的上限,因此为了保证公平性会触发抢占,等待队列中的其余任务会得到资源。在整个调度过程当中,一共发生了 6 次上下文切换。
图 8 - 抢占式调度
若是部分任务的执行时间很长,协做式的任务调度会使部分执行时间长的任务饿死其余任务;不过若是待执行的任务执行时间较短而且几乎相同,那么使用协做式的任务调度能减小任务中断带来的额外开销,从而带来更好的调度性能。
由于多数状况下任务执行的时间都不肯定,在协做式调度中一旦任务没有主动让出资源,那么就会致使其它任务等待和阻塞,因此调度系统通常都会以抢占式的任务调度为主,同时支持任务的协做式调度。
使用单个调度器仍是多个调度器也是设计调度系统时须要仔细考虑的,多个调度器并不必定意味着多个进程,也有多是一个进程中的多个调度线程,它们既能够选择在多核上并行调度、在单核上并发调度,也能够同时利用并行和并发提升性能。
图 9 - 单调度器调度任务和资源
不过对于调度系统来讲,由于它作出的决策会改变资源的状态和系统的上下文进而影响后续的调度决策,因此单调度器的串行调度是可以精准调度资源的惟一方法。单个调度器利用不一样渠道收集调度须要的上下文,并在收到调度请求后会根据任务和资源状况作出当下最优的决策。
随着调度器的不断演变,单调度器的性能和吞吐量可能会受到限制,咱们仍是须要引入并行或者并发调度来解决性能上的瓶颈,这时咱们须要将待调度的资源分区,让多个调度器分别负责调度不一样区域中的资源。
图 10 - 多调度器与资源分区
多调度器的并发调度可以极大提高调度器的总体性能,例如 Go 语言的调度器。Go 语言运行时会将多个 CPU 交给不一样的处理器分别调度,这样经过并行调度可以提高调度器的性能。
上面介绍的两种调度方法都创建在须要精准调度的前提下,多调度器中的每个调度器都会面对无关的资源,因此对于同一个分区的资源,调度仍是串行的。
图 11 - 多调度器粗粒度调度
使用多个调度器同时调度多个资源也是可行的,只是可能须要牺牲调度的精确性 — 不一样的调度器可能会在不一样时间接收到状态的更新,这就会致使不一样调度器作出不一样的决策。负载均衡就能够看作是多线程和多进程的调度器,由于对任务和资源掌控的信息有限,这种粗粒度调度的结果极可能就是不一样机器的负载会有较大差别,因此不管是小规模集群仍是大规模集群都颇有可能致使某些实例的负载太高。
这一小节将继续介绍在多个调度器间从新分配任务的两个调度范式 — 工做分享(Work Sharing)和工做窃取(Work Stealing)。独立的调度器能够同时处理全部的任务和资源,因此它不会遇到多调度器的任务和资源的不平衡问题。在多数的调度场景中,任务的执行时间都是不肯定的,假设多个调度器分别调度相同的资源,因为任务的执行时间不肯定,多个调度器中等待调度的任务队列最终会发生差别 — 部分队列中包含大量任务,而另一些队列不包含任务,这时就须要引入任务再分配策略。
工做分享和工做窃取是彻底不一样的两种再分配策略。在工做分享中,当调度器建立了新任务时,它会将一部分任务分给其余调度器;而在工做窃取中,当调度器的资源没有被充分利用时,它会从其余调度器中窃取一些待分配的任务,以下图所示:
图 12 - 工做窃取调度器
这两种任务再分配的策略都为系统增长了额外的开销,与工做分享相比,工做窃取只会在当前调度器的资源没有被充分利用时才会触发,因此工做窃取引入的额外开销更小。工做窃取在生产环境中更加经常使用,Linux 操做系统和 Go 语言都选择了工做窃取策略。
本节将从调度器内部和外部两个角度分析调度器的架构设计,前者分析调度器内部多个组件的关系和作出调度决策的过程;后者分析多个调度器应该如何协做,是否有其余的外部服务能够辅助调度器作出更合理的调度决策。
当调度器收到待调度任务时,会根据采集到的状态和待调度任务的规格(Spec)作出合理的调度决策,咱们能够从下图中了解常见调度系统的内部逻辑。
图 13 - 调度器作出调度决策
常见的调度器通常由两部分组成 — 用于收集状态的状态模块和负责作决策的决策模块。
状态模块会从不一样途径收集尽量多的信息为调度提供丰富的上下文,其中可能包括资源的属性、利用率和可用性等信息。根据场景的不一样,上下文可能须要存储在 MySQL 等持久存储中,通常也会在内存中缓存一份以减小调度器访问上下文的开销。
决策模块会根据状态模块收集的上下文和任务的规格作出调度决策,须要注意的是作出的调度决策只是在当下有效,在将来某个时间点,状态的改变可能会致使以前作的决策不符合任务的需求,例如:当咱们使用 Kubernetes 调度器将工做负载调度到某些节点上,这些节点可能因为网络问题忽然不可用,该节点上的工做负载也就不能正常工做,即调度决策失效。
调度器在调度时都会经过如下的三个步骤为任务调度合适的资源:
图 14 - 调度框架
上图展现了常见调度器决策模块执行的几个步骤,肯定优先级、对闲置资源进行打分、肯定抢占资源的牺牲者,上述三个步骤中的最后一个每每都是可选的,部分调度系统不须要支持抢占式调度的功能。
若是咱们将调度器当作一个总体,从调度器外部看架构设计就会获得彻底不一样的角度 — 如何利用外部系统加强调度器的功能。在这里咱们将介绍两种调度器外部的设计,分别是多调度器和反调度器(Descheduler)。
串行调度与并行调度一节已经分析了多调度器的设计,咱们能够将待调度的资源进行分区,让多个调度器线程或者进程分别负责各个区域中资源的调度,充分利用多和 CPU 的并行能力。
反调度器是一个比较有趣的概念,它可以移除决策再也不正确的调度,下降系统中的熵,让调度器根据当前的状态从新决策。
图 15 - 调度器与反调度器
反调度器的引入使得整个调度系统变得更加健壮。调度器负责根据当前的状态作出正确的调度决策,反调度器根据当前的状态移除错误的调度决策,它们的做用看起来相反,可是目的都是为任务调度更合适的资源。
反调度器的使用没有那么普遍,实际的应用场景也比较有限。做者第一次发现这个概念是在 Kubernetes 孵化的descheduler 项目中,不过由于反调度器移除调度关系可能会影响正在运行的线上服务,因此 Kubernetes 也只会在特定场景下使用。
调度器是操做系统中的重要组件,操做系统中有进程调度器、网络调度器和 I/O 调度器等组件,本节介绍的是操做系统中的进程调度器。
有一些读者可能会感到困惑,操做系统调度的最小单位不是线程么,为何这里使用的是进程调度。在 Linux 操做系统中,调度器调度的不是进程也不是线程,它调度的是 task_struct 结构体,该结构体既能够表示线程,也能够表示进程,而调度器会将进程和线程都当作任务,咱们在这里先说明这一问题,避免读者感到困惑。咱们会使用进程调度器这个术语,可是必定要注意 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 密集型任务,最大化的利用操做系统的 I/O 和 CPU 资源。
中期调度器会将不活跃的、低优先级的、发生大量页错误的或者占用大量内存的进程从内存中移除,为其余的进程释放资源。
图 16 - 中期调度器
当正在运行的进程陷入 I/O 操做时,该进程只会占用计算资源,在这种状况下,中期调度器就会将它从内存中移除等待 I/O 操做完成后,该进程会从新加入就绪队列并等待短时间调度器的调度。
短时间调度器应该是咱们最熟悉的调度器,它会从就绪队列中选出一个进程执行。进程的选择会使用特定的调度算法,它会同时考虑进程的优先级、入队时间等特征。由于每一个进程可以获得的执行时间有限,因此短时间调度器的执行十分频繁。
本节将重点介绍 Linux 的 CPU 调度器,也就是短时间调度器。Linux 的 CPU 调度器并非从设计之初就是像今天这样复杂的,在很长的一段时间里(v0.01 ~ v2.4),Linux 的进程调度都由几十行的简单函数负责,咱们先了解一下不一样版本调度器的历史:
这里会详细介绍从最初的调度器到今天复杂的彻底公平调度器(Completely Fair Scheduler,CFS)的演变过程。
Linux 最初的进程调度器仅由 sched.h 和 sched.c 两个文件构成。你可能很难想象 Linux 早期版本使用只有几十行的 schedule 函数负责了操做系统进程的调度:
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 个。
图 17 - 最初的进程调度器
上述函数会先唤醒得到信号的可中断进程,而后从队列倒序查找计数器 counter 最大的可执行进程,counter 是进程可以占用的时间切片数量,该函数会根据时间切片的值执行不一样的逻辑:
Linux 操做系统的计时器会每隔 10ms 触发一次 do_timer 将当前正在运行进程的 counter 减一,当前进程的计数器归零时就会从新触发调度。
调度器是 Linux 在 v2.4 ~ v2.6 版本使用的调度器,因为该调取器在最坏的状况下会遍历全部的任务,因此它调度任务的时间复杂度就是 。Linux 调度算法将 CPU 时间分割成了不一样的时期(Epoch),也就是每一个任务可以使用的时间切片。
咱们能够在 sched.h 和 sched.c 两个文件中找到调度器的源代码。与上一个版本的调度器相比, 调度器的实现复杂了不少,该调度器会在 schedule 函数中遍历运行队列中的全部任务并调用 goodness 函数分别计算它们的权重得到下一个运行的进程:
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 左右的时间切片,然而这种调度和分配方式是 调度器的最大问题:
正是由于调度器存在了上述的问题,因此 Linux 内核在两个版本后使用新的 调度器替换该实现。
调度器在 v2.6.0 到 v2.6.22 的 Linux 内核中使用了四年的时间,它可以在常数时间内完成进程调度,你能够在sched.h 和 sched.c 中查看 调度器的源代码。由于实现和功能复杂性的增长,调度器的代码行数从 的 2100 行增长到 5000 行,它在调度器的基础上进行了以下的改进:
调度器经过运行队列 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 共同组成了以下图所示的数据结构:
图 18 - 优先数组
优先数组的 bitmap 总共包含 140 位,每一位都表示对应优先级的进程是否存在。图 17 中的优先数组包含 3 个优先级为 2 的进程和 1 个优先级为 5 的进程。每个优先级的标志位都对应一个 list_head 数组中的链表。 调度器使用上述的数据结构进行以下所示的调度:
上述的这些规则是 调度器运行遵照的主要规则,除了上述规则以外,调度器还须要支持抢占、CPU 亲和等功能,不过在这里就不展开介绍了。
全局的运行队列是 调度器难以在对称多处理器架构上扩展的主要缘由。为了保证运行队列的一致性,调度器在调度时须要获取运行队列的全局锁,随着处理器数量的增长,多个处理器在调度时会致使更多的锁竞争,严重影响调度性能。 调度器经过引入本地运行队列解决这个问题,不一样的 CPU 能够经过 this_rq 获取绑定在当前 CPU 上的运行队列,下降了锁的粒度和冲突的可能性。
#define this_rq() (&__get_cpu_var(runqueues))
图 19 - 全局运行队列和本地运行队列
多个处理器因为再也不须要共享全局的运行队列,因此加强了在对称对处理器架构上的扩展性,当咱们增长新的处理器时,只须要增长新的运行队列,这种方式不会引入更多的锁冲突。
调度器中包含两种不一样的优先级计算方式,一种是静态任务优先级,另外一种是动态任务优先级。在默认状况下,任务的静态任务优先级都是 0,不过咱们能够经过系统调用 nice 改变任务的优先级; 调度器会奖励 I/O 密集型任务并惩罚 CPU 密集型任务,它会经过改变任务的静态优先级来完成优先级的动态调整,由于与用户交互的进程时 I/O 密集型的进程,这些进程因为调度器的动态策略会提升自身的优先级,从而提高用户体验。
彻底公平调度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入内核的调度器,也是内核的默认进程调度器,它的目的是最大化 CPU 利用率和交互的性能。Linux 内核版本 v2.6.23 中的 CFS 由如下的多个文件组成:
经过 CFS 的名字咱们就能发现,该调度器的能为不一样的进程提供彻底公平性。一旦某些进程受到了不公平的待遇,调度器就会运行这些进程,从而维持全部进程运行时间的公平性。这种保证公平性的方式与『水多了加面,面多了加水』有一些类似:
调度器算法不断计算各个进程的运行时间并依次调度队列中的受到最不公平对待的进程,保证各个进程的运行时间差不会大于最小运行的时间单位。
虽然咱们仍是会延用运行队列这一术语,可是 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)是平衡的二叉搜索树,红黑树的增删改查操做的最坏时间复杂度为 ,也就是树的高度,树中最左侧的节点 rb_leftmost 运行的时间最短,也是下一个待运行的进程。
注:在最新版本的 CFS 实现中,内核使用虚拟运行时间 vruntime 替代了等待时间,可是基本的调度原理和排序方式没有太多变化。
CFS 的调度过程仍是由 schedule 函数完成的,该函数的执行过程能够分红如下几个步骤:
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_NORMAL、SCHED_BATCH、SCHED_IDLE、SCHED_FIFO 和 SCHED_RR 调度类,这些不一样的调度类分别实现了 sched_class 中的函数以提供不一样的调度行为。
本节介绍了操做系统调度器的设计原理以及演进的历史,从 2007 年合入 CFS 到如今已通过去了很长时间,目前的调度器也变得更加复杂,社区也在不断改进进程调度器。
咱们能够从 Linux 调度器的演进的过程看到主流系统架构的变化,最初几十行代码的调度器就能完成基本的调度功能,而如今要使用几万行代码来完成复杂的调度,保证系统的低延时和高吞吐量。
因为篇幅有限,咱们很难对操做系统的调度器进行面面俱到的分析,你能够在 这里 找到做者使用的 Linux 源代码,亲自动手分析不一样版本的进程调度器。
Go 语言是诞生自 2009 年的编程语言,相信不少人对 Go 语言的印象都是语法简单,可以支撑高并发的服务。语法简单是编程语言的顶层设计哲学,而语言的高并发支持依靠的是运行时的调度器,这也是本节将要研究的内容。
对 Go 语言稍微有了解的人都知道,通讯顺序进程(Communicating sequential processes,CSP)影响着 Go 语言的并发模型,其中的 Goroutine 和 Channel 分别表示实体和用于通讯的媒介。
图 20 - Go 和 Erlang 的并发模型
『不要经过共享内存来通讯,咱们应该使用通讯来共享内存』不仅是 Go 语言鼓励的设计哲学,更为古老的 Erlang 语言其实也遵循了一样的设计,可是 Erlang 选择使用了Actor 模型,咱们在这里就不介绍 CSP 和 Actor 的区别和联系的,感兴趣的读者能够在推荐阅读和应引用中找到相关资源。
今天的 Go 语言调度器有着很是优异的性能,可是若是咱们回过头从新看 Go 语言的 v0.x 版本的调度器就会发现最初的调度器很是简陋,也没法支撑高并发的服务。整个调度器通过几个大版本的迭代才有了今天的优异性能。
除了多线程、任务窃取和抢占式调度器以外,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); }
该函数会遵循以下所示的过程执行:
这个单线程调度器的惟一优势就是能跑,不过从此次提交中咱们能看到 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 函数上,目前的调度器实现有如下问题须要解决:
这里的全局锁问题和 Linux 操做系统调度器在早期遇到的问题比较类似,解决方案也都大同小异。
2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:
基于任务窃取的 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); }
当前处理器本地的运行队列中不包含 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 的关系。
图 21 - G-M-P 模型
基于工做窃取的多线程调度器将每个线程绑定到了独立的 CPU 上并经过不一样处理器分别管理,不一样处理器中经过工做窃取对任务进行再分配,提高了调度器和 Go 语言程序的总体性能,今天全部的 Go 语言服务的高性能都受益于这一改动。
对 Go 语言并发模型的修改提高了调度器的性能,可是在 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源。Go 语言的调度器在1.2 版本中引入了基于协做的抢占式调度解决下面的问题:
然而 1.2 版本中实现的抢占式调度是基于协做的,在很长的一段时间里 Go 语言的调度器都包含一些没法被抢占的边缘状况,直到 1.14 才实现了基于信号的真抢占式调度解决部分问题。
咱们能够在 proc.c 文件中找到引入抢占式调度后的调度器实现。Go 语言会在当前的分段栈机制上实现抢占式的调度,全部的 Goroutine 在函数调用时都有机会进入运行时检查是否须要执行抢占。基于协做的抢占是经过如下的多个提交实现的:
从上述一系列的提交中,咱们会发现 Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时提出抢占请求 StackPreempt;由于编译器会在函数调用中插入 runtime.newstack,因此函数调用时会经过 runtime.newstack 检查 Goroutine 的 stackguard0 是否为 StackPreempt 进而触发抢占让出当前线程。
这种作法没有带来运行时的过多额外开销,实现也相对比较简单,不过增长了运行时的复杂度,整体来看仍是一种比较成功的实现。由于上述的抢占是经过编译器在特定时机插入函数实现的,仍是须要函数调用做为入口才能触发抢占,因此这是一种协做式的抢占式调度。
协做的抢占式调度实现虽然巧妙,可是留下了不少的边缘状况,咱们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:
Go 语言在 1.14 版本中实现了非协做的抢占式调度,在实现的过程当中咱们对已有的逻辑进行重构并为 Goroutine 增长新的状态和字段来支持抢占。Go 团队经过下面提交的实现了这一功能,咱们能够顺着提交的顺序理解其实现原理:
目前的抢占式调度也只会在垃圾回收扫描任务时触发,咱们能够梳理一下触发抢占式调度的过程:
上述 9 个步骤展现了基于信号的抢占式调度的执行过程。咱们还须要讨论一下该过程当中信号的选择,提案根据如下的四个缘由选择 SIGURG 做为触发异步抢占的信号:
目前的抢占式调度也没有解决全部潜在的问题,由于 STW 和栈扫描时更可能出现问题,也是一个能够抢占的安全点(Safe-points),因此咱们会在这里先加入抢占功能,在将来可能会加入更多抢占时间点。
非均匀内存访问(Non-uniform memory access,NUMA)调度器目前只是 Go 语言的提案,由于该提案过于复杂,而目前的调度器的性能已经足够优异,因此暂时没有实现该提案。该提案的原理就是经过拆分全局资源,让各个处理器可以就近获取本地资源,减小锁竞争并增长数据局部性。
在目前的运行时中,线程、处理器、网络轮训器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局的资源。运行时没有保证本地化,也不清楚系统的拓扑结构,部分结构能够提供必定的局部性,可是从全局来看没有这种保证。
图 22 - Go 语言 NUMA 调度器
如上图所示,堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮训器和计时器会由单独的处理器持有。这种方式虽然可以利用局部性提升调度器的性能,可是自己的实现过于复杂,因此 Go 语言团队尚未着手实现这一提案。
Go 语言的调度器在最初的几个版本中迅速迭代,可是从 1.2 版本以后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度解决了自 1.2 以来一直存在的问题。在可预见的将来,Go 语言的调度器还会进一步演进,增长抢占式调度的时间点减小存在的边缘状况。
本节内容选择《Go 语言设计与实现》一书中的 Go 语言调度器实现原理,你能够点击连接了解更多与 Go 语言设计与实现原理相关的内容。
Kubernetes 是生产级别的容器调度和管理系统,在过去的一段时间中,Kubernetes 迅速占领市场,成为容器编排领域的实施标准。
图 23 - 容器编排系统演进
Kubernetes 是希腊语『舵手』的意思,它最开始由 Google 的几位软件工程师创立,深受公司内部Borg 和 Omega 项目的影响,不少设计都是从 Borg 中借鉴的,同时也对 Borg 的缺陷进行了改进,Kubernetes 目前是 Cloud Native Computing Foundation (CNCF) 的项目,也是不少公司管理分布式系统的解决方案。
调度器是 Kubernetes 的核心组件,它的主要功能是为待运行的工做负载 Pod 绑定运行的节点 Node。与其余调度场景不一样,虽然资源利用率在 Kubernetes 中也很是重要,可是这只是 Kubernetes 关注的一个因素,它须要在容器编排这个场景中支持很是多而且复杂的业务需求,除了考虑 CPU 和内存是否充足,还须要考虑其余的领域特定场景,例如:两个服务不能占用同一台机器的相同端口、几个服务要运行在同一台机器上,根据节点的类型调度资源等。
这些复杂的业务场景和调度需求使 Kubernetes 调度器的内部设计与其余调度器彻底不一样,可是做为用户应用层的调度器,咱们却能从中学到不少有用的模式和设计。接下来,本节将介绍 Kubernetes 中调度器的设计以及演变。
Kubernetes 调度器的演变过程比较简单,咱们能够将它的演进过程分红如下的两个阶段:
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 期间也引入了不少改进:
谓词和优先级都是 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 版本中调度器的执行过程:
图 24 - 谓词和优先级算法
如上图所示,咱们假设调度器中存在一个谓词算法和一个 Map-Reduce 优先级算法,当咱们为一个 Pod 在 6 个节点中选择最合适的一个时,6 个节点会先通过谓词的筛选,图中的谓词算法会过滤掉一半的节点,剩余的 3 个节点通过 Map 和 Reduce 两个过程分别获得了 五、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 }
这就是使用谓词和优先级算法时的调度过程,咱们在这里省略了调度器的优先队列中的排序,出现调度错误时的抢占以及 Pod 持久存储卷绑定到 Node 上的过程,只保留了核心的调度逻辑。
Kubernetes 调度框架是 Babak Salamat 和 Jonathan Basseri 2018 年提出的最新调度器设计,这个提案明确了 Kubernetes 中的各个调度阶段,提供了设计良好的基于插件的接口。调度框架认为 Kubernetes 中目前存在调度(Scheduling)和绑定(Binding)两个循环:
除了两个大循环以外,调度框架中还包含 QueueSort、PreFilter、Filter、PostFilter、Score、Reserve、Permit、PreBind、Bind、PostBind 和 Unreserve 11 个扩展点(Extension Point),这些扩展点会在调度的过程当中触发,它们的运行顺序以下:
图 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 } ... }
由于每一次调度决策都会改变上下文,因此该阶段 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) } }() }
目前的调度框架在 Kubernetes v1.17.0 版本中仍是 Alpha 阶段,不少功能还不明确,为了支持更多、更丰富的场景,在接下来的几个版本还可能会作出不少改进,不过调度框架在很长的一段时间中都会是调度器的核心。
本节介绍了 Kubernetes 调度器从 v1.0.0 到最新版本中的不一样设计,Kubernetes 调度器中总共存在两种不一样的设计,一种是基于谓词和优先级算法的调度器,另外一种是基于调度框架的调度器。
不少的业务调度器也须要从多个选项中选出最优的选择,不管是成本最低仍是质量最优,咱们能够考虑将调度的过程分红过滤和打分两个阶段为调度器创建合适的抽象,过滤阶段会按照需求过滤掉不知足需求的选项,打分阶段可能会按照质量、成本和权重对多个选项进行排序,遵循这种设计思路能够解决不少相似问题。
目前的 Kubernetes 已经经过调度框架详细地支持了多个阶段的扩展方法,几乎是调度器内部实现的最终形态了。不过随着调度器功能的逐渐复杂,将来可能还会遇到更复杂的调度场景,例如:多租户的调度资源隔离、多调度器等功能,而 Kubernetes 社区也一直都在为构建高性能的调度器而努力。
从操做系统、编程语言到应用程序,咱们在这篇文章中分析了 Linux、Go 语言和 Kubernetes 调度器的设计与实现原理,这三个不一样的调度器其实有相互依赖的关系:
图 26 - 三层调度器
如上图所示,Kubernetes 的调度器依赖于 Go 语言的运行时调度器,而 Go 语言的运行时调度器也依赖于 Linux 的进程调度器,从上到下离用户愈来愈远,从下到上愈来愈关注具体业务。咱们在最后经过两个比较分析一下这几个调度器的异同:
这是两种不一样层面的比较,相信经过不一样角度的比较可以让咱们对调度器的设计有更深刻的认识。
首先是 Linux 和 Go 语言调度器,这两个调度器的场景都很是类似,它们最终都是要充分利用机器上的 CPU 资源,因此在实现和演进上有不少类似之处:
由于场景很是类似,因此它们的目的也很是类似,只是它们调度的任务粒度会有不一样,Linux 进程调度器的最小调度单位是线程,而 Go 语言是 Goroutine,与 Linux 进程调度器相比,Go 语言在用户层创建新的模型,实现了另外一个调度器,为使用者提供轻量级的调度单位来加强程序的性能,可是它也引入了不少组件来处理系统调用、网络轮训等线程相关的操做,同时组合多个不一样粒度的任务致使实现相对复杂。
Linux 调度器的最终设计引入了调度类的概念,让不一样任务的类型分别享受不一样的调度策略以此来调和低延时和实时性这个在调度上两难的问题。
Go 语言的调度器目前刚刚引入了基于信号的抢占式调度,还有不少功能都不完善。除了抢占式调度以外,复杂的 NUMA 调度器提案也多是将来 Go 语言的发展方向。
若是咱们将系统调度器和业务调度器进行对比的话,你会发现二者在设计差异很是大,毕竟它们处于系统的不一样层级。系统调度器考虑的是极致的性能,因此它经过分区的方式将运行队列等资源分离,经过下降锁的粒度来下降系统的延迟;而业务调度器关注的是完善的调度功能,调度的性能虽然十分重要,可是必定要创建在知足特定调度需求之上,而由于业务上的调度需求每每都是比较复杂,因此只能作出权衡和取舍。
正是由于需求的不一样,咱们会发现不一样调度器的演进过程也彻底不一样。系统调度器都会先充分利用资源,下降系统延时,随后在性能没法优化时才考虑加入调度类等功能知足不一样场景下的调度,而 Kubernetes 调度器更关注内部不一样调度算法的组织,如何同时维护多个复杂的调度算法,当设计了良好的抽象以后,它才会考虑更加复杂的多调度器、多租户等场景。
3. 最后
这种研究历史变化带来的快乐是很不一样的,当咱们发现代码发生变化的缘由时也会感到欣喜,这让咱们站在今天从新见证了历史上的决策,本文中的相应章节已经包含了对应源代码的连接,各位读者能够自行阅读相应内容,也衷心但愿各位读者可以有所收获。
查看更多:https://yq.aliyun.com/article..._content=g_1000104318
上云就看云栖号:更多云资讯,上云案例,最佳实践,产品入门,访问:https://yqh.aliyun.com/导读:本文做者写这篇文章前先后后大概 2 个月的时间,全文大概 2w 字,建议收藏后阅读或者经过电脑阅读。
调度是一个很是普遍的概念,不少领域都会使用调度这个术语,在计算机科学中,调度就是一种将任务(Work)分配给资源的方法。任务多是虚拟的计算任务,例如线程、进程或者数据流,这些任务会被调度到硬件资源上执行,例如:处理器 CPU 等设备。
图 1 - 调度系统设计精要
本文会介绍调度系统的常见场景以及设计过程当中的一些关键问题,调度器的设计最终都会归结到一个问题上 — 如何对资源高效的分配和调度以达到咱们的目的,可能包括对资源的合理利用、最小化成本、快速匹配供给和需求。
图 2 - 文章脉络和内容
除了介绍调度系统设计时会遇到的常见问题以外,本文还会深刻分析几种常见的调度器的设计、演进与实现原理,包括操做系统的进程调度器,Go 语言的运行时调度器以及 Kubernetes 的工做负载调度器,帮助咱们理解调度器设计的核心原理。
调度系统其实就是调度器(Scheduler),咱们在不少系统中都能见到调度器的身影,就像咱们在上面说的,不止操做系统中存在调度器,编程语言、容器编排以及不少业务系统中都会存在调度系统或者调度模块。
这些调度模块的核心做用就是对有限的资源进行分配,以实现最大化资源的利用率或者下降系统的尾延迟,调度系统面对的就是资源的需求和供给不平衡的问题。
图 3 - 调度器的任务和资源
咱们在这一节中将从多个方面介绍调度系统设计时须要重点考虑的问题,其中包括调度系统的需求调研、调度原理以及架构设计。
在着手构建调度系统以前,首要的工做就是进行详细的需求调研和分析,在这个过程当中须要完成如下两件事:
调度系统应用的场景是咱们首先须要考虑的问题,对应用场景的分析相当重要,咱们须要深刻了解当前场景下待执行任务和能用来执行任务的资源的特色。咱们须要分析待执行任务的如下特征:
而用于执行任务的资源也可能存在资源不平衡,不一样资源处理任务的速度不一致的问题。
资源和任务特色的多样性决定了调度系统的设计,咱们在这里举几个简单的例子帮助各位读者理解调度系统需求分析的过程。
图 4 - Linux 操做系统
在操做系统的进程调度器中,待调度的任务就是线程,这些任务通常只会处于正在执行或者未执行(等待或者终止)的状态;而用于处理这些任务的 CPU 每每都是不可再分的,同一个 CPU 在同一时间只能执行一个任务,这是物理上的限制。简单总结一下,操做系统调度器的任务和资源有如下特性:
在上述场景中,待执行的任务是操做系统调度的基本单位 —— 线程,而可分配的资源是 CPU 的时间。Go 语言的调度器与操做系统的调度器面对的是几乎相同的场景,其中的任务是 Goroutine,能够分配的资源是在 CPU 上运行的线程。
图 5 - 容器编排系统 Kubernetes
除了操做系统和编程语言这种较为底层的调度器以外,容器和计算任务调度在今天也很常见,Kubernetes 做为容器编排系统会负责调取集群中的容器,对它稍有了解的人都知道,Kubernetes 中调度的基本单元是 Pod,这些 Pod 会被调度到节点 Node 上执行:
调度系统在生活和工做中都很常见,除了上述的两个场景以外,其余须要调度系统的场景包括 CDN 的资源调度、订单调度以及离线任务调度系统等。在不一样场景中,咱们都须要深刻思考任务和资源的特性,它们对系统的设计起者指导做用。
在深刻分析调度场景后,咱们须要理解调度的目的。咱们能够将调度目的理解成机器学习中的成本函数(Cost function),肯定调度目的就是肯定成本函数的定义,调度理论一书中曾经介绍过常见的调度目的,包含如下内容:
这些都是偏理论的调度的目的,多数业务调度系统的调度目的都是优化与业务联系紧密的指标 — 成本和质量。如何在成本和质量之间达到平衡是须要仔细思考和设计的,因为篇幅所限以及业务场景的复杂,本文不会分析如何权衡成本和质量,这每每都是须要结合业务考虑的事情,不具备足够的类似性。
性能优异的调度器是实现特定调度目的前提,咱们在讨论调度场景和目的时每每都会忽略调度的额外开销,然而调度器执行时的延时和吞吐量等指标在调度负载较重时是不可忽视的。本节会分析与调度器实现相关的一些重要概念,这些概念可以帮助咱们实现高性能的调度器:
协做式(Cooperative)与抢占式(Preemptive)调度是操做系统中常见的多任务运行策略。这两种调度方法的定义彻底不一样:
图 6 - 协做式调度与抢占式调度
任务的执行时间和任务上下文切换的额外开销决定了哪一种调度方式会带来更好的性能。以下图所示,图 7 展现了一个协做式调度器调度任务的过程,调度器一旦为某个任务分配了资源,它就会等待该任务主动释放资源,图中 4 个任务尽管执行时间不一样,可是它们都会在任务执行完成后释放资源,整个过程也只须要 4 次上下文的切换。
图 7 - 协做式调度
图 8 展现了抢占式调度的过程,因为调度器不知道全部任务的执行时间,因此它为每个任务分配了一段时间切片。任务 1 和任务 4 因为执行时间较短,因此在第一次被调度时就完成了任务;可是任务 2 和任务 3 由于执行时间较长,超过了调度器分配的上限,因此为了保证公平性会触发抢占,等待队列中的其余任务会得到资源。在整个调度过程当中,一共发生了 6 次上下文切换。
图 8 - 抢占式调度
若是部分任务的执行时间很长,协做式的任务调度会使部分执行时间长的任务饿死其余任务;不过若是待执行的任务执行时间较短而且几乎相同,那么使用协做式的任务调度能减小任务中断带来的额外开销,从而带来更好的调度性能。
由于多数状况下任务执行的时间都不肯定,在协做式调度中一旦任务没有主动让出资源,那么就会致使其它任务等待和阻塞,因此调度系统通常都会以抢占式的任务调度为主,同时支持任务的协做式调度。
使用单个调度器仍是多个调度器也是设计调度系统时须要仔细考虑的,多个调度器并不必定意味着多个进程,也有多是一个进程中的多个调度线程,它们既能够选择在多核上并行调度、在单核上并发调度,也能够同时利用并行和并发提升性能。
图 9 - 单调度器调度任务和资源
不过对于调度系统来讲,由于它作出的决策会改变资源的状态和系统的上下文进而影响后续的调度决策,因此单调度器的串行调度是可以精准调度资源的惟一方法。单个调度器利用不一样渠道收集调度须要的上下文,并在收到调度请求后会根据任务和资源状况作出当下最优的决策。
随着调度器的不断演变,单调度器的性能和吞吐量可能会受到限制,咱们仍是须要引入并行或者并发调度来解决性能上的瓶颈,这时咱们须要将待调度的资源分区,让多个调度器分别负责调度不一样区域中的资源。
图 10 - 多调度器与资源分区
多调度器的并发调度可以极大提高调度器的总体性能,例如 Go 语言的调度器。Go 语言运行时会将多个 CPU 交给不一样的处理器分别调度,这样经过并行调度可以提高调度器的性能。
上面介绍的两种调度方法都创建在须要精准调度的前提下,多调度器中的每个调度器都会面对无关的资源,因此对于同一个分区的资源,调度仍是串行的。
图 11 - 多调度器粗粒度调度
使用多个调度器同时调度多个资源也是可行的,只是可能须要牺牲调度的精确性 — 不一样的调度器可能会在不一样时间接收到状态的更新,这就会致使不一样调度器作出不一样的决策。负载均衡就能够看作是多线程和多进程的调度器,由于对任务和资源掌控的信息有限,这种粗粒度调度的结果极可能就是不一样机器的负载会有较大差别,因此不管是小规模集群仍是大规模集群都颇有可能致使某些实例的负载太高。
这一小节将继续介绍在多个调度器间从新分配任务的两个调度范式 — 工做分享(Work Sharing)和工做窃取(Work Stealing)。独立的调度器能够同时处理全部的任务和资源,因此它不会遇到多调度器的任务和资源的不平衡问题。在多数的调度场景中,任务的执行时间都是不肯定的,假设多个调度器分别调度相同的资源,因为任务的执行时间不肯定,多个调度器中等待调度的任务队列最终会发生差别 — 部分队列中包含大量任务,而另一些队列不包含任务,这时就须要引入任务再分配策略。
工做分享和工做窃取是彻底不一样的两种再分配策略。在工做分享中,当调度器建立了新任务时,它会将一部分任务分给其余调度器;而在工做窃取中,当调度器的资源没有被充分利用时,它会从其余调度器中窃取一些待分配的任务,以下图所示:
图 12 - 工做窃取调度器
这两种任务再分配的策略都为系统增长了额外的开销,与工做分享相比,工做窃取只会在当前调度器的资源没有被充分利用时才会触发,因此工做窃取引入的额外开销更小。工做窃取在生产环境中更加经常使用,Linux 操做系统和 Go 语言都选择了工做窃取策略。
本节将从调度器内部和外部两个角度分析调度器的架构设计,前者分析调度器内部多个组件的关系和作出调度决策的过程;后者分析多个调度器应该如何协做,是否有其余的外部服务能够辅助调度器作出更合理的调度决策。
当调度器收到待调度任务时,会根据采集到的状态和待调度任务的规格(Spec)作出合理的调度决策,咱们能够从下图中了解常见调度系统的内部逻辑。
图 13 - 调度器作出调度决策
常见的调度器通常由两部分组成 — 用于收集状态的状态模块和负责作决策的决策模块。
状态模块会从不一样途径收集尽量多的信息为调度提供丰富的上下文,其中可能包括资源的属性、利用率和可用性等信息。根据场景的不一样,上下文可能须要存储在 MySQL 等持久存储中,通常也会在内存中缓存一份以减小调度器访问上下文的开销。
决策模块会根据状态模块收集的上下文和任务的规格作出调度决策,须要注意的是作出的调度决策只是在当下有效,在将来某个时间点,状态的改变可能会致使以前作的决策不符合任务的需求,例如:当咱们使用 Kubernetes 调度器将工做负载调度到某些节点上,这些节点可能因为网络问题忽然不可用,该节点上的工做负载也就不能正常工做,即调度决策失效。
调度器在调度时都会经过如下的三个步骤为任务调度合适的资源:
图 14 - 调度框架
上图展现了常见调度器决策模块执行的几个步骤,肯定优先级、对闲置资源进行打分、肯定抢占资源的牺牲者,上述三个步骤中的最后一个每每都是可选的,部分调度系统不须要支持抢占式调度的功能。
若是咱们将调度器当作一个总体,从调度器外部看架构设计就会获得彻底不一样的角度 — 如何利用外部系统加强调度器的功能。在这里咱们将介绍两种调度器外部的设计,分别是多调度器和反调度器(Descheduler)。
串行调度与并行调度一节已经分析了多调度器的设计,咱们能够将待调度的资源进行分区,让多个调度器线程或者进程分别负责各个区域中资源的调度,充分利用多和 CPU 的并行能力。
反调度器是一个比较有趣的概念,它可以移除决策再也不正确的调度,下降系统中的熵,让调度器根据当前的状态从新决策。
图 15 - 调度器与反调度器
反调度器的引入使得整个调度系统变得更加健壮。调度器负责根据当前的状态作出正确的调度决策,反调度器根据当前的状态移除错误的调度决策,它们的做用看起来相反,可是目的都是为任务调度更合适的资源。
反调度器的使用没有那么普遍,实际的应用场景也比较有限。做者第一次发现这个概念是在 Kubernetes 孵化的descheduler 项目中,不过由于反调度器移除调度关系可能会影响正在运行的线上服务,因此 Kubernetes 也只会在特定场景下使用。
调度器是操做系统中的重要组件,操做系统中有进程调度器、网络调度器和 I/O 调度器等组件,本节介绍的是操做系统中的进程调度器。
有一些读者可能会感到困惑,操做系统调度的最小单位不是线程么,为何这里使用的是进程调度。在 Linux 操做系统中,调度器调度的不是进程也不是线程,它调度的是 task_struct 结构体,该结构体既能够表示线程,也能够表示进程,而调度器会将进程和线程都当作任务,咱们在这里先说明这一问题,避免读者感到困惑。咱们会使用进程调度器这个术语,可是必定要注意 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 密集型任务,最大化的利用操做系统的 I/O 和 CPU 资源。
中期调度器会将不活跃的、低优先级的、发生大量页错误的或者占用大量内存的进程从内存中移除,为其余的进程释放资源。
图 16 - 中期调度器
当正在运行的进程陷入 I/O 操做时,该进程只会占用计算资源,在这种状况下,中期调度器就会将它从内存中移除等待 I/O 操做完成后,该进程会从新加入就绪队列并等待短时间调度器的调度。
短时间调度器应该是咱们最熟悉的调度器,它会从就绪队列中选出一个进程执行。进程的选择会使用特定的调度算法,它会同时考虑进程的优先级、入队时间等特征。由于每一个进程可以获得的执行时间有限,因此短时间调度器的执行十分频繁。
本节将重点介绍 Linux 的 CPU 调度器,也就是短时间调度器。Linux 的 CPU 调度器并非从设计之初就是像今天这样复杂的,在很长的一段时间里(v0.01 ~ v2.4),Linux 的进程调度都由几十行的简单函数负责,咱们先了解一下不一样版本调度器的历史:
这里会详细介绍从最初的调度器到今天复杂的彻底公平调度器(Completely Fair Scheduler,CFS)的演变过程。
Linux 最初的进程调度器仅由 sched.h 和 sched.c 两个文件构成。你可能很难想象 Linux 早期版本使用只有几十行的 schedule 函数负责了操做系统进程的调度:
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 个。
图 17 - 最初的进程调度器
上述函数会先唤醒得到信号的可中断进程,而后从队列倒序查找计数器 counter 最大的可执行进程,counter 是进程可以占用的时间切片数量,该函数会根据时间切片的值执行不一样的逻辑:
Linux 操做系统的计时器会每隔 10ms 触发一次 do_timer 将当前正在运行进程的 counter 减一,当前进程的计数器归零时就会从新触发调度。
调度器是 Linux 在 v2.4 ~ v2.6 版本使用的调度器,因为该调取器在最坏的状况下会遍历全部的任务,因此它调度任务的时间复杂度就是 。Linux 调度算法将 CPU 时间分割成了不一样的时期(Epoch),也就是每一个任务可以使用的时间切片。
咱们能够在 sched.h 和 sched.c 两个文件中找到调度器的源代码。与上一个版本的调度器相比, 调度器的实现复杂了不少,该调度器会在 schedule 函数中遍历运行队列中的全部任务并调用 goodness 函数分别计算它们的权重得到下一个运行的进程:
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 左右的时间切片,然而这种调度和分配方式是 调度器的最大问题:
正是由于调度器存在了上述的问题,因此 Linux 内核在两个版本后使用新的 调度器替换该实现。
调度器在 v2.6.0 到 v2.6.22 的 Linux 内核中使用了四年的时间,它可以在常数时间内完成进程调度,你能够在sched.h 和 sched.c 中查看 调度器的源代码。由于实现和功能复杂性的增长,调度器的代码行数从 的 2100 行增长到 5000 行,它在调度器的基础上进行了以下的改进:
调度器经过运行队列 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 共同组成了以下图所示的数据结构:
图 18 - 优先数组
优先数组的 bitmap 总共包含 140 位,每一位都表示对应优先级的进程是否存在。图 17 中的优先数组包含 3 个优先级为 2 的进程和 1 个优先级为 5 的进程。每个优先级的标志位都对应一个 list_head 数组中的链表。 调度器使用上述的数据结构进行以下所示的调度:
上述的这些规则是 调度器运行遵照的主要规则,除了上述规则以外,调度器还须要支持抢占、CPU 亲和等功能,不过在这里就不展开介绍了。
全局的运行队列是 调度器难以在对称多处理器架构上扩展的主要缘由。为了保证运行队列的一致性,调度器在调度时须要获取运行队列的全局锁,随着处理器数量的增长,多个处理器在调度时会致使更多的锁竞争,严重影响调度性能。 调度器经过引入本地运行队列解决这个问题,不一样的 CPU 能够经过 this_rq 获取绑定在当前 CPU 上的运行队列,下降了锁的粒度和冲突的可能性。
#define this_rq() (&__get_cpu_var(runqueues))
图 19 - 全局运行队列和本地运行队列
多个处理器因为再也不须要共享全局的运行队列,因此加强了在对称对处理器架构上的扩展性,当咱们增长新的处理器时,只须要增长新的运行队列,这种方式不会引入更多的锁冲突。
调度器中包含两种不一样的优先级计算方式,一种是静态任务优先级,另外一种是动态任务优先级。在默认状况下,任务的静态任务优先级都是 0,不过咱们能够经过系统调用 nice 改变任务的优先级; 调度器会奖励 I/O 密集型任务并惩罚 CPU 密集型任务,它会经过改变任务的静态优先级来完成优先级的动态调整,由于与用户交互的进程时 I/O 密集型的进程,这些进程因为调度器的动态策略会提升自身的优先级,从而提高用户体验。
彻底公平调度器(Completely Fair Scheduler,CFS)是 v2.6.23 版本被合入内核的调度器,也是内核的默认进程调度器,它的目的是最大化 CPU 利用率和交互的性能。Linux 内核版本 v2.6.23 中的 CFS 由如下的多个文件组成:
经过 CFS 的名字咱们就能发现,该调度器的能为不一样的进程提供彻底公平性。一旦某些进程受到了不公平的待遇,调度器就会运行这些进程,从而维持全部进程运行时间的公平性。这种保证公平性的方式与『水多了加面,面多了加水』有一些类似:
调度器算法不断计算各个进程的运行时间并依次调度队列中的受到最不公平对待的进程,保证各个进程的运行时间差不会大于最小运行的时间单位。
虽然咱们仍是会延用运行队列这一术语,可是 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)是平衡的二叉搜索树,红黑树的增删改查操做的最坏时间复杂度为 ,也就是树的高度,树中最左侧的节点 rb_leftmost 运行的时间最短,也是下一个待运行的进程。
注:在最新版本的 CFS 实现中,内核使用虚拟运行时间 vruntime 替代了等待时间,可是基本的调度原理和排序方式没有太多变化。
CFS 的调度过程仍是由 schedule 函数完成的,该函数的执行过程能够分红如下几个步骤:
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_NORMAL、SCHED_BATCH、SCHED_IDLE、SCHED_FIFO 和 SCHED_RR 调度类,这些不一样的调度类分别实现了 sched_class 中的函数以提供不一样的调度行为。
本节介绍了操做系统调度器的设计原理以及演进的历史,从 2007 年合入 CFS 到如今已通过去了很长时间,目前的调度器也变得更加复杂,社区也在不断改进进程调度器。
咱们能够从 Linux 调度器的演进的过程看到主流系统架构的变化,最初几十行代码的调度器就能完成基本的调度功能,而如今要使用几万行代码来完成复杂的调度,保证系统的低延时和高吞吐量。
因为篇幅有限,咱们很难对操做系统的调度器进行面面俱到的分析,你能够在 这里 找到做者使用的 Linux 源代码,亲自动手分析不一样版本的进程调度器。
Go 语言是诞生自 2009 年的编程语言,相信不少人对 Go 语言的印象都是语法简单,可以支撑高并发的服务。语法简单是编程语言的顶层设计哲学,而语言的高并发支持依靠的是运行时的调度器,这也是本节将要研究的内容。
对 Go 语言稍微有了解的人都知道,通讯顺序进程(Communicating sequential processes,CSP)影响着 Go 语言的并发模型,其中的 Goroutine 和 Channel 分别表示实体和用于通讯的媒介。
图 20 - Go 和 Erlang 的并发模型
『不要经过共享内存来通讯,咱们应该使用通讯来共享内存』不仅是 Go 语言鼓励的设计哲学,更为古老的 Erlang 语言其实也遵循了一样的设计,可是 Erlang 选择使用了Actor 模型,咱们在这里就不介绍 CSP 和 Actor 的区别和联系的,感兴趣的读者能够在推荐阅读和应引用中找到相关资源。
今天的 Go 语言调度器有着很是优异的性能,可是若是咱们回过头从新看 Go 语言的 v0.x 版本的调度器就会发现最初的调度器很是简陋,也没法支撑高并发的服务。整个调度器通过几个大版本的迭代才有了今天的优异性能。
除了多线程、任务窃取和抢占式调度器以外,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); }
该函数会遵循以下所示的过程执行:
这个单线程调度器的惟一优势就是能跑,不过从此次提交中咱们能看到 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 函数上,目前的调度器实现有如下问题须要解决:
这里的全局锁问题和 Linux 操做系统调度器在早期遇到的问题比较类似,解决方案也都大同小异。
2012 年 Google 的工程师 Dmitry Vyukov 在 Scalable Go Scheduler Design Doc 中指出了现有多线程调度器的问题并在多线程调度器上提出了两个改进的手段:
基于任务窃取的 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); }
当前处理器本地的运行队列中不包含 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 的关系。
图 21 - G-M-P 模型
基于工做窃取的多线程调度器将每个线程绑定到了独立的 CPU 上并经过不一样处理器分别管理,不一样处理器中经过工做窃取对任务进行再分配,提高了调度器和 Go 语言程序的总体性能,今天全部的 Go 语言服务的高性能都受益于这一改动。
对 Go 语言并发模型的修改提高了调度器的性能,可是在 1.1 版本中的调度器仍然不支持抢占式调度,程序只能依靠 Goroutine 主动让出 CPU 资源。Go 语言的调度器在1.2 版本中引入了基于协做的抢占式调度解决下面的问题:
然而 1.2 版本中实现的抢占式调度是基于协做的,在很长的一段时间里 Go 语言的调度器都包含一些没法被抢占的边缘状况,直到 1.14 才实现了基于信号的真抢占式调度解决部分问题。
咱们能够在 proc.c 文件中找到引入抢占式调度后的调度器实现。Go 语言会在当前的分段栈机制上实现抢占式的调度,全部的 Goroutine 在函数调用时都有机会进入运行时检查是否须要执行抢占。基于协做的抢占是经过如下的多个提交实现的:
从上述一系列的提交中,咱们会发现 Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时提出抢占请求 StackPreempt;由于编译器会在函数调用中插入 runtime.newstack,因此函数调用时会经过 runtime.newstack 检查 Goroutine 的 stackguard0 是否为 StackPreempt 进而触发抢占让出当前线程。
这种作法没有带来运行时的过多额外开销,实现也相对比较简单,不过增长了运行时的复杂度,整体来看仍是一种比较成功的实现。由于上述的抢占是经过编译器在特定时机插入函数实现的,仍是须要函数调用做为入口才能触发抢占,因此这是一种协做式的抢占式调度。
协做的抢占式调度实现虽然巧妙,可是留下了不少的边缘状况,咱们能在 runtime: non-cooperative goroutine preemption 中找到一些遗留问题:
Go 语言在 1.14 版本中实现了非协做的抢占式调度,在实现的过程当中咱们对已有的逻辑进行重构并为 Goroutine 增长新的状态和字段来支持抢占。Go 团队经过下面提交的实现了这一功能,咱们能够顺着提交的顺序理解其实现原理:
目前的抢占式调度也只会在垃圾回收扫描任务时触发,咱们能够梳理一下触发抢占式调度的过程:
上述 9 个步骤展现了基于信号的抢占式调度的执行过程。咱们还须要讨论一下该过程当中信号的选择,提案根据如下的四个缘由选择 SIGURG 做为触发异步抢占的信号:
目前的抢占式调度也没有解决全部潜在的问题,由于 STW 和栈扫描时更可能出现问题,也是一个能够抢占的安全点(Safe-points),因此咱们会在这里先加入抢占功能,在将来可能会加入更多抢占时间点。
非均匀内存访问(Non-uniform memory access,NUMA)调度器目前只是 Go 语言的提案,由于该提案过于复杂,而目前的调度器的性能已经足够优异,因此暂时没有实现该提案。该提案的原理就是经过拆分全局资源,让各个处理器可以就近获取本地资源,减小锁竞争并增长数据局部性。
在目前的运行时中,线程、处理器、网络轮训器、运行队列、全局内存分配器状态、内存分配缓存和垃圾收集器都是全局的资源。运行时没有保证本地化,也不清楚系统的拓扑结构,部分结构能够提供必定的局部性,可是从全局来看没有这种保证。
图 22 - Go 语言 NUMA 调度器
如上图所示,堆栈、全局运行队列和线程池会按照 NUMA 节点进行分区,网络轮训器和计时器会由单独的处理器持有。这种方式虽然可以利用局部性提升调度器的性能,可是自己的实现过于复杂,因此 Go 语言团队尚未着手实现这一提案。
Go 语言的调度器在最初的几个版本中迅速迭代,可是从 1.2 版本以后调度器就没有太多的变化,直到 1.14 版本引入了真正的抢占式调度解决了自 1.2 以来一直存在的问题。在可预见的将来,Go 语言的调度器还会进一步演进,增长抢占式调度的时间点减小存在的边缘状况。
本节内容选择《Go 语言设计与实现》一书中的 Go 语言调度器实现原理,你能够点击连接了解更多与 Go 语言设计与实现原理相关的内容。
Kubernetes 是生产级别的容器调度和管理系统,在过去的一段时间中,Kubernetes 迅速占领市场,成为容器编排领域的实施标准。
图 23 - 容器编排系统演进
Kubernetes 是希腊语『舵手』的意思,它最开始由 Google 的几位软件工程师创立,深受公司内部Borg 和 Omega 项目的影响,不少设计都是从 Borg 中借鉴的,同时也对 Borg 的缺陷进行了改进,Kubernetes 目前是 Cloud Native Computing Foundation (CNCF) 的项目,也是不少公司管理分布式系统的解决方案。
调度器是 Kubernetes 的核心组件,它的主要功能是为待运行的工做负载 Pod 绑定运行的节点 Node。与其余调度场景不一样,虽然资源利用率在 Kubernetes 中也很是重要,可是这只是 Kubernetes 关注的一个因素,它须要在容器编排这个场景中支持很是多而且复杂的业务需求,除了考虑 CPU 和内存是否充足,还须要考虑其余的领域特定场景,例如:两个服务不能占用同一台机器的相同端口、几个服务要运行在同一台机器上,根据节点的类型调度资源等。
这些复杂的业务场景和调度需求使 Kubernetes 调度器的内部设计与其余调度器彻底不一样,可是做为用户应用层的调度器,咱们却能从中学到不少有用的模式和设计。接下来,本节将介绍 Kubernetes 中调度器的设计以及演变。
Kubernetes 调度器的演变过程比较简单,咱们能够将它的演进过程分红如下的两个阶段:
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 期间也引入了不少改进:
谓词和优先级都是 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 版本中调度器的执行过程:
图 24 - 谓词和优先级算法
如上图所示,咱们假设调度器中存在一个谓词算法和一个 Map-Reduce 优先级算法,当咱们为一个 Pod 在 6 个节点中选择最合适的一个时,6 个节点会先通过谓词的筛选,图中的谓词算法会过滤掉一半的节点,剩余的 3 个节点通过 Map 和 Reduce 两个过程分别获得了 五、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 }
这就是使用谓词和优先级算法时的调度过程,咱们在这里省略了调度器的优先队列中的排序,出现调度错误时的抢占以及 Pod 持久存储卷绑定到 Node 上的过程,只保留了核心的调度逻辑。
Kubernetes 调度框架是 Babak Salamat 和 Jonathan Basseri 2018 年提出的最新调度器设计,这个提案明确了 Kubernetes 中的各个调度阶段,提供了设计良好的基于插件的接口。调度框架认为 Kubernetes 中目前存在调度(Scheduling)和绑定(Binding)两个循环:
除了两个大循环以外,调度框架中还包含 QueueSort、PreFilter、Filter、PostFilter、Score、Reserve、Permit、PreBind、Bind、PostBind 和 Unreserve 11 个扩展点(Extension Point),这些扩展点会在调度的过程当中触发,它们的运行顺序以下:
图 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 } ... }
由于每一次调度决策都会改变上下文,因此该阶段 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) } }() }
目前的调度框架在 Kubernetes v1.17.0 版本中仍是 Alpha 阶段,不少功能还不明确,为了支持更多、更丰富的场景,在接下来的几个版本还可能会作出不少改进,不过调度框架在很长的一段时间中都会是调度器的核心。
本节介绍了 Kubernetes 调度器从 v1.0.0 到最新版本中的不一样设计,Kubernetes 调度器中总共存在两种不一样的设计,一种是基于谓词和优先级算法的调度器,另外一种是基于调度框架的调度器。
不少的业务调度器也须要从多个选项中选出最优的选择,不管是成本最低仍是质量最优,咱们能够考虑将调度的过程分红过滤和打分两个阶段为调度器创建合适的抽象,过滤阶段会按照需求过滤掉不知足需求的选项,打分阶段可能会按照质量、成本和权重对多个选项进行排序,遵循这种设计思路能够解决不少相似问题。
目前的 Kubernetes 已经经过调度框架详细地支持了多个阶段的扩展方法,几乎是调度器内部实现的最终形态了。不过随着调度器功能的逐渐复杂,将来可能还会遇到更复杂的调度场景,例如:多租户的调度资源隔离、多调度器等功能,而 Kubernetes 社区也一直都在为构建高性能的调度器而努力。
从操做系统、编程语言到应用程序,咱们在这篇文章中分析了 Linux、Go 语言和 Kubernetes 调度器的设计与实现原理,这三个不一样的调度器其实有相互依赖的关系:
图 26 - 三层调度器
如上图所示,Kubernetes 的调度器依赖于 Go 语言的运行时调度器,而 Go 语言的运行时调度器也依赖于 Linux 的进程调度器,从上到下离用户愈来愈远,从下到上愈来愈关注具体业务。咱们在最后经过两个比较分析一下这几个调度器的异同:
这是两种不一样层面的比较,相信经过不一样角度的比较可以让咱们对调度器的设计有更深刻的认识。
首先是 Linux 和 Go 语言调度器,这两个调度器的场景都很是类似,它们最终都是要充分利用机器上的 CPU 资源,因此在实现和演进上有不少类似之处:
由于场景很是类似,因此它们的目的也很是类似,只是它们调度的任务粒度会有不一样,Linux 进程调度器的最小调度单位是线程,而 Go 语言是 Goroutine,与 Linux 进程调度器相比,Go 语言在用户层创建新的模型,实现了另外一个调度器,为使用者提供轻量级的调度单位来加强程序的性能,可是它也引入了不少组件来处理系统调用、网络轮训等线程相关的操做,同时组合多个不一样粒度的任务致使实现相对复杂。
Linux 调度器的最终设计引入了调度类的概念,让不一样任务的类型分别享受不一样的调度策略以此来调和低延时和实时性这个在调度上两难的问题。
Go 语言的调度器目前刚刚引入了基于信号的抢占式调度,还有不少功能都不完善。除了抢占式调度以外,复杂的 NUMA 调度器提案也多是将来 Go 语言的发展方向。
若是咱们将系统调度器和业务调度器进行对比的话,你会发现二者在设计差异很是大,毕竟它们处于系统的不一样层级。系统调度器考虑的是极致的性能,因此它经过分区的方式将运行队列等资源分离,经过下降锁的粒度来下降系统的延迟;而业务调度器关注的是完善的调度功能,调度的性能虽然十分重要,可是必定要创建在知足特定调度需求之上,而由于业务上的调度需求每每都是比较复杂,因此只能作出权衡和取舍。
正是由于需求的不一样,咱们会发现不一样调度器的演进过程也彻底不一样。系统调度器都会先充分利用资源,下降系统延时,随后在性能没法优化时才考虑加入调度类等功能知足不一样场景下的调度,而 Kubernetes 调度器更关注内部不一样调度算法的组织,如何同时维护多个复杂的调度算法,当设计了良好的抽象以后,它才会考虑更加复杂的多调度器、多租户等场景。
3. 最后
这种研究历史变化带来的快乐是很不一样的,当咱们发现代码发生变化的缘由时也会感到欣喜,这让咱们站在今天从新见证了历史上的决策,本文中的相应章节已经包含了对应源代码的连接,各位读者能够自行阅读相应内容,也衷心但愿各位读者可以有所收获。
查看更多:https://yq.aliyun.com/article..._content=g_1000104318
上云就看云栖号:更多云资讯,上云案例,最佳实践,产品入门,访问:https://yqh.aliyun.com/