【AICon】 如何构建高效的 RAG 系统?RAG 技术在实际应用中遇到的挑战及应对策略?>>> 了解详情
写点什么

调度系统设计精要

  • 2020-02-28
  • 本文字数:15438 字

    阅读完需:约 51 分钟

调度系统设计精要


图 8 - 抢占式调度


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


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

单调度器与多调度器

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



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


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


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



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


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


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



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


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

工作分享与工作窃取

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


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



图 12 - 工作窃取调度器


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

架构设计

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

调度器内部

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



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


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

状态模块

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

决策模块

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


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


  1. 通过优先级、任务创建时间等信息确定不同任务的调度顺序;

  2. 通过过滤和打分两个阶段为任务选择合适的资源;

  3. 不存在满足条件的资源时,选择牺牲的抢占对象;



图 14 - 调度框架


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

调度器外部

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

多调度器

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

反调度器

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



图 15 - 调度器与反调度器


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


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

操作系统

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


有一些读者可能会感到困惑,操作系统调度的最小单位不是线程么,为什么这里使用的是进程调度。在 Linux 操作系统中,调度器调度的不是进程也不是线程,它调度的是 task_struct 结构体,该结构体既可以表示线程,也可以表示进程,而调度器会将进程和线程都看成任务,我们在这里先说明这一问题,避免读者感到困惑5。我们会使用进程调度器这个术语,但是一定要注意 Linux 调度器中并不区分线程和进程。


Linux incorporates process and thread scheduling by treating them as one in the same. A process can be viewed as a single thread, but a process can contain multiple threads that share some number of resources (code and/or data).


接下来,本节会研究操作系统中调度系统的类型以及 Linux 进程调度器的演进过程。

调度系统类型

操作系统会将进程调度器分成三种不同的类型,即长期调度器、中期调度器和短期调度器。这三种不同类型的调度器分别提供了不同的功能,我们将在这一节中依次介绍它们。

长期调度器

长期调度器(Long-Term Scheduler)也被称作任务调度器(Job Scheduler),它能够决定哪些任务会进入调度器的准备队列。当我们尝试执行新的程序时,长期调度器会负责授权或者延迟该程序的执行。长期调度器的作用是平衡同时正在运行的 I/O 密集型或者 CPU 密集型进程的任务数量:


  • 如果 I/O 密集型任务过多,就绪队列中就不存在待调度的任务,短期调度器不需要执行调度,CPU 资源就会面临闲置;

  • 如果 CPU 密集型任务过多,I/O 等待队列中就不存在待调度的任务,I/O 设备就会面临闲置;


长期调度器能平衡同时正在运行的 I/O 密集型和 CPU 密集型任务,最大化的利用操作系统的 I/O 和 CPU 资源。

中期调度器

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



图 16 - 中期调度器


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

短期调度器

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

设计与演进

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


  • 初始调度器 · v0.01 ~ v2.4

  • 由几十行代码实现,功能非常简陋;

  • 同时最多处理 64 个任务;

  • O(n)O(n)调度器 · v2.4 ~ v2.6

  • 调度时需要遍历全部任务;

  • 当待执行的任务较多时,同一个任务两次执行的间隔很长,会有比较严重的饥饿问题;

  • O(1)O(1)调度器 · v2.6.0 ~ v2.6.22

  • 通过引入运行队列和优先数组实现 O(1)O(1)的时间复杂度;

  • 使用本地运行队列替代全局运行队列增强在对称多处理器的扩展性;

  • 引入工作窃取保证多个运行队列中任务的平衡;

  • 完全公平调度器 · v2.6.23 ~ 至今

  • 引入红黑树和运行时间保证调度的公平性;

  • 引入调度类实现不同任务类型的不同调度策略;


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

初始调度器

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


C


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 是进程能够占用的时间切片数量,该函数会根据时间切片的值执行不同的逻辑:


  • 如果最大的 counter 时间切片大于 0,调用汇编语言的实现的 switch_to 切换进程;

  • 如果最大的 counter 时间切片等于 0,意味着所有进程的可执行时间都为 0,那么所有进程都会获得新的时间切片;


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

O(n) 调度器

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


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


