多核场景下的 Linux 调度器现状和未来¶
标题:多核场景下的 Linux 调度器现状和未来
日期:2025/07/10
作者:陈渝
链接:https://developer.aliyun.com/live/255213
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:演讲本来就是中文(龙蜥MeetUp),只是扫了一遍文字转录,有些口误好像没修正。
备注二:B站有备份。其实幻灯片给的信息更多一点。
大家下午好,感谢大家一直在这里听我们一起交流。我是陈渝,来自英特尔。我看到很多熟悉的面孔,感觉非常亲切。今天很高兴能跟大家分享我们之前在多核系统上做 Linux 内核的一些体会。
今天早些时候跟一些同学交流,提到操作系统这块可能要跟 AI 结合,确实是个难点。所以今天跟大家分享的,可能是一些具体的 Linux 内核问题,特别是调度器相关的问题。希望能够集思广益,或者说是抛砖引玉,看看大家有没有什么想法,来结合 AI 解决这些具体的问题。
首先,我先介绍一下背景。我们这个组是英特尔的软件组。每次有新的 CPU 即将发布或刚刚发布后,都会到我们组这边。我们会做一些 CPU 功能的适配,以及性能的调优。我们需要保证的是,新的 CPU 跑起来之后,软件和内核层面至少不能造成性能回退。在这个调优的过程中,我们就会经常发现一些问题:当 CPU 的核数越来越多的情况下,我们发现 Linux 内核里面有很多的性能瓶颈。
其中,涉及到调度器这块,我们发现在一些非常典型的常规场景里,就有很多跟多核相关的问题。针对这些问题,我今天会进行具体的原因分析,包括解释 Linux 内核针对这些问题做了哪些优化,以及这些优化的优缺点。除此之外,我们自己也在社区里面做了一些工作,欢迎大家也一起到社区参与讨论。最后,我们会关注一下 Linux 内核,特别是调度器未来的发展方向,以及我们是否能有机会和 AI 结合起来做一些事情。
调度器的三个核心问题¶
在说具体的问题之前,我们需要了解,内核调度器实际上要关注三个核心问题:
哪个进程可以运行?
进程被选中之后,可以跑多久?
进程处于可运行状态后,应该在哪个 CPU 去运行?
前两个问题跟多核没有太大关系,而第三个问题,则是我们今天要讨论的重点。
作为背景,我稍微讲一下前两个问题。以常规的 CFS (Completely Fair Scheduler) 调度器为例,来说明哪个进程可以运行。在内核的设计里面,所有的进程是按照它的虚拟时间(virtual runtime)进行排序,放在一棵排序树(红黑树)里面。我们每次选择进程的时候,一定会选择它跑的时间最短的那个进程去跑。
选中这个进程之后,我们要做下一个决定:这个进程要跑多久?内核里有两个场景会决定进程能跑多久:
时钟中断:内核会通过定期的时钟中断,来判断当前进程是不是跑了太长的时间。如果是,就会让它放弃 CPU,让下一个进程来跑。
新进程就绪:当一个进程(比如 T4)正在运行时,突然有另一个进程(比如 T5)就绪了。这个时候内核就要做一个比较:是让 T4 继续跑,还是让新的 T5 跑。
通过这个描述我们可以看到,这实际上是单 CPU 上面的策略,跟多核关系不太大。
接下来就是我们今天讨论的重点:进程应该在哪个 CPU 去跑? 这里面也涉及到两个场景:一个是进程唤醒时的 CPU 选择,第二个是负载均衡时的 CPU 选择。
场景一:进程唤醒带来的挑战¶
进程唤醒会涉及到两个步骤:
尝试去搜索一个闲置的(idle)CPU,把这个进程放到闲置的 CPU 去跑。
在找到一个闲置的 CPU 之后,把这个进程放到该 CPU 的运行队列中(入队)。
这两个步骤都和多核系统密切相关。首先,线性搜索 CPU 的时间,在系统 CPU 核数特别多(比如一两百个)的情况下,可能会非常耗时。其次,就算找到了 CPU,将进程入队的操作实际上是对缓存(Cache)高度敏感的。下面我会具体讲解。
1. 搜索闲置 CPU 的耗时问题¶
刚才讲到,CPU 核数越多,搜索时间越长。我们可以想象一个场景:当系统比较忙的时候,闲置的 CPU 很可能排在线性搜索序列的最后。这意味着每次搜索,都可能要遍历很多 CPU 之后才能找到一个闲置的。这个时间是非常可观的。
更坏的场景是,如果系统一个闲置 CPU 都没有了,内核难道还要一直搜下去吗?搜到最后,一个都找不到,这不就是在做无用功吗?时间也消耗了。这种搜索策略会造成一些工作负载(workload)的唤醒延迟非常大。
针对这个搜索耗时的问题,Linux 内核实际上做了一些优化。
优化方案一:动态调整搜索深度 我们可以根据系统的繁忙程度来决定搜索的深度。如果系统非常忙,找到闲置 CPU 的几率就会降低很多,那么就没必要一开始就用很大的努力去搜索。不如“躺平”一点,搜几个就停止。反之,如果系统比较闲置,找到闲置 CPU 的几率会非常大,那就可以一直搜下去。
基于这个策略,大概在两年前,内核引入了一个改动:根据整个系统的 CPU 利用率去决定闲置 CPU 的搜索长度。我们可以把它理解成一个下降函数,横坐标是 CPU 利用率,纵坐标是搜索的次数。利用率越高,要搜索的 CPU 就会越少。这个功能引入后,我们在一个比较大的、200 多个 CPU 的服务器上,看到唤醒延迟降低了 100% 以上。这个功能当时也在 Linux 内核社区作为一个新闻进行了报道。
优化方案二:使用位图(Bitmap)管理闲置 CPU 社区里也做了另一个方案来降低搜索延迟:用一个位图来保存系统中所有闲置的 CPU。这样每次寻找闲置 CPU 的时候,就能非常快地找到它。
这看上去很理想,对不对?但是这有个问题:你怎么去维护这个位图? 如果有多个 CPU 同时想更新自己的状态(比如“我变闲置了”),就会有一个并发问题。多个 CPU 同时对一块内存地址进行读写,缓存的竞争(cache contention)会非常激烈。这个方案在社区里可能迭代了很多轮,最后也不了了之,没有继续推进。但我个人觉得,理论上这个方案应该是最优的,可以通过各种其他方法去规避它的开销问题。
2. 进程迁移的缓存敏感性问题¶
说完了搜索耗时,我们再来看第二个问题。刚才我们提到,一个进程被唤醒到另一个 CPU 是缓存敏感的。为什么呢?
我们首先定义一个事件:一个进程如果之前在 CPU1 上运行,而本次被唤醒时,是在一个不同的 CPU(比如 CPU15)上运行,我们就称之为一次进程迁移(process migration)。
这个进程迁移跟缓存敏感有什么关系呢?这里直接给出两个结论:
进行一次迁移,Linux 内核代码本身的设计会造成缓存竞争。
跨 CPU 之后,进程的数据需要重新在新的 Cache 里面“加热”。
下面我详细讲一下这两个我们平时发现的问题。
首先,这张图是我们在一个多核系统(可能 200 多个 CPU)上跑某个 benchmark 时发现的性能瓶颈。最前面两行是我们通过 perf
工具采样的两个热点,它们都是内核函数。第一个函数可以看作一个“读者”,第二个函数是一个“写者”,它们俩可能对同一个内存地址进行了比较频繁的读写。
这个场景是怎么回事呢?Linux 内核调度器的设计方案是这样的:如果一个进程迁移到另外一个CPU,同时这个进程如果属于一个 Task Group(或者说 Cgroup),它会对这个 Cgroup 的一个全局变量进行读写。具体来说,一个进程迁移之后,首先要把这个进程的负载从它老的 CPU 的运行队列负载中减去,然后再加到新的 CPU 的运行队列负载上,这样才能反映每个 CPU 真实的负载情况。这个过程伴随着对一个 Task Group 共享变量的更改操作。
这就带来了多个进程可能对同一个物理地址的数据进行读写操作。这里就会造成一个我们叫做“伪共享”(False Sharing)或者缓存争用(Cache Contention)的问题。
比如,一个“写者”对 Task Group 的数据变量进行写操作。如果其他 CPU 上有这个数据的共享缓存行(Cache Line),它首先会做一个缓存失效的操作。例如,CPU15 将自己的缓存行置为“修改”(Modify)状态,同时它会把跟它共享数据的那个 CPU(比如 CPU1 的缓存行置为“无效”(Invalid)。写者做完还没完,如果此时“读者”在 CPU1 上发生了读数据操作,它会触发一次类似“Cache Hit Modify”的操作,因为它的数据已经是无效的,别人修改过了,它需要做一次缓存一致性同步(Cache Coherence)。
这个过程本身是非常耗时的。除此之外,现在内核里面刚才讲的这两个写者和读者的操作,还用的是原子操作,比如 Compare Exchange
。这个开销本身就算没有缓存竞争的影响,也是非常大的。基于这个发现,内核里面做了一个规避方案,相当于限制了“写者”的更新频率。做了这个改动之后,性能会提升很多。但是我们仔细想一下,它实际上没有解决根本问题,只是减轻了这个问题。
刚才讲的是内核自身代码设计导致的多核间缓存竞争,从而导致性能低下。还有一种情况,就是很常规的负载本身,也会因为进程迁移导致数据需要重新“加热”而性能低下。
这里是一个小的网络 benchmark Netperf
的对比测试结果。我们使用了一种叫做“Top-Down”的性能分析方法,它会根据系统的性能监控单元(PMU)指标,对进程的瓶颈进行分类,找到瓶颈到底属于哪一块。
基准测试(内核什么都不动):我们可以看到标红的地方,瓶颈指标中
L3
(L3 Cache)的部分比例比较大。对比测试(在内核里做了一些手脚,让它尽量减少迁移):我们可以看到下面的绿色部分,
L3
的比例降了很多。
从结果来推导,我们做了一些抑制进程迁移的操作之后,L3 缓存未命中(miss)的几率降低了很多。同时,我们通过指令完成数(retiring
)这个指标来看,它的比例是增加很多的。retiring
可以理解成你这个 workload 最终的性能数据,比例越高越好。
3. 社区对抑制迁移方案的探索与挑战¶
听下来感觉好像进程迁移是件坏事,我们是不是要尽量避免它?针对这种想法,我们尝试往 Linux 社区推广了两种方案。
方案一:禁止短进程迁移 我们的想法是,如果一个进程单次运行时间很短,是不是把它放在哪个 CPU 运行都无所谓?首先,把这个短进程放在一个已经有进程在跑的 CPU 上,它对当前正在运行的进程影响比较小,因为它跑一会儿就结束了。其次,短进程本身对数据可能不是太敏感,只要能拿到 CPU 运行,就能满足它的需求。
我们把这个方案往社区去推,但最后没有成功。后面我会讲我们遇到的调度器社区的普遍问题。
方案二:
Stay-Cache
(亲和性缓存) 我们退而求其次,提出了一个叫做Stay-Cache
的方案。思路是,我们尽量让进程在它以前睡眠前曾经运行过的那个 CPU 去跑,这样它会得到最大的缓存热数据的好处。具体做法是,当一个进程睡眠时,我们帮它“预留”一段时间它之前跑过的那个 CPU。比如预留一毫秒,下次这个进程唤醒搜索 CPU 时,就优先搜索这个被预留的 CPU,这样就很容易能搜到它,并且缓存数据也不会被别的进程弄“脏”。我们也尝试推这个方案,但是社区里面也有一些反对意见。他们认为,如果你把所有 CPU 都预留给了某些睡眠的进程,那么当第三个新进程上来时,是不是增加了它的搜索时间?因为它要跳过那些被预留的 CPU。这种唤醒延迟的增加是别人不能接受的。所以这个方案最后也没有推成功。
场景二:负载均衡的困境¶
除了进程唤醒,负载均衡(load balancing) 在多核场景下也有很多问题。Linux 内核里面主要做两种负载均衡:
空闲负载均衡(Idle Load Balance):当一个 CPU 变为空闲之后,原则上它会尝试从任意一个其他 CPU 拉一些工作过来做。
周期性负载均衡(Periodic Load Balance):它会周期性地去评估两个 CPU 之间是否存在负载不均衡。如果有,就尽量把这两个 CPU 上的负载拉平。这个调用的频率比较低,跟 CPU 的个数有关,因为开销比较大。
那么,这两种负载均衡在多核下又有什么问题呢?
问题一:空闲 CPU 浪费 刚才讲的空闲负载均衡,虽然一个 CPU 变闲置后会尝试去拉进程,但它不是无条件地去拉。它有很多限制条件和逻辑。这就可能造成一个情况:虽然系统里有 CPU 是闲置的,但一个进程都拉不过来,导致这个 CPU 还是处于闲置状态,等于浪费了一个 CPU 资源。
问题二:破坏缓存亲和性 对于周期性负载均衡,如果两个 CPU 它们本身不在同一个共享 L3 Cache 的域里(比如跨 NUMA 节点),我如果把其中一个进程从一个 CPU 拉到另一个域上面去,这样是不是造成它访问的数据需要全部重新预热?而且开销非常大。这跟刚才唤醒的问题有点相似。
1. 应对负载均衡问题的方案¶
Meta 的方案:全局共享运行队列 针对空闲 CPU 浪费的问题,内核社区里有位来自 Meta 的开发者发了一个方案,叫做全局共享运行队列(Global Run Queue)。它的意思是,在系统中维护一个全局的队列。每个进程就绪的时候,会优先被放入这个全局队列。然后,如果一个 CPU 变闲置了,它去拉进程的时候,不再去费力搜索,而是直接从这个全局队列里强行拉一个过来,不管系统其他状况是怎么样。
这种非常激进的拉进程策略,可以让每个 CPU 尽量得到利用。这个方案在社区里推了很久,也经历了很多争论。因为内核社区觉得这是一个比较激进的改法,可能会带来很多性能回退的问题,最后这个方案没有被合并。但是这个事没完,后面有下文。虽然 Meta 往社区推这个方案没有成功,但是它以另外一种方式“复活”了。
我们的方案:拓扑感知的负载均衡 针对负载均衡可能丢失缓存亲和性的问题,我们可以看到这样一种场景:
[图略]
如图中第一行所示,假设系统有两个 NUMA 节点(或者说 L3 Cache 域)。如果绿色的 P1 和 P2 这两个进程(或线程)属于同一个应用,它们之间可能有很频繁的数据交互。把它们放到不同的 NUMA 域上面去,开销会很大。但是当前的 Linux 内核调度器是不知道这个事情的,它只关心负载数值,不关心线程之间的关系,就随便按照负载去均衡了。
所以我们觉得,可以做一个优化。比如,将上图中的 P2 迁移到 NUMA 0 的那个闲置 CPU 上,再把 T1 放到 NUMA 1,跟 T2 在同一个域里。这样 T1 和 T2 数据交互的时候,性能就会好很多。
这个方案目前我们正在往社区里面推,在很多平台上面都看到了比较大的性能提升。尤其是在虚拟机的场景下,如果 VM 不绑定物理 CPU,这个方案可以自动帮你把虚拟机的 vCPU 聚合到同一个 NUMA 节点去,性能提升会更明显。
总结:当前调度器面临的挑战¶
总结一下我们刚才看到的问题:
内核中的调度策略实在太多了。理想的状况是,内核作为最底层的软件,应该只提供机制(interface),而把策略(policy)尽量让上层来实现。这样是社区比较容易接受的方案。
一个方案很难满足所有的场景。一个方案可能对某些特定场景有性能提升,但在其他场景就会引入性能回退。这对内核社区来说是不能接受的。你的原则是不能引起性能回退。像刚才我们讲的 Meta 提的那个全局共享队列,它为什么没进,就是因为它虽然对某些特定 benchmark(比如虚拟机)有非常大的性能提升,但对其他场景,它可能引入了全局队列的竞争,导致很多性能回退问题。
现有调度器缺乏足够的信息。现在的调度器基本是基于时间片的,它缺乏进程的其他信息,比如进程的数据集是多大?它分配了多少内存?访问了其中多少?根据这些信息,我们是不是能做更好的调度?
未来展望:可扩展调度器与 AI 的结合¶
这个时候就回到这个问题了:调度器的未来到底是怎么样?我们看到,我们尝试往内核社区里提各种调度器优化,阻力都比较大。
这时,就有人提出了 可扩展调度器(Extensible Scheduler) 这个东西。这个东西和刚才我们讲的 Meta 提的全局队列调度器是同一个人提的。那个人从内核调度器里面去尝试修改代码没有成功,他从另外一个角度去绕过了这个问题。
他的思路是,既然内核里面不希望有策略,那我干脆把策略做到 BPF (Berkeley Packet Filter) 里面去,我把所有调度策略全部放到用户态,只给内核提供一些非常基础的接口。他通过这个角度,去实现了这个可扩展调度器,同时把他之前的全局队列调度策略也做了进去。
这样做的时候,阻力就小了很多。最终这个方案进入内核社区,是 Linus Torvalds 自己最终拍板说“我要这个”。内核调度器的维护者本身是反对这个东西的,但是 Linus 最终认为,这个东西扩展性更好,能吸引更多人去做内核的开发,所以最终是进去了。
右边这个图,可能就是可扩展调度器的一个大体框架。我们可以看到,里面有一个叫做“全局队列”的东西。就是说,你在做用户态调度器的时候,可以借助这些已经嵌入的、自带的全局调度器、本 CPU 调度器,去做各种各样的策略分析。包括我刚才提到的那几个进程唤醒的优化,都可以用这个方案去实现。
最后,关于未来的一些展望。刚才我们讲,因为内核调度器的策略太多,所以你很难往里面去推一些新的策略。所以我们觉得未来的方向是:
跟硬件拓扑(topology)相关的一些优化,我们可能还是借助于内核自带的调度器去做。
如果涉及到上层业务、一些定制化的调度东西,我们觉得还是可以放到这个可扩展调度器里面,在用户态去做。
现在可扩展调度器仍然处于初步阶段。可能会有些场景,比如多个定制调度器同时存在于系统中。一个 Cgroup 用一种调度策略,另一个 Cgroup 用另一种调度策略,这样调度的粒度控制得会不会更好一点?
最后一个可能就是跟今天主题有一些关系的一些想法。既然你把调度策略放到了用户态,这就说明你可以根据用户的负载或者是你的性能指标,来选择和调整你的调度策略。比如说,你根据系统的 CPU 利用率、进程的唤醒延迟时间、上下文切换频率,或者甚至内存带宽、吞吐量等指标,针对你特定的进程,如果表现好,我可能给一个正反馈;如果不好,给一个负反馈。
我觉得这是可以在这个扩展调度器里面去做的事情。包括像刚才浪潮的曾鹏老师也讲了,你这些内核的行为是可以通过 BPF 的 map 提取到用户态去做的。所以对于调度器来说,你这些进程的调度信息,也是可以暴露给用户态的。然后你在用户态根据这些信息做一些微调(Fine-Tune),我觉得这是可行的。
OK,今天大概就是这些情况,欢迎大家下来也一起交流。谢谢大家。