☰
Linux CPU负载均衡机制解析:从runqueue到PELT的调度器原理与性能调优实践
2026/10/16 1:23:17 网站建设 项目流程

搞过Linux性能排查的人,迟早会碰到一个场景:一台24核的服务器,业务高峰期看top,CPU0已经100%,CPU12在80%,剩下二十几个核都在个位数徘徊。顺着进程列表往下翻,明明有几十个线程在跑,有些闲着的核上却一个可运行任务都没有。这个现象,业内通常叫“负载不均衡”。它看上去是调度问题,但背后真正要说清楚的是CPU负载均衡这一整套概念——也就是调度器如何理解负载、如何决定把一个任务放到哪个CPU上、如何在不同CPU之间搬运任务。

这篇文章想把这块讲透。面向两类人:一是被线上性能问题折磨的运维和后台开发,二是对内核调度器感兴趣但不知从哪入手的爱好者。调度器源码很枯燥,但概念并不复杂,我从最贴近可观察现象的角度切入,把runqueue、调度域、PELT负载跟踪、唤醒迁移、周期均衡、空闲拉取这些术语一次讲清楚,最后给出排查和调参思路。

1. 为什么要把CPU负载均衡当成一个独立课题

1.1 一个核忙死、其他核闲死的现场

先还原一下典型现场。某次线上告警,业务接口延迟从2ms涨到200ms,我看了一眼mpstat的输出:CPU1跑到99%,CPU5在55%,其余二十多个核挂在2%~8%。load average并不低,但CPU的整体利用率只有三成左右。也就是说,机器看起来忙,实际上大部分算力在睡觉。

到底发生了什么?最直接的原因是:大量短小的任务被反复调度到同一个或少数几个核上。每个任务只跑几十到几百微秒,还没来得及触发调度器周期性的负载均衡动作,就结束了;而唤醒它们的工作线程又恰好固定在几个核上,唤醒路径上的亲和性逻辑让新任务继续跟在唤醒者身边。结果就是少数几个核承担了绝大多数调度活动,其他核完全没机会体现“我很闲”。

这里面牵涉到局部性偏好、唤醒路径决策、任务生命周期长短,都是负载均衡概念里必须理解的东西。它们不是一句“调度器应该自动均衡”就能糊弄过去的,因为调度器的不少设计其实是在“均衡”和“局部性”之间做取舍。

1.2 调度器要平衡的是“负载”,不是“线程数”

如果把负载均衡理解成“每个CPU上的线程数量差不多”,方向就偏了。5个CPU密集型的线程和100个正在等IO的阻塞线程,对运行队列的压力完全不同。运行队列上的可运行实体数量是瞬时快照,一个线程可能下一秒就睡眠,也可能下一秒就结束,单纯按数量去迁移任务毫无意义。

调度器真正关心的是“负载”这个抽象指标,它是对每个调度实体运行历史和未来需求的综合估计。负载高,意味着这个实体占用了不少CPU时间,且大概率还会继续占用;负载低,可能只是短暂出现在运行队列上,或者大部分时间在睡眠。PELT这套机制的出现,就是为了给这种估计提供稳定、可比较的数值。

所以,负载均衡本质上是对“估计值”的平衡,而不是“人数”的平衡。这也解释了为什么有时候某个CPU上明明只有两个线程,却依然被认为负载很高——因为这两个线程几乎吃满了整个CPU。

1.3 理解均衡之前,先把三个词记住

正式进入机制之前,有3个基础词必须建立印象,后面会反复用到。

概念一句话理解
runqueue每个CPU独立持有的运行队列集合,所有想在这颗CPU上运行的线程都挂在这里
调度实体调度器看到的任务单元,一个线程、一个进程、或者一个cgroup分组都可以是调度实体
调度类按优先级划分的调度策略,内核按停止、限期、实时、公平、空闲的顺序选择运行谁

尤其要记住“每CPU一个runqueue”。这个设计意味着线程在不同核之间迁移,并不是改一个指针那么简单,要把整个调度实体从A核的队列摘下来,放进B核的队列,期间还要处理缓存、TLB、NUMA本地性等一系列代价。后面所有“为什么要设置迁移阈值”“为什么绑核会影响均衡”的分析,都从这一点出发。