Go


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 次,所以调度器被称作 O(n)O(n)调度器。在默认情况下,每个任务在一个周期都会分配到 200ms 左右的时间切片,然而这种调度和分配方式是 O(n)O(n)调度器的最大问题:


  • 每轮调度完成之后就会陷入没有任务需要调度的情况,需要提升交互性能的场景会受到严重影响,例如:在桌面拖动鼠标会感觉到明显的卡顿;

  • 每次查找权重最高的任务都需要遍历数组中的全部任务;

  • 调度器分配的平均时间片大小为 210ms8,当程序中包含 100 个进程时,同一个进程被运行两次的间隔是 21s,这严重影响了操作系统的可用性;


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

O(1) 调度器

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


  • 调度器支持了 O(1)O(1)时间复杂度的调度;

  • 调度器支持了对称多处理(Symmetric multiprocessing,SMP)的扩展性;

  • 调度器优化了对称多处理的亲和性;

数据结构

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


C


struct runqueue {  ...  prio_array_t *active, *expired, arrays[2];  ...}
struct prio_array { unsigned int nr_active; unsigned long bitmap[BITMAP_SIZE]; struct list_head queue[MAX_PRIO];};
复制代码


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



图 18 - 优先数组


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


  1. 调用 sched_find_first_bit 按照优先级分配 CPU 资源;

  2. 调用 schedule 从链表头选择进程执行;

  3. 通过 schedule 轮询调度同一优先级的进程,该函数在每次选中待执行的进程后,将进程添加到队列的末尾,这样可以保证同一优先级的进程会依次执行(Round-Robin);

  4. 计时器每隔 1ms 会触发一次 scheduler_tick 函数,如果当前进程的执行时间已经耗尽,就会将其移入过期数组;

  5. 当活跃队列中不存在待运行的进程时,schedule 会交换活跃优先数组和过期优先数组;


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

本地运行队列

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


C


#define this_rq()    (&__get_cpu_var(runqueues))
复制代码



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


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

优先级和时间切片

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

完全公平调度器

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



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


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

  2. 进程运行结束之后发现运行队列中又有了其他的进程受到了最不公平的待遇,调度器又会运行新的进程;


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

数据结构

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


C


struct cfs_rq {  struct load_weight load;  unsigned long nr_running;
s64 fair_clock; u64 exec_clock; s64 wait_runtime; u64 sleeper_bonus; unsigned long 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,红黑树的增删改查操作的最坏时间复杂度为 O(logn)O(log⁡n),也就是树的高度,树中最左侧的节点 rb_leftmost 运行的时间最短,也是下一个待运行的进程。


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

调度过程

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


  1. 关闭当前 CPU 的抢占功能;

  2. 如果当前 CPU 的运行队列中不存在任务,调用 idle_balance 从其他 CPU 的运行队列中取一部分执行;

  3. 调用 pick_next_task 选择红黑树中优先级最高的任务;

  4. 调用 context_switch 切换运行的上下文,包括寄存器的状态和堆栈;

  5. 重新开启当前 CPU 的抢占功能;


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

调度类

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


C


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_FIFOSCHED_RR 调度类,这些不同的调度类分别实现了 sched_class 中的函数以提供不同的调度行为。

小结

本节介绍了操作系统调度器的设计原理以及演进的历史,从 2007 年合入 CFS 到现在已经过去了很长时间,目前的调度器12也变得更加复杂,社区也在不断改进进程调度器。


我们可以从 Linux 调度器的演进的过程看到主流系统架构的变化,最初几十行代码的调度器就能完成基本的调度功能,而现在要使用几万行代码来完成复杂的调度,保证系统的低延时和高吞吐量。


由于篇幅有限,我们很难对操作系统的调度器进行面面俱到的分析,你可以在 这里 找到作者使用的 Linux 源代码,亲自动手分析不同版本的进程调度器。

延伸阅读

Go 语言

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


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



图 20 - Go 和 Erlang 的并发模型


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

设计与演进

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


  • 单线程调度器 · 0.x

  • 只包含 40 多行代码;

  • 程序中只能存在一个活跃线程,由 G-M 模型组成;

  • 多线程调度器 · 1.0

