压测的时候经常遇到一个奇怪现象:明明整台机器好几个核都闲着,任务却死死堆在其中一个核上,好一会儿才被摊匀。很多人第一反应是“负载均衡没生效”,其实内核的负载均衡根本不是后台线程一直在扫描,它是散落在几个特定时间点上的被动动作。在 kernel 6.12 下,这些触发点尤其值得捋清楚——搞明白了,你才能解释为什么有时候均衡很快、有时候慢半拍,也才能在出问题的时候判断到底是哪条路径没能及时跟上。
这篇文章就用 Linux 6.12 的代码逻辑把“什么时候触发负载均衡”这件事拆开讲,顺便给出我实际排查负载不均衡问题时用到的观察手段。适合内核新人理解调度器框架,也适合做性能调优的人当排查手册翻。
1. 先分清一件事:负载均衡是事件驱动,不是后台轮询
很多人对内核调度器的第一印象是“有个守护线程每过一阵子就检查一次 CPU 负载,然后做迁移”。这个直觉对了一半,但机制上完全不是这么回事。从 6.12 的代码看,负载均衡的触发点分散在 tick 中断、任务唤醒、任务创建、CPU 进入空闲、以及 nohz 相关的通知里,全都是“事件”而不是“轮询”。
1.1 “负载”到底是什么:PELT 和瞬时任务数不是一回事
在谈触发时机之前,得先定义内核眼里“负载”是什么。CFS 调度器维护了两套指标:load_avg和util_avg。
load_avg是带权重的可运行实体负载,基于 PELT(Per-Entity Load Tracking)半衰期累加得到。util_avg是实体实际占用 CPU 的比例,反映的是“正在运行”的状态,而不是“可运行但等着”的状态。
负载均衡真正拿来比较的,主要是调度组(sched_group)的平均负载和利用率。这里有个很容易踩的误区:看top里的 %CPU 高,并不代表这个 CPU 在当前负载均衡周期里就一定是“busiest”。内核比较的是 PELT 序列,不是瞬时采样值。所以你会看到明明某个核上现在跑了 10 个线程,但均衡器算出来它负载不高,因为线程刚刚醒来、PELT 还没涨上去。
1.2 调度域和调度组:所有均衡都有边界
负载均衡不是全机器范围内乱拉任务。内核把 CPU 按照拓扑嵌套成调度域(sched_domain),每个域内部再划分成若干调度组(sched_group)。比如一台典型的 2 路 x86 服务器:
NUMA domain (两个 NUMA 节点) └─ DIE domain (单个处理器内所有核) └─ MC domain (共享 LLC 的一组核) └─ SMT domain (同时多线程核)load_balance()比较的是一个域内各组的平均负载,find_busiest_group()只在当前域的组之间挑最忙的组。这意味着跨 NUMA 节点的负载均衡和同节点内核心间的均衡是分开进行的,频率和触发条件都可能不同。
1.3 六类触发路径总览
结合 6.12 的kernel/sched/fair.c和kernel/sched/core.c,我把触发场景归纳成下面几类。后文会逐个展开:
| 触发路径 | 入口 | 典型触发条件 | 对应的域标志 |
|---|---|---|---|
| tick 周期均衡 | scheduler_tick -> trigger_load_balance | 周期性心跳,时间差超过域间隔 | SD_LOAD_BALANCE |
| 唤醒均衡 | select_task_rq_fair | 新任务唤醒,需要选 CPU | SD_BALANCE_WAKE |
| exec/fork 均衡 | select_task_rq_fair | exec()/fork()产生新任务 | SD_BALANCE_EXEC/SD_BALANCE_FORK |
| 新空闲均衡 | schedule -> newidle_balance | CPU 进入 idle 后尝试拉任务 | SD_BALANCE_NEWIDLE |
| nohz 远程均衡 | nohz_balancer_kick | tickless 系统中有 idle CPU 需要被照顾 | 根域层面 |
| 手动/事件触发 | sysfs、cgroup、热插拔等 | 管理员显式干预或拓扑变化 | 视域配置而定 |
这张表建议保存下来。排查的时候第一步就是判断:当前这个迁移到底是在哪个时间点发生的。
2. 每毫秒的 tick 里藏着周期均衡:scheduler_tick 到 SCHED_SOFTIRQ
周期均衡是很多人脑海里“负载均衡”的原型——每过一段时间,系统就把所有 CPU 扫一遍,看看需不需要搬任务。它的入口在每毫秒(或随意配置的 tick 周期)触发的调度器 tick 里。
2.1 调用链:trigger_load_balance 到 run_rebalance_domains
当一个 CPU 的周期性调度器 tick 发生时,scheduler_tick()在处理完当前任务的运行时间后,会调用trigger_load_balance()。这个函数只做一件事:
void trigger_load_balance(struct rq *rq) { if (rq->cpu == smp_processor_id() && time_after_eq(jiffies, rq->next_balance)) raise_softirq(SCHED_SOFTIRQ); }它判断当前 rq 的next_balance时间是否到了,到了就触发SCHED_SOFTIRQ软中断,而不是直接同步执行均衡逻辑。软中断处理器是run_rebalance_domains(),它会在软中断上下文中遍历当前 CPU 所属的每一层调度域:
scheduler_tick() -> trigger_load_balance(rq) -> raise_softirq(SCHED_SOFTIRQ) -> run_rebalance_domains() -> for_each_domain(cpu, sd) // 从叶子域往根域遍历 -> rebalance_domains(rq, sd)为什么要做成软中断?关键原因是调度器 tick 本身在硬中断上下文里,不适合做任务迁移这种复杂操作。软中断把均衡推迟到更安全的时间点,同时又不会像进程上下文那样被无限延迟。
2.2 rebalance_domains 的门槛和间隔控制
rebalance_domains()不是每层域每次都做均衡,它有两个核心门槛:
- 域必须设置了
SD_LOAD_BALANCE标志。 - 当前时间必须超过
sd->last_balance + sd->balance_interval。
只有这两个条件都满足,才会真的调用load_balance()。balance_interval不是固定死的,内核会根据 CPU idle 状态动态调整。如果一个 CPU 处于空闲状态,均衡间隔会用更激进的档位(通常减半);如果均衡连续失败,nr_balance_failed会累积,间隔会指数级放大。这个机制是为了避免两个核之间因为一点点负载差反复搬任务——也就是所谓的“抖动”。
这里有一个很反直觉的细节:周期均衡解决的其实是长时间尺度上的负载漂移。任务创建后慢慢堆积到某些 CPU,PELT 负载逐渐升高,直到超过阈值才触发迁移。它反应慢,但开销可控。所以不要指望周期均衡能解决瞬时热点。
2.3 为什么周期均衡解决不了瞬时抖动
假设一个 32 核机器,某个时刻 CPU0 突然被塞进来 20 个短任务,其它核空闲。当下一次周期均衡到来时,PELT 负载可能还没完全反映出来,而且balance_interval通常设置成几十毫秒到几百毫秒。等均衡器终于发现 CPU0 负载高,短任务可能已经跑完了。
在 6.12 里,这个场景更多依赖唤醒路径上的即时分配来解决,而不是周期均衡。周期均衡是“堵漏”的最后一道防线,第一道防线是任务落地前的 CPU 选择。
3. 任务落下前的那一刻:唤醒/exec/fork 路径上的即时均衡
真正的负载均衡主战场,其实在select_task_rq_fair()。当任务被唤醒、执行exec(),或者父进程fork()出子进程时,内核必须立刻决定这个任务放到哪个 CPU 上。这才是决定“细粒度负载分布”的关键节点。
3.1 select_task_rq_fair 的三条路线
select_task_rq_fair()会根据调用方的类型决定搜索深度和域标志:
WF_TTWU(唤醒):使用SD_BALANCE_WAKE,从当前任务所在 CPU 或唤醒者所在 CPU 出发向上搜索。WF_EXEC(exec):使用SD_BALANCE_EXEC,因为新程序往往意味着完全不同的缓存足迹,值得重新选址。WF_FORK(fork):使用SD_BALANCE_FORK,新子进程初始负载很小,优先挑一个不太忙的 CPU。
代码上,它会先在 LLC 域内尝试快速路径,如果找不到合适 CPU,再逐级往上层域扩展。这个过程不是简单的“找最空闲”,而是要平衡缓存亲和性和负载分布。
3.2 wake_affine:先看看缓存,再决定值不值得搬
唤醒路径上第一个大头是wake_affine()。它的核心诉求是:如果能在一个调度域内找到比prev_cpu和current_cpu都更“划算”的 CPU,就把任务迁过去;否则保持原 CPU 不动。
wake_affine()内部有两条检查路径:
wake_affine_idle():看 prev_cpu 和 current_cpu 是否处于 idle。如果 prev_cpu 已经空闲,任务可以直接迁回上一个运行位置,省得继续找。wake_affine_weight():用负载和利用率做加权比较。如果 current_cpu(唤醒者所在地)的负载明显低于 prev_cpu 所在组,说明搬过去可能更优。
为什么要优先选择同一个 LLC(最后一级缓存)域内的 CPU?因为跨 LLC 迁移意味着唤醒者访问被唤醒任务共享数据时要付出内存访问代价。wake_affine本质上是在“避免缓存 miss”和“追求负载均衡”之间做取舍——它偏好前者。
3.3 从 find_idlest_group 到 find_idlest_cpu
如果wake_affine()没有给出明确结论,select_task_rq_fair()会进入更深的搜索:find_idlest_group()在目标域内比较所有调度组的平均利用率(util),选出负载最低的组;然后find_idlest_cpu()在这个组内挑一个最合适的 CPU,还会考虑 CPU 容量(大小核场景下尤其关键)。
注意一个细节:find_idlest_*选出来的是“最闲”的 CPU,但唤醒均衡并不是每次都会执行。如果目标域没有设置SD_BALANCE_WAKE标志,整个逻辑会被跳过。这解释了为什么在某些 NUMA 默认配置下,刚唤醒的任务会优先留在本节点,即使另一个节点明显有很多空核——因为跨节点的 wake 均衡不一定每次都做,它受域标志和负载差阈值的双重限制。
唤醒路径是日常负载均衡中最频繁的触发点。工作量大的生产环境,一秒钟内可能有十万次唤醒,每次都会走进select_task_rq_fair()。所以这里面的每条分支都做了精心裁剪,尽可能让“大部分唤醒都停在原地”,只在负载差足够大时才动。
4. CPU 刚空闲的窗口期:newidle_balance 主动去抢任务
如果说唤醒均衡是任务侧的“送上门”,那么 newidle 均衡就是 CPU 侧的“主动出击”。当某个 CPU 发现自己的运行队列空了,它会尝试从别的 CPU 那里拉任务过来,让新空闲的 CPU 不至于闲着。
4.1 触发位置与结束条件
newidle_balance()的触发点在schedule()路径里:当调度器发现当前 rq 已经没有可运行任务时,就会尝试进入newidle_balance(),而不是立刻切到 idle 进程。
__schedule() -> pick_next_task() -> if (rq->nr_running == 0) newidle_balance()这个函数从最底层的调度域开始向上遍历,只处理设置了SD_BALANCE_NEWIDLE标志的域。每层都尝试调用load_balance(),把别的 rq 上的任务拉到自己这里来。
但 newidle 均衡有一个非常严苛的中止条件:只要need_resched()被置位,比如被 IPI 唤醒或者高优先级任务插入,函数必须立刻退出。因为它本身是在调度路径上执行的,不能阻碍下一次调度。
4.2 为什么 newidle_balance 只做一轮
很多看过代码的人会问:既然 CPU 已经空了,为什么不把所有域都翻一遍,尽量多拉些任务?
原因是成本。newidle 均衡是在调度器最关键的内核路径上执行的,它每多停留一微秒,所有线程的调度延迟都会受影响。所以它有max_newidle_lb_cost这样的上限,并且每次newidle_balance()扫描完一个域觉得没东西可拉,就会迅速放弃继续往上走。实际表现往往是:一个 CPU 刚空下来,拉过来一两个任务,然后又空下来,再拉。这种“间隙性”是刻意设计的,代价是某些时候空闲 CPU 要等下一次调度才继续拉活。
4.3 newidle 均衡的代价与开关
newidle 均衡最容易被忽视的副作用是缓存抖动。当一个 CPU 进入 idle 后,它可能从别的 CPU 抢一个任务过来,但这个任务的缓存足迹还留在原 CPU 上,导致新 CPU 运行时各种 cache miss。尤其在 NUMA 或多 LLC 拓扑下,这种跨节点拉取带来的开销可能大于收益。
如果你在做低延迟调优,并且明确知道某些 CPU 不应该参与这种主动拉取,可以考虑:
- 使用
isolcpus内核参数把 CPU 从调度器均衡范围内隔离出去; - 配合 cpuset 或 cgroup 做精细绑定;
- 临时调整调度域 flags(不建议在生产环境直接改,风险高)。
有关这个方向的实践经验,我在第 7 节会用一个实际案例说明。
5. tickless 下没人 tick 怎么办:nohz idle balance 的远程唤醒
现代内核默认开启CONFIG_NO_HZ_IDLE,CPU 进入 idle 后会停掉 tick 以省电。问题是:既然 tick 停了,周期均衡就不会在这个 CPU 上发生,那它怎么参与负载均衡?
答案是“远程唤醒”。这个机制在 6.12 里叫 nohz idle balance,核心思路是让还在 tick 的忙碌 CPU 或某些特殊角色 CPU 帮忙照看那些不 tick 的空闲 CPU。
5.1 nohz 状态下的负载均衡困局
当一个 CPU 进入 idle 并停掉 tick,它的nohz.idle_cpus_mask会被置位,表示“我不自己参与均衡了,但我需要别人在有必要时叫醒我”。从这一刻起,它不会主动发起任何均衡,只会被动等待。
问题在于:如果系统里所有 CPU 都空闲了,谁来发起均衡?内核的做法是选出一个ilb_cpu(idle load balancer),专门负责代表所有 nohz idle CPU 做周期性检查。
5.2 nohz_balancer_kick 是怎么点名的
当系统里还有 CPU 在运行任务时,每次scheduler_tick()都会调用trigger_load_balance(),这里会检查nohz.idle_cpus_mask是否非空。如果发现有空闲 CPU 等待被照顾,就会走nohz_balancer_kick()。
nohz_balancer_kick()的职责是:
- 维护
nohz.idle_cpus_mask,把退出 idle 的 CPU 及时摘掉,避免给已经忙起来的 CPU 发送唤醒请求; - 从 idle CPU 中选出一个
ilb_cpu; - 通过 IPI 把目标 CPU 唤醒,让它在调度软中断里执行
rebalance_domains()。
这个过程非常值得注意:它用的不是某个专用内核线程,而是 IPI 打断一个空闲 CPU,让它自己再跑一遍调度软中断。所以你在perf或 ftrace 里看到某个空闲 CPU 突然被唤醒去做均衡,十有八九就是 nohz 路径在起作用。
5.3 有效观察 nohz 均衡的指标
怎么判断 nohz 均衡是否正常工作?最直接的方式是观察/proc/sched_debug:
$ cat /proc/sched_debug | head -80 Sched Debug Version: v0.11, 6.12.0 ... rq->cpu: 0 .nr_running : 2 ... nohz_balance_enter_idle : 123如果nohz_balance_enter_idle的计数持续增长,说明系统正在频繁切换 idle 状态。再配合perf sched看唤醒源,就能判断是不是有大量跨 CPU 唤醒来自 nohz 均衡。实际调优中,最常见的 nohz 问题是:空闲 CPU 过多时,ilb_cpu的负担会变重,IPI 数量也会明显上升。
6. kernel 6.12 前后:这些触发时机有什么变化
聊完基本框架,再说说 6.12 这个版本对负载均衡触发时机本身的影响。结论前置:6.12 没有像 6.6 引入 EEVDF 那样重构调度核心,负载均衡的四类触发路径基本稳定,但它的上下文已经变了不少。
6.1 从 EEVDF 到 sched_ext:6.12 调度器真正的重头戏
kernel 6.12 最重磅的调度器事件是 sched_ext 正式合入主线。sched_ext 允许用 BPF 程序实现自己的调度策略,这听起来和“触发负载均衡”没关系,实际上关系极大。
默认情况下,内核使用的仍是 CFS/EEVDF,select_task_rq_fair()和run_rebalance_domains()这些路径照常工作。但一旦系统加载了 BPF 调度器,CFS 的唤醒均衡、周期均衡都可能被完全绕过——拓扑、负载、触发时机全部由 BPF 程序自行决定。也就是说,6.12 之后,“什么时候触发负载均衡”这个问题多了一个新答案:取决于你挂载的 BPF 调度器。
这也意味着,如果你在新版本内核上排查负载不均衡问题,第一件事先确认当前用的是不是默认调度器:
$ cat /sys/kernel/sched_ext/state 2>/dev/null || echo "sched_ext not enabled"如果 sched_ext 被启用,传统 CFS 的触发逻辑就得暂时放一边。
6.2 负载均衡触发框架在 6.12 的变化
从代码层面看,6.12 对kernel/sched/fair.c里负载均衡相关路径做的主要是“修修补补”:
- 对 EEVDF 加入后出现的一些边界情况做了修复,比如任务在
wakeup路径上因为vruntime对齐导致的选择偏差; - 增强了对大小核(asymmetric CPU capacity)场景的负载感知,
find_idlest_group里对 capacity 的处理更细化; - 细化
balance_interval的动态调整逻辑,避免高负载短任务场景下迁移频率过低。
这些改动不会让触发时机表发生颠覆性变化,但会让同一个触发点在不同负载模式下表现得更灵敏。如果你在 6.6 上发现“唤醒均衡总是慢半拍”,升级到 6.12 后可能不需要改任何配置,现象就自动改善了。
6.3 内核参数与编译选项对触发时机的影响
6.12 里影响触发时机的内核参数,排查时值得再确认一遍:
| 参数 | 作用 |
|---|---|
kernel.sched_autogroup_enabled | 是否启用自动分组,影响任务组间负载均衡的粒度 |
kernel.sched_migration_cost_ns | 迁移任务需要跨越的最小成本,过大会抑制迁移 |
kernel.sched_nr_migrate | 一次负载均衡最多迁移的任务数 |
CONFIG_SCHED_CLUSTER | 是否启用 cluster 调度域,影响低层域结构 |
我见过不少人因为把sched_autogroup_enabled关掉后,发现桌面或容器场景负载分布反而变差,就是因为它改变了任务组被均衡的粒度。调参前先想清楚:你要改的是触发频率,还是迁移成本,还是域结构。
7. 实操:如何定位一次迁移是哪个触发路径干的
知道了所有触发点,下一步就是把理论用到排查中。这里分享一套我自己重复过很多次的定位流程。
7.1 先查调度域拓扑和标志位
每次排查负载均衡问题,我第一件事永远是看当前系统的调度域长什么样:
$ cat /sys/kernel/debug/sched/domains domain0 MC: span: 0-15 level: MC flags: 0x0000c033 (SD_LOAD_BALANCE SD_BALANCE_NEWIDLE SD_BALANCE_WAKE SD_BALANCE_EXEC SD_BALANCE_FORK ...) interval: 4ms max_interval: 64ms busy_factor: 64 imbalance_pct: 125这个输出能一次性看出每个域支持哪些触发路径。如果SD_BALANCE_WAKE不在某个域上,那就别指望这个域做唤醒均衡。interval是基础均衡间隔,imbalance_pct=125表示组间负载差超过 25% 才认为失衡。这些参数是判断“均衡为什么没发生”的第一手依据。
7.2 ftrace 抓取 load_balance 的完整调用栈
当你看到一次任务迁移,但不确定是哪条路径触发的,用 ftrace 的 function_graph 最直接:
cd /sys/kernel/tracing echo 0 > tracing_on echo function_graph > current_tracer echo 'load_balance' > set_ftrace_filter echo 1 > tracing_on # 触发业务压力,观察输出 cat trace | grep -B 20 'load_balance'日志里会同时显示调用栈来源:
- 如果栈底是
run_rebalance_domains,说明是周期均衡或 nohz 均衡; - 如果栈底是
select_task_rq_fair,说明是唤醒/exec/fork 均衡; - 如果栈底是
schedule/newidle_balance,说明是 CPU 空闲拉取。
7.3 一个由 newidle_balance 引起的抖动排查
实际案例是这样的:一台 32 核机器跑 Redis 压测,网络软中断主要在 CPU8 上处理,但 CPU8 偶尔会进入 idle。每当它空闲,newidle_balance立刻从其它 CPU 拉任务过来,拉过来的线程和网络软中断抢 CPU,导致尾延迟飙高。从监控上看 CPU8 利用率不高,但业务延迟很糟糕。
排查过程:
- 用
perf sched record -g抓事件,看到大量migrate_task_to_curr; - ftrace 确认这些迁移都起源于
newidle_balance,而不是唤醒均衡; - 看调度域 flags,确认 MC 域开了
SD_BALANCE_NEWIDLE; - 把 Redis 进程绑到固定的几个 CPU(cpuset),同时允许软中断在其他 CPU 上处理,问题消失。
如果不动 cpuset,也可以小心地去掉 MC 域的SD_BALANCE_NEWIDLE标志观察效果,但这属于实验性操作,不建议在正式环境直接改。
7.4 我的排查习惯和几个建议
最后给几条经验性建议,算是给前面内容的总结,也算是我个人踩坑之后的固定动作:
- 看到负载不均,先看调度域输出,不要急着调
sched_migration_cost_ns; - 用 ftrace 定位触发路径,比盲调参数高效得多;
- 区分“利用率不均”和“任务数不均”:有时候 CPU0 利用率 80%、CPU1 利用率 20%,但任务数差不多,这是短任务和长任务调度差异造成的,不是均衡器失效;
- 对延迟敏感的业务,优先考虑用 cpuset 明确隔离,而不是依赖自动均衡;
- 升级到 6.12 后,先把
CONFIG_SCHED_DEBUG打开,方便后续排查调度域标志。
负载均衡的触发机制说到底就是这几条路径的组合。每次遇到“为什么任务不搬”的问题,先问自己三个问题:当前域有没有对应的均衡标志?时间上有没有到均衡间隔?负载差值有没有超过 imbalance 阈值?三个问题过一遍,大部分疑团都能解开。