2. 调度器实现负载均衡的底层逻辑

2.1 每CPU一条跑道:为什么非要把队列拆开

早期单核时代,系统只有一个全局运行队列,调度器每次取任务都要加锁。到了多核阶段,如果还保留全局队列,所有CPU取任务都会竞争同一把锁,扩展性直接崩掉。所以内核把运行队列拆成每CPU一份:每颗核只操作自己的队列,绝大多数情况下不需要跨CPU锁,调度路径的开销大幅下降。

代价就是负载均衡变成了“跨队列搬运”问题。一个CPU上的任务跑到另一个CPU上执行,要把调度实体从源runqueue迁到目标runqueue,同时更新跟它相关的负载计数器、调度组统计、NUMA信息。这些操作并不便宜,特别是当任务还“热”着的时候:它的数据还留在源CPU的L1/L2缓存里,一旦换核,缓存全部失效,第一波执行必然慢。

这就引出调度器的一条重要原则:不轻易迁移。它会先用负载估算判断值不值得搬,只有当不均衡带来的浪费超过迁移需要的成本时,才出手。这个原则在后面调参数时会反复出现。

2.2 调度域:负载均衡的分层地图

机器越大,均衡越复杂。一台双路服务器可能有两颗物理CPU、每颗CPU下多个物理核、每个物理核可能还有超线程兄弟,再加上NUMA节点,整个拓扑是一棵很深的树。如果把整台机器当成一个完全平等的“大池子”,跨节点迁移虽然能拉平CPU利用率,却会引入很高的内存访问延迟。

内核的做法是把CPU按硬件拓扑组织成sched domain树。大致分层是这样的:

  • SMT域:同一个物理核内部的超线程兄弟;
  • MC域:同一颗物理CPU封装下的所有核;
  • NUMA域:同一NUMA节点内的所有CPU;
  • 全机域:跨NUMA节点的整机范围。

每个域内部独立做均衡,域和域之间有层级关系。调度器在一个域里寻找“最忙的调度组”,再在组里找最忙的那个运行队列,然后决定要不要迁移。跨入更上层的域时,门槛明显提高,因为代价变了。换句话说,如果NUMA节点0比节点1忙一点点,调度器很可能选择“不搬了”,因为搬过去之后,任务在节点1上访问内存反而更慢。

理解调度域,才知道为什么很多服务器会出现“某个NUMA节点明显忙、另一个节点明显闲”却相对稳定的状态。这不是调度器坏了,而是它在权衡后认为迁移不划算。

2.3 PELT:负载值到底是怎么算出来的

老内核看负载,主要看运行队列长度和任务数量。这种统计方式在现代CPU面前太粗暴,因为线程的运行模式千差万别。于是内核引入了PELT,全称Per-Entity Load Tracking,按调度实体追踪负载。

PELT的核心思路是:每个调度实体维护一个load_avg,这个数值随运行时间增加而上升,随空闲时间按指数衰减。每过1024微秒,历史贡献按一个固定比例衰减一次,大约32毫秒前的影响就已经衰减到一半。这样,近期的运行状况权重最高,几天前的历史负载会逐渐淡出,不会把一时的繁忙当成永久的繁忙。

具体到数值上,一个线程每10毫秒里运行5毫秒,它的PELT负载会趋近于0.5,也就是50%的CPU容量。一个CPU上所有可运行实体的负载加起来,就是这颗CPU的负载信号。调度器比较CPU之间负载时,靠的就是这些累计出来的数字,而不是“队列里有几个线程”。

PELT理解透之后还有一层好处:看到/proc/schedstat里各种均衡计数,能猜到调度器把哪些值拿来比较了。负载不是瞬时值,而是带历史记忆的平滑估计。因此在短任务很多、任务来回睡眠唤醒的场景里,PELT给出的数字可能滞后于真实压力,这也是一种典型的均衡延迟。

2.4 均衡不会随时发生:三个触发时机