  • 允许运行多线程的程序;

  • 全局锁导致竞争严重;

  • 任务窃取调度器 · 1.1

  • 引入了处理器 P,构成了目前的 G-M-P 模型;

  • 在处理器 P 的基础上实现了基于工作窃取的调度器;

  • 在某些情况下,Goroutine 不会让出线程,进而造成饥饿问题;

  • 时间过长的垃圾回收(Stop-the-world,STW)会导致程序长时间无法工作;

  • 抢占式调度器 · 1.2 ~ 至今

  • 基于协作的抢占式调度器 - 1.2 ~ 1.13

  • 通过编译器在函数调用时插入抢占检查指令,在函数调用时检查当前 Goroutine 是否发起了抢占请求,实现基于协作的抢占式调度;

  • Goroutine 可能会因为垃圾回收和循环长时间占用资源导致程序暂停;

  • 基于信号的抢占式调度器 - 1.14 ~ 至今

  • 实现基于信号的真抢占式调度

  • 垃圾回收在扫描栈时会触发抢占调度;

  • 抢占的时间点不够多,还不能覆盖全部的边缘情况;

  • 非均匀存储访问调度器 · 提案

  • 对运行时的各种资源进行分区;

  • 实现非常复杂,到今天还没有提上日程;


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

单线程调度器

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


C


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);}
复制代码


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


  1. 获取调度器的全局锁;

  2. 调用 runtime.gosave 保存栈寄存器和程序计数器;

  3. 调用 runtime.nextgandunlock 获取下一个需要运行的 Goroutine 并解锁调度器;

  4. 修改全局线程 m 上要执行的 Goroutine;

  5. 调用 runtime.gogo 函数运行最新的 Goroutine;


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

多线程调度器

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


C


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.futex15,该调度器有以下问题需要解决:


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


Go


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. 如果当前运行时在等待垃圾回收,调用 runtime.gcstopm 函数;

  2. 调用 runtime.runqgetruntime.findrunnable 从本地或者全局的运行队列中获取待执行的 Goroutine;

  3. 调用 runtime.execute 函数在当前线程 M 上运行 Goroutine;


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


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


C


struct P {  Lock;
uint32 status; P* link; uint32 tick; M* m; MCache* mcache;
G** runq; int32 runqhead; int32 runqtail; int32 runqsize;
G* gfree; int32 gfreecnt;};
复制代码


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



图 21 - G-M-P 模型


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

抢占式调度器

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


  • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;

  • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间18,导致整个程序无法工作;


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

基于协作的抢占式调度

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



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


  1. 编译器会在调用函数前插入 runtime.morestack

  2. Go 语言运行时会在垃圾回收暂停程序、系统监控发现 Goroutine 运行超过 10ms 时发出抢占请求 StackPreempt

  3. 当发生函数调用时,可能会执行编译器插入的 runtime.morestack 函数,它调用的 runtime.newstack 会检查 Goroutine 的 stackguard0 字段是否为 StackPreempt

  4. 如果 stackguard0StackPreempt,就会触发抢占让出当前线程;


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

基于信号的抢占式调度

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



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



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


  1. 程序启动时,在 runtime.sighandler 函数中注册 SIGURG 信号的处理函数 runtime.doSigPreempt

  2. 在触发垃圾回收的栈扫描时会调用 runtime.suspendG 挂起 Goroutine,该函数会执行下面的逻辑:

  3. _Grunning 状态的 Goroutine 标记成可以被抢占,即将 preemptStop 设置成 true

  4. 调用 runtime.preemptM 触发抢占;

  5. runtime.preemptM 会调用 runtime.signalM 向线程发送信号 SIGURG

  6. 操作系统会中断正在运行的线程并执行预先注册的信号处理函数 runtime.doSigPreempt

  7. runtime.doSigPreempt 函数会处理抢占信号,获取当前的 SP 和 PC 寄存器并调用 runtime.sigctxt.pushCall

  8. runtime.sigctxt.pushCall 会修改寄存器并在程序回到用户态时执行 runtime.asyncPreempt

  9. 汇编指令 runtime.asyncPreempt 会调用运行时函数 runtime.asyncPreempt2

  10. runtime.asyncPreempt2 会调用 runtime.preemptPark

  11. runtime.preemptPark 会修改当前 Goroutine 的状态到 _Gpreempted 并调用 runtime.schedule 让当前函数陷入休眠并让出线程,调度器会选择其它的 Goroutine 继续执行;


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


  1. 该信号需要被调试器透传;

  2. 该信号不会被内部的 libc 库使用并拦截;

  3. 该信号可以随意出现并且不触发任何后果;

  4. 我们需要处理多个平台上的不同信号;


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

