文章目录
- 前言
- 一、简介
- 二、Linux 调度器
- 2.1 在单核系统上,CFS 非常简单
- 2.2 在多核系统上,CFS 变得非常复杂
- 2.2.1 负载均衡算法
- 2.2.2 优化措施
- 三、Linux调度器负载均衡的存在的问题
- 3.1 组负载不均衡问题(Group Imbalance Bug)
- 3.2 调度组构建问题(Scheduling Group Construction Bug)
- 3.3 唤醒时过载问题(Overload-on-Wakeup Bug)
- 3.4 调度域缺失问题(Missing Scheduling Domains Bug)
- 3.5 总结
- 四、调度器架构的重新思考
- 总结
- 参考资料
前言
看到一篇关于调度的论文,翻译其核心部分:https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
作为资源管理的核心部分,操作系统的线程调度器必须维护以下简单的不变式:确保将就绪线程调度到可用的核心上。尽管这看似简单,但我们发现,在 Linux 中这一不变式经常被打破。核心可能会闲置数秒,而就绪线程却在运行队列中等待。
在CPU核数越来越多的时代,更应该关心 把进程调度到哪里CPU核上 而不是 某个CPU核要运行哪个进程。
一、简介
经典的调度问题围绕着设置调度时间片(quantum)的长度,以在最小化上下文切换开销的同时提供交互响应性,同时兼顾批处理和交互式工作负载,并高效管理调度器的运行队列。总的来说,到 2000 年,操作系统设计者认为调度问题已经得到解决;Linus Torvalds 的言论准确反映了当时的普遍观点。
2004 年,Dennard 缩放定律的终结迎来了多核时代,并使能效成为计算机系统设计的首要关注点。这些变化再次使调度器变得重要,但同时也使其变得越来越复杂且常常出现问题。
我们最近对 Linux 调度器的研究表明,为了应对现代硬件的挑战性特性(如非统一内存访问延迟(NUMA)、缓存一致性和同步的高成本,以及 CPU 和内存延迟的分化),调度器的实现变得异常复杂。结果,调度器的基本功能——确保可运行线程使用空闲核心——反而被忽视了。
这项工作的主要贡献是发现并研究了 Linux 调度器中的四个性能问题。这些问题导致调度器在可运行线程等待运行时让核心处于空闲状态。对于典型的 Linux 工作负载,性能下降幅度在 13-24% 之间,在某些极端情况下甚至达到 138 倍。能源浪费与之成正比。由于这些问题破坏了内核的关键子系统,导致性能显著(有时是巨大)下降,并且避开了传统的测试和调试技术,因此理解它们的本质和起源非常重要。
这些问题的根本原因各不相同,但有一个共同的症状:调度器在无意中长时间让核心处于空闲状态,而运行队列中却有可运行的线程在等待。短期出现这种情况是可以接受的:例如,当线程退出、阻塞、创建或解除阻塞时,系统可能会暂时进入这种状态。但长期出现这种情况则不是预期行为。Linux 调度器是工作保守型的,这意味着如果有工作要做,它不应该让核心空闲。因此,长期出现这种症状是无意的:这是由于错误导致的,并且会损害性能。
我们提供了这些问题的修复方案,并观察到了显著的性能改进。
二、Linux 调度器
我们首先描述 Linux 的完全公平调度(CFS)算法在单核单用户系统上的工作原理(第 2.1 节)。从这个角度来看,该算法非常简单。然后,在第 2.2 节中,我们解释了现代多核系统的局限性如何迫使开发者绕过潜在的性能瓶颈,从而导致实现变得更加复杂且容易出错。
2.1 在单核系统上,CFS 非常简单
Linux 的 CFS 是加权公平队列(WFQ)调度算法的一种实现,其中可用的 CPU 周期按线程的权重比例分配给线程。为了支持这种抽象,CFS(像大多数其他 CPU 调度器一样)将 CPU 时间片分配给运行的线程。调度器中的关键决策是:如何确定线程的时间片?以及如何选择下一个要运行的线程?
调度器定义了一个固定的时间间隔,在此期间系统中的每个线程必须至少运行一次。该间隔按线程的权重比例分配给线程。分配后的间隔就是我们所说的时间片。线程的权重本质上是其优先级,或者在 UNIX 术语中称为 niceness。niceness 值较低的线程具有较高的权重,反之亦然。
当一个线程运行时,它会累积 vruntime(线程的运行时间除以其权重)。一旦线程的 vruntime 超过其分配的时间片,如果有其他可运行的线程可用,该线程将被抢占。如果另一个 vruntime 较小的线程被唤醒,当前线程也可能被抢占。
线程被组织在一个运行队列(runqueue)中,该队列实现为红黑树,其中线程按其 vruntime 的递增顺序排序。当 CPU 寻找新线程运行时,它会选择红黑树中最左边的节点,该节点包含 vruntime 最小的线程。
2.2 在多核系统上,CFS 变得非常复杂
在多核环境中,调度器的实现变得复杂得多。可扩展性问题要求使用每核运行队列。每核运行队列的动机是,在上下文切换时,核心只会访问其本地运行队列以寻找要运行的线程。上下文切换处于关键路径上,因此必须快速完成。仅访问核心本地队列可以避免调度器进行潜在的高成本同步访问,如果访问全局共享的运行队列,则需要进行这种同步访问。
然而,为了在存在每核运行队列的情况下仍然正确且高效地运行调度算法,必须保持运行队列的平衡。考虑一个双核系统,其中两个运行队列不平衡。假设一个队列中有一个低优先级线程,而另一个队列中有十个高优先级线程。如果每个核心仅在其本地运行队列中寻找工作,那么高优先级线程将比低优先级线程获得少得多的 CPU 时间,这不是我们想要的。我们可以让每个核心不仅检查其运行队列,还检查其他核心的队列,但这将违背每核运行队列的初衷。因此,Linux 和大多数其他调度器所做的是定期运行负载均衡算法,以保持队列大致平衡。
从概念上讲,负载均衡(load balancing) 是简单的。在 2001 年,CPU 大多是单核的,商用服务器系统通常只有少数几个处理器。因此,很难预见到在现代多核系统上,负载均衡会变得如此具有挑战性。在当今的系统上,负载均衡是一个开销很大的操作,无论是从计算角度还是通信角度来看:
(1)计算开销:负载均衡需要遍历数十个运行队列(runqueues)。
(2)通信开销:负载均衡涉及修改远程缓存的数据结构,导致极其昂贵的缓存失效(cache misses)和同步开销。
因此,调度器会尽量避免频繁执行负载均衡过程。然而,如果负载均衡执行得不够频繁,可能会导致运行队列不平衡。当这种情况发生时,核心可能会在仍有工作要做时处于空闲状态,从而损害性能。为了应对这一问题,调度器不仅会定期执行负载均衡,还会在核心空闲时触发“紧急”负载均衡,并在新创建或新唤醒的线程放置时实现一些负载均衡逻辑。理论上,这些机制应确保在有工作要做时,核心能够保持忙碌。
接下来,我们将描述负载均衡的工作原理,首先解释算法,然后介绍调度器为保持低开销和节省功耗而采用的优化措施。
2.2.1 负载均衡算法
理解负载均衡算法的关键在于 CFS 调度器用于跟踪负载的指标。我们首先解释这一指标,然后描述实际的算法。
负载跟踪指标:一个简单的负载均衡算法可能会确保每个运行队列(runqueue)中的线程数量大致相同。然而,这并不是我们真正需要的。考虑以下场景:有两个运行队列,一个队列中有一些高优先级线程,另一个队列中有相同数量的低优先级线程。如果仅按线程数量均衡负载,高优先级线程将获得与低优先级线程相同的 CPU 时间,这并不是我们想要的。因此,一个想法是基于线程的权重(weight)而不是数量来均衡负载。
然而,仅基于线程权重来均衡负载也是不够的。考虑以下场景:两个运行队列中共有十个线程,其中一个队列有一个高优先级线程,另一个队列有九个低优先级线程。假设高优先级线程的权重是低优先级线程的九倍。如果按权重均衡负载,一个运行队列将包含高优先级线程,而另一个队列将包含九个低优先级线程。高优先级线程将获得比低优先级线程多九倍的 CPU 时间,这看起来是我们想要的。但是,假设高优先级线程经常短暂睡眠,导致第一个核心经常空闲。这个核心将不得不频繁从另一个核心的运行队列中“偷取”工作以保持忙碌。然而,我们不希望“工作偷取”成为常态,因为这违背了每核运行队列的初衷。我们真正需要的是以更智能的方式均衡运行队列,考虑到高优先级线程并不需要占用整个核心。
为了实现这一目标,CFS 不仅基于权重,还基于一个称为**负载(load)**的指标来均衡运行队列。负载是线程权重与其平均 CPU 利用率的结合。如果一个线程没有使用太多 CPU,其负载将相应减少。
此外,负载跟踪指标还考虑了不同进程中的多线程级别。例如,假设一个进程有很多线程,而另一个进程只有少量线程。如果仅按权重均衡负载,第一个进程的线程将获得比第二个进程的线程多得多的 CPU 时间,导致第一个进程占用大部分 CPU 周期,而第二个进程被“饿死”。这是不公平的。因此,从 Linux 2.6.38 版本开始,引入了组调度(group scheduling)功能,以实现线程组之间的公平性(cgroup 功能)。当一个线程属于某个 cgroup 时,其负载会进一步除以其 cgroup 中的线程总数。这一功能后来被扩展为自动将属于不同 tty 的进程分配到不同的 cgroup 中(autogroup 功能)。
负载均衡算法:传统的负载均衡算法会直接比较所有核心的负载,然后将任务从负载最高的核心迁移到负载最低的核心。但这种方式可能导致线程在机器内随意迁移,而忽略缓存局部性(cache locality)和NUMA架构(非统一内存访问)的影响。为此,负载均衡器改用一种分层策略。
核心在逻辑上被组织成一个层次结构,最底层是单个核心。更高层次的组取决于核心如何共享机器的物理资源。在这里的示例中,我们描述了实验机器上的层次结构(参见表 5),其中:
1. 底层:单个核心。
2. 上层分组:根据核心共享的物理资源逐级分组。
(1)第二层:两个核心为一组,共享功能单元(如浮点运算单元FPU)。
(2)第三层:八个核心为一组,共享末级缓存(LLC),形成一个NUMA节点。
(3)更高层:不同的 NUMA 节点具有不同的连接性,如图 4 所示。
因此,在我们的目标系统中,层次结构的第二层是核心对,第三层是共享 LLC 的八核组(即 NUMA 节点)。NUMA 节点根据其连接性进一步分组。例如,一跳距离的 NUMA 节点会被分到同一层次,以此类推。图 1 展示了这种层次结构的示例。每一层被称为一个调度域(scheduling domain)。
调度域(Scheduling Domain):
每个层级(核心对、LLC组、NUMA节点组)称为一个调度域。
目标:优先在低层级(如共享缓存的组内)平衡负载,减少跨域迁移的开销。
Linux内核采用调度域解决现代多CPU多核的问题,调度域是具有相同属性和调度策略的处理器集合,任务进程可以在它们内部按照某种策略进行调度迁移。
进程在多CPU的负载均衡也是针对调度域的,调度域根据超线程、多核、SMP、NUMA等系统架构划分为不同的等级,不同的等级架构通过指针链接在一起,从而形成树状结构;在进程的负载均衡过程中,从树的叶子节点往上遍历,直到所有的域中的负载都是平衡的。
由于NUMA是以层次关系呈现,因此在执行进程的负载均衡也会呈现不同的成本开销。进程在同一个物理Core上的逻辑Core之前迁移开销最小;
如果在不同的物理Core之间迁移,如果每个物理Core拥有私有的L1 Cache,共享L2 Cache,进程迁移后就无法使用原来的L1 Cache,进程迁移到新的Core上缺失L1 Cache数据,这就需要进程的状态数据需要在CPU Core之间进行通信获取这些数据,成本代价是蛮大的。
工作原理:
(1)局部性优先:
先在核心对内部平衡任务,利用共享的FPU和缓存。
若无法平衡,再扩大到LLC组(同一NUMA节点)。
(2)NUMA感知:
跨NUMA节点迁移任务时,优先选择互联距离更近(“一跳”)的节点,降低内存访问延迟。
图 1:一台具有 32 个核心、四个节点(每个节点八个核心)以及核心对之间 SMT 级共享的机器
图中的四个灰色区域表示与机器的第一个核心相关的调度域。注意,在层次结构的第二层,我们有一个由三个节点组成的组。这是因为这三个节点可以从第一个核心通过一跳到达。在第四层,我们包含了机器的所有节点,因为所有节点都可以通过两跳到达。图 4 展示了我们系统中节点之间的连接性。
示例层次结构(见图1):
(1)层级1:单核。
(2)层级2:核心对(共享FPU)。
(3)层级3:8核组(共享LLC,即一个NUMA节点)。
(4)层级4:互联的NUMA节点组(按一跳、两跳等划分)。
优势
(1)缓存亲和性:减少跨核心/跨节点迁移导致的缓存失效。
(2)NUMA优化:避免远程内存访问带来的高延迟。
(3)动态扩展性:适应不同硬件拓扑(如多核、多LLC、多NUMA节点)。
负载均衡算法的步骤:
负载均衡算法如算法 1 所总结。负载均衡从最底层开始,逐层向上执行。在每个层次上,调度域中的一个核心负责执行负载均衡。
(1)选择负责均衡的核心(第2–9行):
每个调度域中,由一个核心负责负载均衡。
优先级:若域内有空闲核心(可用其空闲CPU周期执行均衡),则选第一个空闲核心;否则选调度域的第一个核心。
(2)计算负载与选择繁忙组(第10行):
计算调度域内每个调度组的平均负载。
通过启发式策略(优先选择过载且不均衡的组),选出最繁忙的调度组。
(3)判断负载是否均衡(第16行):
若最繁忙组的负载 ≤ 当前本地组的负载,则认为该层负载已平衡,结束当前层处理。
(4)执行负载均衡(第18–23行):
若负载不均衡,则在本地CPU与最繁忙组的CPU之间迁移任务。
引入优化措施:即使存在固定绑定的任务集(tasksets),仍确保均衡生效(如绕过绑定的限制)。
假设目前该算法在每个负载均衡周期中由所有核心执行;在下一节中,我们将解释,作为一种优化,并非所有核心都会实际执行该算法。执行算法的核心从层次结构的第二低层开始,均衡其下一层的负载。例如,在图 1 所示的系统中,核心会从核心对层次开始,均衡其中两个核心之间的负载。然后,它会继续到 NUMA 节点层次,均衡其下一层的负载(在这种情况下是核心对之间的负载),而不是 NUMA 节点内单个核心之间的负载。在调度域中,执行负载均衡的核心集合称为调度组(scheduling groups)。在 NUMA 节点域中,有四个调度组,每个组对应一个核心对。核心会找到最忙的调度组(除了自己所在的组),并从该组的最忙核心中“偷取”任务。
总结,负载均衡算法通过分层策略和优化措施,在确保负载平衡的同时,尽量减少缓存失效和 NUMA 延迟的影响。尽管算法在理论上能够有效工作,但在实际实现中,由于硬件复杂性、优化措施和极端情况的存在,可能导致一些问题(如负载不均衡、优先级反转等)。因此,调度器的设计和实现需要非常谨慎,并经过广泛的测试和验证。
2.2.2 优化措施
为了避免重复工作,调度器仅在给定调度域的指定核心上运行负载均衡算法。当每个活跃核心接收到周期性时钟中断并开始运行负载均衡算法时,它会检查自己是否是域中编号最小的核心(如果所有核心都忙碌),或者是否是编号最小的空闲核心(如果有核心空闲)。这在算法的第 2 行中有所体现。如果满足这一条件,核心会认定自己为指定核心,并继续运行算法。
节能相关的优化可能会进一步减少空闲核心上的负载均衡频率。最初,空闲核心会在每个时钟中断时被唤醒,此时它们会运行负载均衡算法。然而,从 Linux 2.6.21 版本开始,系统提供了避免定期唤醒睡眠核心的选项(现在默认启用):这些核心会进入无时钟中断的空闲状态(tickless idle state),从而降低能耗。在这种状态下,核心只有在其他核心过载时才会被唤醒以获取工作。为此,在每个调度中断时,如果核心认为自己“过载”,它会检查系统中是否存在一段时间内处于无时钟中断空闲状态的核心。如果存在,它会唤醒第一个无时钟中断空闲核心,并赋予其 NOHZ 均衡器的角色。NOHZ 均衡器核心负责在每个中断时为自己和所有无时钟中断空闲核心运行周期性负载均衡例程。
除了周期性负载均衡外,调度器还会在线程唤醒时进行负载均衡。当一个线程从睡眠或等待资源(如锁、I/O)中唤醒时,调度器会尝试将其放置在最空闲的核心上。如果线程是由另一个线程(唤醒线程)唤醒的,则会应用特殊规则:调度器会优先选择与唤醒线程共享缓存的核心,以提高缓存重用率。
三、Linux调度器负载均衡的存在的问题
3.1 组负载不均衡问题(Group Imbalance Bug)
Linux调度器对于调度域负载不均衡时,通过计算平均负载来确定需要迁移的负载量,计算一个调度域中需要迁移的负载量,可以使用以下方法:
(1)计算平均负载:首先,计算每个调度组中CPU的平均负载。可以通过查看每个CPU的负载指标(如CPU使用率、运行队列长度等)来计算平均负载。将每个调度组中CPU的负载指标相加并除以CPU数量,得到平均负载值。
(2)确定负载差异:然后,确定每个调度组的负载与平均负载之间的差异。将每个调度组的负载值减去平均负载值,得到负载差异值。正值表示负载较高,负值表示负载较低。
(3)计算迁移负载量:根据负载差异值,计算需要迁移的负载量。可以根据负载差异的绝对值来衡量需要迁移的负载量。较大的负载差异值表示需要迁移更多的负载量。
一个node一个CPU很忙,其它CPU空闲,通过计算平均负载,那么导致该node的平均负载也比较高,然后该node的其他CPU却空闲,反而无法运行其他node的任务。
调度器没有均衡负载的原因在于:
(1)负载跟踪指标的复杂性:线程的负载是其权重和所需 CPU 时间的组合。在自动分组(autogroup)机制下,线程的负载还会除以其父自动组中的线程数。
(2)分层设计的限制:为了限制算法复杂性,负载均衡算法采用了分层设计。当核心尝试从另一个节点(调度组)偷取任务时,它不会检查该组中每个核心的负载,而只会查看组的平均负载(算法 1 的第 11 行)。如果目标调度组的平均负载不大于本地组的平均负载,则不会尝试偷取任务。
具体问题分析:
比如 节点 0 和 4 的核心由于运行高负载的 R 线程,导致整个节点的平均负载被拉高,但是节点 0 和 4还有空闲的核心。
其他节点的核心虽然有许多等待线程,但由于平均负载与节点 0 和 4 相近,因此无法从这些节点偷取任务。
结果,节点 0 和 4 的空闲核心无法从其他节点的过载核心中偷取任务,导致负载不均衡。
比如:一个node有8个核心,其中一个核心运行了高负载的线程,但是其中7个核心是空闲的,这一个高负载核心将整个节点的平均负载被拉高,之久导致该Node的空闲核心无法从其他节点的过载核心中偷取任务,导致负载不均衡。
一个合理的问题是:在这种情况下是否应该偷取任务?理论上,我们希望高负载线程获得更多的 CPU 时间。然而,Linux CFS 调度器是工作保守型的,这意味着如果系统中有空闲核心,线程可以获得比其公平份额更多的 CPU 周期。换句话说,空闲核心应该始终分配给等待的线程。但在上述场景中,这种情况并未发生。
组负载不均衡问题是由于负载跟踪指标的复杂性和分层设计的限制导致的。尽管调度器试图通过平均负载来简化决策,但在某些情况下(如高负载线程集中在少数节点),这种设计会导致负载不均衡,使得空闲核心无法被充分利用。这一问题的存在表明,即使在高性能的现代系统中,调度器的实现仍然可能受到复杂性和极端情况的影响。
修复方案:
为了修复组负载不均衡问题,我们修改了算法中比较调度组负载的部分。具体来说,我们不再比较调度组的平均负载,而是比较它们的最小负载(算法 1 的第 11 和 15 行)。最小负载是指调度组中负载最低的核心的负载。如果一个调度组的最小负载低于另一个调度组的最小负载,这意味着第一个调度组中有一个核心的负载比第二个调度组中的所有核心都低,因此第一个调度组中的核心必须从第二个调度组中偷取任务。
这种算法确保了在第一个调度组中有一个核心负载较低的情况下,第二个调度组中的核心不会保持过载状态,从而实现了核心之间的负载均衡。需要注意的是,这种修复方案之所以有效,是因为在调度组内部也会进行负载均衡(由于在层次结构的较低层次上调用负载均衡)。与原始算法一样,我们使用组负载不均衡的特殊情况(算法 1 的第 13 行)来处理由于任务集(tasksets)导致的极端情况。
修复的优点:
无额外算法复杂度:计算调度组的最小负载和平均负载的成本相同,因此这种修改不会增加算法的复杂度。
避免“乒乓效应”:这种修复方案并未导致调度组之间的任务迁移次数增加(即没有出现“乒乓效应”)。
通过比较最小负载而不是平均负载,修复后的算法能够更准确地识别出负载不均衡的情况,并确保空闲核心能够从过载核心中偷取任务。这种改进显著提高了系统的负载均衡能力,尤其是在高负载线程集中在少数节点的情况下。
通过将负载比较从平均负载改为最小负载,我们有效地修复了组负载不均衡问题。这一修复方案在不增加算法复杂度的前提下,显著改善了调度器的负载均衡能力,避免了核心空闲而任务等待的情况。这一改进展示了在调度器设计中,简单而有效的调整可以解决复杂的性能问题。
3.2 调度组构建问题(Scheduling Group Construction Bug)
Linux 提供了一个名为 taskset 的命令,用于将应用程序绑定到可用核心的子集上运行。本节描述的问题发生在应用程序被绑定到相距两跳的节点上时。例如,在图 4 所示的 NUMA 机器拓扑中,节点 1 和节点 2 相距两跳。该问题会阻止负载均衡算法在这两个节点之间迁移线程。由于线程通常在其父线程所在的节点上创建,最终结果是无论应用程序有多少线程,它都只在一个节点上运行。
该问题是由于调度组的构建方式不适应现代 NUMA 机器(如我们实验中使用的机器)。具体来说,调度组是从特定核心(核心 0)的视角构建的,而实际上应该从每个节点上负责负载均衡的核心的视角构建。我们通过一个示例来解释。
在我们的机器中(如图 4 所示):
第一个调度组:包含节点 0 的核心,以及与节点 0 相距一跳的所有节点的核心(即节点 1、2、4 和 6)。
第二个调度组:包含第一个未包含在第一个组中的节点(节点 3)的核心,以及与节点 3 相距一跳的所有节点的核心(即节点 1、2、4、5 和 7)。
因此,前两个调度组为:
{0, 1, 2, 4, 6}, {1, 2, 3, 4, 5, 7}
注意,节点 1 和 2 同时包含在这两个调度组中,而这两个节点实际上相距两跳。如果调度组是从节点 1 的视角构建的,节点 1 和 2 不会同时出现在所有组中。
假设一个应用程序被绑定到节点 1 和 2 上,并且其所有线程都在节点 1 上创建(Linux 通常将线程创建在其父线程所在的节点上;当应用程序在初始化阶段创建多个线程时,它们通常会创建在同一个节点上)。最终,我们希望负载能够在节点 1 和 2 之间均衡。然而,当节点 2 上的核心尝试偷取任务时,它会比较上述两个调度组的负载。由于每个调度组都包含节点 1 和 2,因此平均负载相同,节点 2 不会偷取任何任务!
该问题源于试图提高 Linux 在大型 NUMA 系统上的性能。在引入该问题之前,Linux 会在 NUMA 节点内部均衡负载,然后在所有 NUMA 节点之间均衡负载。为了增加线程保持在原始 NUMA 节点附近的概率,引入了新的层次结构(相距一跳的节点、相距两跳的节点等)。然而,这种设计在特定情况下会导致负载均衡失效。
调度组构建问题是由于调度组的构建方式不适应现代 NUMA 机器的拓扑结构,导致负载均衡算法无法在特定节点之间迁移任务。这一问题的存在表明,在优化调度器性能时,需要仔细考虑硬件拓扑和负载均衡策略的匹配性。
修复方案:
我们修改了调度组的构建方式,使得每个核心都使用从其自身视角构建的调度组。修复后,当节点 1 和 2 的核心尝试在机器级别偷取任务时,节点 1 和 2 不再同时包含在所有调度组中。因此,这些核心能够检测到负载不均衡并偷取任务。
修复的具体实现
(1)从核心的视角构建调度组:
每个核心在构建调度组时,基于其自身的 NUMA 拓扑关系,而不是统一从核心 0 的视角构建。
例如,节点 1 的核心会构建与其相距一跳的节点组成的调度组,而节点 2 的核心会构建与其相距一跳的节点组成的调度组。
(2)避免节点重复包含:
通过从每个核心的视角构建调度组,确保节点 1 和 2 不会同时出现在所有调度组中,从而避免负载均衡失效。
修复后的效果:
负载均衡恢复:修复后,节点 1 和 2 的核心能够检测到负载不均衡,并从其他节点偷取任务。
性能提升:应用程序的线程能够均匀分布在绑定的节点上,充分利用系统资源,避免性能瓶颈。
通过修改调度组的构建方式,使其基于每个核心的视角,我们成功修复了调度组构建问题。这一修复方案恢复了负载均衡算法的有效性,确保线程能够在绑定的节点之间均匀分布,从而提高了系统性能和资源利用率。这一改进再次表明,调度器的设计需要紧密结合硬件拓扑特性,以避免潜在的性能问题。
3.3 唤醒时过载问题(Overload-on-Wakeup Bug)
该问题的核心是,一个睡眠的线程可能会在一个过载的核心上被唤醒,而系统中其他核心却处于空闲状态。这个问题是由唤醒代码中的优化(select_task_rq_fair 函数)引入的。当一个线程在节点 X 上进入睡眠状态,而稍后唤醒它的线程也在同一节点上运行时,调度器只会考虑节点 X 的核心来调度被唤醒的线程。如果节点 X 的所有核心都忙碌,线程将在一个已经忙碌的核心上唤醒,从而错过了使用其他节点上空闲核心的机会。这会导致机器的利用率显著下降,尤其是在线程频繁等待的工作负载中。
问题的原因:
(1)缓存重用优化:
调度器尝试将被唤醒的线程放置在物理上接近唤醒线程的核心上(例如共享最后一级缓存的核心),以优化生产者-消费者工作负载(被唤醒的线程会消费唤醒线程生成的数据)。
这种优化在某些情况下是合理的,但对于某些工作负载,为了更好的缓存重用而让线程在运行队列中等待并不划算。
(2)短时线程的影响:
当内核启动持续时间少于 1 毫秒的后台任务(如日志记录或 IRQ 处理)时,这些短时线程可能会被调度到运行数据库线程的核心上。
负载均衡器观察到运行短时线程的节点(节点 A)负载较重,并将其中一个线程迁移到另一个节点(节点 B)。如果迁移的是数据库线程而不是短时线程,则会导致唤醒时过载问题。
问题的影响:
(1)核心利用率不均衡:空闲核心未被充分利用,而过载核心却承载了过多线程。
(2)性能下降:线程在过载核心上唤醒,导致响应时间增加和吞吐量下降。
当系统最终从负载不均衡中恢复:负载均衡算法最终将线程从过载核心迁移到空闲核心。然而,恢复过程可能需要几毫秒甚至几秒钟。这是因为:
(1)短期空闲核心与长期空闲核心:
短期空闲核心由于数据库线程的同步或 I/O 事件而间歇性空闲。
长期空闲核心则完全空闲。
(2)负载均衡的限制:
调度器在迁移线程时不会区分短期和长期空闲核心,只检查负载均衡器被调用时核心是否空闲。
如果多个空闲核心符合条件,只有其中一个被选为“指定核心”。如果幸运地选择了长期空闲核心,负载均衡会恢复;否则,不均衡会持续。
唤醒时过载问题是由于调度器在唤醒线程时过度依赖缓存重用优化,导致线程在过载核心上唤醒,而空闲核心未被充分利用。通过改进唤醒逻辑和负载均衡策略,可以显著提高系统的资源利用率和性能。这一问题的存在表明,调度器的优化需要在性能和资源利用率之间找到更好的平衡。
修复方案:
为了修复唤醒时过载问题,我们修改了线程唤醒时执行的代码。具体修复方案如下:
(1)优先唤醒到本地核心:
如果线程上次运行的核心(本地核心)当前空闲,则在该核心上唤醒线程。
这有助于利用缓存局部性,减少缓存失效的开销。
(2)选择长期空闲核心:
如果本地核心忙碌,但系统中存在空闲核心,则选择空闲时间最长的核心来唤醒线程。
这可以避免线程在过载核心上唤醒,同时充分利用系统中的空闲资源。
(3)回退到原始算法:
如果系统中没有空闲核心,则回退到原始算法,选择最合适的核心唤醒线程。
修复的优化与限制:
(1)功耗管理:
长期空闲的核心通常会进入低功耗模式。唤醒线程会迫使核心退出低功耗模式并恢复全功率运行。
因此,我们仅在系统的电源管理策略不允许核心进入低功耗状态时,才强制执行新的唤醒策略。
(2)适用场景:
该修复主要适用于线程频繁睡眠和唤醒且系统间歇性过载(线程数多于核心数)的工作负载。
在这些情况下,唤醒线程到长期空闲核心是有意义的。
在其他情况下,由于线程唤醒较少,修复不会显著改变调度器的行为。
(3)性能开销:
查找长期空闲核心不会增加唤醒函数的开销,因为内核已经维护了系统中所有空闲核心的列表,选择第一个空闲核心(即空闲时间最长的核心)的时间复杂度为常数。
总结,通过修改线程唤醒逻辑,优先选择本地核心或长期空闲核心,我们成功修复了唤醒时过载问题。这一修复方案在提高系统资源利用率的同时,尽量减少对功耗和性能的影响。该修复特别适用于高并发和线程频繁睡眠唤醒的场景,展示了调度器优化在提升系统整体性能中的重要作用。
3.4 调度域缺失问题(Missing Scheduling Domains Bug)
当通过 /proc 接口禁用并重新启用一个核心时,NUMA 节点之间的负载均衡将不再执行。该问题是由于一个表示机器中调度域数量的全局变量更新错误导致的。当一个核心被禁用时,该变量被设置为 NUMA 节点内部的调度域数量。因此,主调度循环(算法 1 的第 1 行)会提前退出。
结果,线程只能在其核心被禁用之前运行的节点上运行(即使该节点与禁用并重新启用的核心所在的节点不同)。对于在核心禁用后创建的进程,所有线程将与其父进程运行在同一个节点上。由于所有进程通常都是从同一个“根”进程(例如 sshd 守护进程及其派生的 ssh 进程)创建的,该问题通常会导致所有新创建的线程仅在机器的一个节点上运行,无论线程数量多少。
比如一个具有 16 个线程的应用程序在机器上启动。线程创建后,节点 1 上的所有核心都运行两个线程。核心 0 在尝试偷取任务时考虑的核心。由于循环提前退出,核心 0 只考虑其本地节点的核心,而不考虑节点 1 的核心。
问题的原因:
(1)全局变量更新错误:当核心被禁用时,表示调度域数量的全局变量被错误地设置为 NUMA 节点内部的调度域数量,而不是整个机器的调度域数量。
(2)负载均衡失效:由于调度域数量错误,主调度循环提前退出,导致 NUMA 节点之间的负载均衡无法执行。
问题的影响:
(1)线程分布不均:线程被限制在单个节点上运行,无法充分利用其他节点的资源。
(2)性能下降:由于负载不均衡,系统的整体性能显著下降。
总结,调度域缺失问题是由于全局变量更新错误导致 NUMA 节点之间的负载均衡失效。通过修复该问题,可以恢复负载均衡功能,确保线程能够均匀分布在所有可用节点上,从而提高系统性能和资源利用率。这一问题的存在表明,调度器的实现需要仔细处理全局状态和硬件拓扑的变化,以避免潜在的性能问题。
修复方案
我们追踪到该问题的根本原因是机器调度域重新生成的代码存在问题。每次核心被禁用时,Linux 都会重新生成调度域。重新生成调度域是一个两步过程:
(1)内核重新生成 NUMA 节点内部的调度域。
(2)内核重新生成跨 NUMA 节点的调度域。
然而,在代码重构过程中,Linux 开发者意外删除了生成跨 NUMA 节点调度域的函数调用。我们将其重新添加回去,从而修复了该问题。
修复的具体实现:
(1)恢复跨 NUMA 节点调度域的生成:
在核心被禁用时,确保重新生成 NUMA 节点内部和跨 NUMA 节点的调度域。
这恢复了 NUMA 节点之间的负载均衡功能。
(2)验证修复效果:
修复后,线程能够均匀分布在所有可用节点上,而不再被限制在单个节点上运行。
修复的优点:
(1)恢复负载均衡:修复后,调度器能够正确执行跨 NUMA 节点的负载均衡,充分利用系统资源。
(2)性能提升:线程分布均匀,减少了负载不均衡导致的性能下降。
总结,通过恢复跨 NUMA 节点调度域的生成,我们成功修复了调度域缺失问题。这一修复方案恢复了负载均衡功能,确保线程能够均匀分布在所有可用节点上,从而提高系统性能和资源利用率。这一问题的修复再次强调了代码重构过程中需要仔细检查关键功能的完整性,以避免引入潜在的性能问题。
3.5 总结
Linux 调度器经历了多次重大 redesign:
(1)原始调度器:算法复杂度高,在多线程工作负载下性能较差。
(2)O(1) 调度器:2001 年引入,复杂度为 O(1),在 SMP 系统上具有更好的扩展性。然而,随着 NUMA 和 SMT 等新架构的出现,O(1) 调度器无法满足需求,最终在 2007 年被 CFS 取代。
(3)CFS 调度器:牺牲了 O(1) 复杂度,采用 O(log n) 复杂度,但提供了更好的功能和性能。
尽管 CFS 调度器在初期表现良好,但随着硬件和工作负载的复杂性增加,它也出现了各种问题:
(1)组负载不均衡问题:由自动分组(autogroups)和分层负载均衡引入。
(2)调度组构建问题:由现代复杂 NUMA 系统的不对称性触发。
(3)调度域缺失问题:由现代系统的 NUMA 特性导致。
(4)唤醒时过载问题:由现代多节点机器的缓存一致性开销和缓存局部性优化引起。
即使一个新的调度器设计在初期是简洁且无错误的,它也无法长期保证不出现问题。Linux 是一个由数十名开发者共同开发的大型开源系统。在这种环境下,不可避免地会有新功能和“临时解决方案”被添加到代码库中,以应对不断变化的硬件和应用需求。
例如,Linux 4.3 内核引入了新的负载指标实现,声称“显著降低了代码复杂度”。然而,我们通过工具确认,组负载不均衡问题仍然存在。
这些错误主要是由于开发者希望在调度器中加入越来越多的优化,以应对现代硬件的复杂性。结果,调度器从一个简单的内核组件演变成了一个复杂的“怪物”,其触角延伸到了系统的许多其他部分,如电源和内存管理。
四、调度器架构的重新思考
我们在研究中发现,这些错误主要是由于开发者希望在调度器中加入越来越多的优化,以应对现代硬件的复杂性。结果,调度器从一个简单的内核组件演变成了一个复杂的“怪物”,其触角延伸到了系统的许多其他部分,如电源和内存管理。本文研究的优化是 Linux 主线的一部分,但研究社区还提出了更多的调度优化方案。
自 2000 年左右以来,许多论文描述了新的调度算法,以应对现代多核系统中的资源争用、一致性瓶颈等问题。这些算法包括:
(1)减少共享缓存、内存控制器和多线程 CPU 流水线争用的调度算法 。
(2)减少共享数据线程之间通信距离的算法。
(3)确定多线程工作负载最佳核心分配数量的算法。
(4)针对非对称多核 CPU 的调度算法。
(5)集成调度与电源和温度管理的算法。
(6)管理 NUMA 系统内存的算法。
(7)在非对称互连系统上最小化通信延迟的调度算法。
尽管这些算法在某些实际应用中展示了性能或功耗方面的优势,但很少有被主流操作系统采用,主要是因为如何安全地集成这些想法尚不明确。
如果每个好的调度想法都被简单地附加到一个单一的整体调度器中,我们可能会面临更多的复杂性和错误,正如本文中的案例研究所展示的那样。我们需要重新思考调度器的架构,因为它不能再是一个小型、紧凑且基本独立的内核部分。
我们设想调度器可以是一个模块化集合:
调度程序是一个模块集合:核心模块和优化模块。核心模块体现了调度程序的基本功能:将可运行的线程分配给空闲核心,并以某种公平的方式在它们之间共享周期。优化模块提出了基本算法的特定增强。
(1)核心模块:
实现调度器的基本功能:将可运行线程分配给空闲核心,并以某种公平的方式共享 CPU 周期。
(2)优化模块:
提供对基本算法的特定增强。例如:
负载均衡模块可能建议一种避免过多开销的负载均衡策略。
缓存亲和性模块可能建议将线程唤醒到其最近运行的核心上。
资源争用模块可能建议一种减少争用导致的性能下降的线程放置策略。
总结
调度问题,即将 CPU 周期分配给线程的任务,曾被认为是一个已解决的问题。然而,我们的研究表明事实并非如此。为了应对现代硬件的复杂性,一个简单的调度策略演变成了一个非常复杂且容易出错的实现。我们发现,Linux 调度器违反了一个基本的工作保守不变式:将等待的线程调度到空闲核心上。结果,可运行线程可能会在运行队列中停留数秒,而系统中却有空闲核心;应用程序的性能可能会下降数倍。
参考资料
https://people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf
https://blog.csdn.net/dog250/article/details/95729830
https://mp.weixin.qq.com/s/Goluls7WXVXFYhCFC7Lj8w