调度器不是每时每刻都在做均衡,它只在特定时机动手。一般可以分成三条路。

第一条是唤醒路径。一个任务被唤醒时,内核会执行wake_affine这类亲和性判断:看看唤醒者所在的CPU、任务之前运行过的CPU,哪个更合适。如果唤醒者所在CPU比任务上次运行的CPU更闲,就让任务跑到唤醒者身边,毕竟唤醒者手上很可能有它刚生产出来的数据,局部性更好。这个机制默认开启,也是“短任务扎堆单核”的最常见来源。

第二条是周期性路径。每个CPU的时钟中断会触发调度器巡检,隔一段时间检查一次自己所在的调度域,发现自己这边是比较闲的一方,就从最忙的队列里拉任务过来。这个过程由一个后台均衡器驱动,会参考迁移成本、不均衡度阈值,一次迁移多少个任务也有上限。

第三条是空闲路径。CPU快要睡大觉之前,会先做一次nohz idle balance,主动到域里找活干,看能不能从别的核上分点任务过来。这条路径让机器不至于“一个核忙到冒烟、另一个核躺着”,但也带来一个问题:CPU可能刚拉来任务就过载,出现利用率抖动。

时机谁发起主要目的常见副作用
唤醒路径任务唤醒者提升缓存局部性短任务集中在同一核
周期路径时钟节拍定期摊平负载迁移频繁导致抖动
空闲路径即将idle的CPU避免CPU空转拉活后瞬间过载

3. 观察负载均衡状态并动手调整

3.1 别靠眼睛猜:先量化分析

第一步永远是量化。最简单的是mpstat -P ALL 1,每秒刷一次每个核的利用率,能直接从列里看出要不要配平。top里按数字1也能看到各核状态,但数据粒度不如mpstat细。

如果想看调度器自己的均衡活动,/proc/schedstat是个宝藏文件。它的输出长这样:

cat /proc/schedstat cpu0 27429413 116343941 32342401 cpu0 domain0 0 12 3 1 45 67 89 12 ... cpu0 domain1 0 5 2 0 ...

每一行第1个字段是CPU编号,domain0后面那一串数字对应某个调度域。其中包含这个域发起的均衡次数、均衡成功次数、发现不均衡的次数、迁移成功次数等。不同内核版本字段数有差异,但前缀含义基本通用。如果发现lb_gained一直很低,而lb_failed很高,说明调度器在频繁尝试却没有成果,通常是任务被绑核或者任务生命周期太短造成的。

再进一步,perf sched timehist能看到每个线程何时在哪个CPU上运行、何时被抢占,对定位“某任务被迁来迁去”极为有效。抓一分钟记录,就知道抖动来自均衡器还是业务自身。

3.2 参数不是拿来随便改的

几个参数值得记住,但必须提醒:默认值在不同版本内核里不一样,调参前请以本机sysctl -a为准,并在测试环境验证。

参数作用调整方向
sched_migration_cost_ns迁移成本估计,太小容易频繁搬家,太大则宁可不均衡抖动时调大,单核繁忙时调小
sched_nr_migrate一次均衡最多迁移多少个任务高频小任务场景可适当调小
sched_min_granularity_ns公平调度下任务最小执行时间太小容易频繁抢占
sched_wakeup_granularity_ns新唤醒任务抢占旧任务的粒度影响唤醒路径的切换频率
sched_autogroup_enabled是否开启自动分组多用户登录场景建议保持开启

举例,某服务高峰期出现单核打满、其他核空闲,排除中断和绑核因素后,可以把sched_migration_cost_ns往下调一点,比如从默认值降到100微秒级别,再观察均衡器是否更愿意搬任务。反过来,如果所有核都在波动、进程被频繁迁移,就把它调大,给“搬家”提高门槛。

改参数时留好现场:用sysctl -w先打印当前值,再写入新值,方便回滚。不要把这些参数当永久配置写进内核启动项,除非连续几天压测都没问题。

3.3 手动干预:用CPU亲和性画圈