非均匀内存访问调度器

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


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



图 22 - Go 语言 NUMA 调度器


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

小结

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


本文转载自 Draveness 技术网站。


原文链接:https://draveness.me/system-design-scheduler


2020-02-28 17:151404

评论

发布
暂无评论
发现更多内容

博睿数据分布式手机真机监测+两大核心技术,轻松掌控短信服务质量与用户体验

博睿数据

Java变量使用小技巧~

Bob

Java 变量 8月日更

清华大牛带领20位大厂专家耗时三年整理出这份2000页Java进阶指南

Java~~~

Java MySQL 架构 面试 微服务

深耕城市治理场景,百度智能云联合慧联无限推内涝智能检测预警

百度大脑

人工智能 洪水

让GitHub低头!这份阿里内部的10W字Java面试手册到底有多强?

Java~~~

Java spring 面试 微服务 JVM

DCS_FunTester分布式压测框架更新(二)

FunTester

分布式 性能测试 测试框架 测试开发 FunTester

聊聊我在腾讯和字节工作感受

程序员鱼皮

Java 互联网 求职 IT 实习

我看 JAVA 之 并发编程【四】线程安全与JMM

awen

Java JMM 线程安全

2021年最佳VUE3 UI框架推荐

devpoint

Vue3 antd vue vue2 8月日更

小心这个陷阱:为什么总是你赔钱?

非著名程序员

认知提升 个人提升 投资理财 8月日更

【Takin应用日记】记一次TransmittableThreadLocal引起的业务异常

TakinTalks稳定性社区

高可用 性能压测 生产环境全链路压测 takin

Hadoop生态系统Hive:SQL执行(一)

正向成长

hadoop hive

先到先得!Alibaba最新出版的JDK源码剖析手册(究极奥义版)开源

Java 编程 jdk 面试 程序人生

交易所市值管理机器人搭建,项目方市值管理软件源码

Aosp 之 Property

Qunar技术沙龙

android API properties 字典树 内存映射

网络攻防学习笔记 Day103

穿过生命散发芬芳

态势感知 网络攻防 8月日更

猎杀时刻!疯狂狩猎大厂offer,阿里高工总结698页Spring学习笔记

Java 编程 架构 面试 架构师

架构实战营毕业总结

thewangzl

未来25年看隐私计算与区块链

CECBC

fil挖矿官网有哪些?fil挖矿平台有哪些?

fil挖矿平台有哪些 fil挖矿官网有哪些

极客星球 | 应用开发的性能优化探索

MobTech袤博科技

性能

员工流动大难管理?织信低代码+人事管理系统轻松掌控员工档案信息

优秀

低代码

求阿里大神整理的分布式核心笔记出炉了,GitHub刚发布就标星85k

Java~~~

Java 架构 面试 微服务 JVM

架构训练营 - 模块四 - 作业

姑射仙人

架构训练营

趁着课余时间学点Python(十一)面向对象的理解(高潮)

ベ布小禅

8月日更

写作 7 堂课——【7. 复盘式写作】

LeifChen

复盘 写作技巧 8月日更

熬夜28天肝完这25W字的九大Java核心专题,连续拿下多个大厂offer

Java~~~

Java 架构 面试 JVM 架构师

2021年7月券商App行情刷新及交易体验评测报告

博睿数据

秀到起飞!LeetCode官方推出算法面试指导手册(代码版)限时开源

Java~~~

Java 架构 面试 算法 架构师

用区块链技术实现电力供给“三流合一”

CECBC

MinIO Client 使用(二)

耳东@Erdong

Minio client 8月日更 minio client

调度系统设计精要_文化 & 方法_Draveness_InfoQ精选文章