监控和调参都解决不了的时候,最彻底的手段是CPU亲和性绑定。taskset -pc 0-3 <pid>可以把进程限定在0到3号核之间,调度器只能在这个集合里给它找位置。再配合taskset -pc 2 <pid>固定到单核,常用于对延迟极其敏感的后台线程。

绑核是把双刃剑。好处是确定性很强,坏处是整个集合的负载可能严重倾斜。假如你绑了0-3号核,业务压力上来后0号核可能100%,其他三个核闲死,调度器又不能把任务迁出这个集合,结果就是人为制造不均衡。所以绑核前至少要留出一两个核给系统和其他线程,不要把所有任务都圈进小集合。

有些框架和中间件会在内部给自己线程设置亲和性,这种隐藏绑定最难发现。排查时可以执行taskset -pc <pid>直接读当前关联掩码,把所有线程都扫一遍,就能发现谁偷偷把CPU集合写死了。

3.4 容器场景:cpuset让均衡边界失效

容器普遍依赖cgroup里的cpuset控制器,它允许一个容器只使用宿主机上的部分CPU。调度器感知到cpuset之后,会把均衡范围限制在这个集合内,不会跨集合搬任务。这其实是一种“软绑核”。

于是你能看到一种现象:宿主机有30个核空闲,但容器只开了4个核,容器内某个进程把其中1个核打满,其余3个核也很忙,容器外的核却没有任何调度压力。这很合理,因为那个容器根本看不到外面的核。在判断宿主机负载时,不能只看容器内CPU usage就说“负载均衡出了问题”,要先确认cpuset边界。

还有一个容易踩的坑:如果容器绑定的cpuset是物理核2、3号加上它们的超线程兄弟0、1号,但你对CPU布局不熟悉,可能以为这是4个独立核,其实只有2个物理核。两个任务分别塞给0号和1号超线程,它们共享同一个执行流水线,单核物理资源被超线程占满。用lscpu -e或者lstopo看一次拓扑,比踩一次坑便宜得多。

4. 真实场景里的问题与处理经验

4.1 场景一:单核打满、其余核看戏

最常见的求助就是“为什么有一个核100%”。按优先级排查四件事。

先看绑核残留:逐个进程读CPU亲和性,确认没有谁把任务钉死到某一核。再看中断分布:cat /proc/interrupts,如果某块网卡的中断几乎全落在同一个CPU上,软中断会把这个核烧掉,这不是调度器问题,是中断问题,需要调整smp_affinity把中断分散到不同核。然后看线程数量与唤醒模式:用perf sched timehist观察,如果所有热点线程都是短生命周期、频繁睡眠唤醒,那么唤醒路径的亲和性逻辑很可能把它们聚到了同一个核上。最后才考虑调度参数没调好。

如果确认是唤醒路径导致的扎堆,有两种处理路线。一种是绑核,把不同线程分别固定到不同CPU集合,粗暴但稳定;另一种是把sched_wakeup_granularity_ns调大一些,降低唤醒者抢占和聚集的强度,然后观察。实践中前者更可控。

4.2 场景二:均衡器成了抖动制造机

有一次压测,我发现所有核的利用率都在波动,就像一个正弦波在整机扩散。查调度统计,均衡迁移次数高得可疑,perf sched里看到任务一会儿在CPU4、一会儿在CPU7,来回搬家。原因就是任务粒度很小、PELT反应又灵敏,调度器频繁判断“这个核略忙、那个核略闲”,于是来回倒腾,缓存全被打散。

处理方式是把迁移门槛提上去:调大sched_migration_cost_ns,同时把sched_nr_migrate调小,限制单次平衡搬运的任务个数。效果立竿见影,但代价是峰谷不均衡度可能变大。这里没有银弹,只能在“抖动”和“失衡”之间找平衡。

我的经验是:对固定资源需求的任务,绑核永远比祈求均衡器更靠谱;对弹性负载,才适合把均衡器开得激进一点。

4.3 场景三:NUMA不均衡背后的内存代价

双路服务器上,任务如果被迁到异节点,访问本地内存的距离就变了。这种延迟可以从几十纳秒变成一两百纳秒,有些内存密集型应用性能直接掉两成。

排查时用numastat看节点使用情况,再用numastat -p <pid>看一个进程的内存分布在哪个节点。如果进程内存集中在节点0,CPU却在节点1,那大概率是跨节点执行。调度器在负载均衡时一般会保持NUMA本地性,但总有一些场景会越界,比如节点0内存耗尽被迫分配节点1、或者唤醒路径把任务带去了异节点。

处理NUMA问题,不一定要走调度参数。常见思路是确认业务有没有能力做内存本地化分配,没有的话直接用numactl --cpunodebind=<node>把进程和它的内存都固定到同一节点,等于彻底关掉这项平衡,换取稳定延迟。吞吐集群可以接受轻微不均衡,延迟敏感服务不行。

4.4 场景四:到底该不该关掉负载均衡

总有人问:既然均衡器会抖动,能不能直接关掉?这里要分场景。常规服务器任务,千万不要关,任何手动方案都不如内核在各种异常负载下的自适应能力。真正要“关”的场景是:实时性要求极强、CPU资源是独占的,比如DPDK轮询线程、实时音视频处理。这种任务一般要配合isolcpus之类的启动参数把业务核从普通调度域中摘出去,让它彻底不受均衡影响。

但这种做法代价很高,一旦CPU被隔离,操作系统和普通任务都不能使用它,整机利用率下降。所以结论是:负载均衡不是问题,乱调才是问题;绝大多数线上异常,根源不在均衡器,而在中断分布、隐藏绑定和任务模型。

5. 常见问题速查与自查清单

5.1 快速定位表

现象可能原因第一步排查常规处理
单核100%其它闲置短任务扎堆/中断集中/残留绑定taskset -pc+/proc/interrupts分散中断、适度绑核、下调迁移成本
各核利用率剧烈波动均衡器频繁迁移perf sched timehist调大migration_cost_ns、减少nr_migrate
跨NUMA后性能明显下降任务被迁到异节点numastat -p <pid>绑node、检查内存分配策略
容器内某个核很高cpuset限制导致无法外迁查看Cgroup的cpuset配置扩大cpuset或优化容器内部布局
迁移后延迟反而上升缓存未命中成本大于收益perf stat -e cache-misses对比提高迁移门槛或直接绑核

5.2 排查口诀:先绑核后参数,先中断后调度,先观察后调整

这算不上什么高深方法论,但每次都能缩短时间。看现象时,先排除人为配置和外部因素,再怀疑调度器自身。很多“最难查的调度问题”,最后都查到了代码里某个线程偷偷设置了亲和性,或者网卡中断没均衡。把这两项检查放在最前面,能省掉大量无用功。

调参数时,一次只动一个变量,并且做完整记录。调度器参数之间互相影响,改完一个紧接着改另一个,出了新问题根本分不清是谁的锅。我习惯准备一个shell脚本,把sysctl -a中调度相关参数全部快照到文件里,改前改后各存一份,对比起来非常方便。

5.3 不建议做的“野路子”

有几种做法看着很炫,实际坑很大。一是把sched_migration_cost_ns直接调成0,等于无视迁移成本,缓存失效和调度开销会让系统整体变慢;二是对所有进程统一绑核,把核全占满,新任务反而无路可走;三是一看到某核使用率高就用renice把任务优先级降到极低,这跟负载均衡完全不是一回事,还可能饿死关键任务。

调度器的负载均衡设计,与其说是“平均主义”,不如说是一套成本收益计算:在局部性、延迟、吞吐之间做动态取舍。把这个取舍理解透了,比背参数、抄命令重要得多。

最后再分享一个小经验。我排过很多调度问题,最终发现大部分都收场于“绑核残留”和“中断分布”这两个词。不想重蹈覆辙的话,建议每次排查开始前先花一分钟把这两项检查跑掉,再决定要不要怀疑调度器本身。CPU负载均衡这个概念看起来很深,其实把运行队列、调度域、PELT、触发时机四个支点立住,后面遇到再复杂的问题,都能有条理地拆开看。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询