资讯详情

资讯详情

Linux 6.12内核负载均衡触发机制详解:从tick到nohz

压测的时候经常遇到一个奇怪现象明明整台机器好几个核都闲着任务却死死堆在其中一个核上好一会儿才被摊匀。很多人第一反应是“负载均衡没生效”其实内核的负载均衡根本不是后台线程一直在扫描它是散落在几个特定时间点上的被动动作。在 kernel 6.12 下这些触发点尤其值得捋清楚——搞明白了你才能解释为什么有时候均衡很快、有时候慢半拍也才能在出问题的时候判断到底是哪条路径没能及时跟上。这篇文章就用 Linux 6.12 的代码逻辑把“什么时候触发负载均衡”这件事拆开讲顺便给出我实际排查负载不均衡问题时用到的观察手段。适合内核新人理解调度器框架也适合做性能调优的人当排查手册翻。1. 先分清一件事负载均衡是事件驱动不是后台轮询很多人对内核调度器的第一印象是“有个守护线程每过一阵子就检查一次 CPU 负载然后做迁移”。这个直觉对了一半但机制上完全不是这么回事。从 6.12 的代码看负载均衡的触发点分散在 tick 中断、任务唤醒、任务创建、CPU 进入空闲、以及 nohz 相关的通知里全都是“事件”而不是“轮询”。1.1 “负载”到底是什么PELT 和瞬时任务数不是一回事在谈触发时机之前得先定义内核眼里“负载”是什么。CFS 调度器维护了两套指标load_avg和util_avg。load_avg是带权重的可运行实体负载基于 PELTPer-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新任务唤醒需要选 CPUSD_BALANCE_WAKEexec/fork 均衡select_task_rq_fairexec()/fork()产生新任务SD_BALANCE_EXEC/SD_BALANCE_FORK新空闲均衡schedule - newidle_balanceCPU 进入 idle 后尝试拉任务SD_BALANCE_NEWIDLEnohz 远程均衡nohz_balancer_kicktickless 系统中有 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会累积间隔会指数级放大。这个机制是为了避免两个核之间因为一点点负载差反复搬任务——也就是所谓的“抖动”。这里有一个很反直觉的细节周期均衡解决的其实是长时间尺度上的负载漂移。任务创建后慢慢堆积到某些 CPUPELT 负载逐渐升高直到超过阈值才触发迁移。它反应慢但开销可控。所以不要指望周期均衡能解决瞬时热点。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_EXECexec使用SD_BALANCE_EXEC因为新程序往往意味着完全不同的缓存足迹值得重新选址。WF_FORKfork使用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_IDLECPU 进入 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_cpuidle 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_ext6.12 调度器真正的重头戏kernel 6.12 最重磅的调度器事件是 sched_ext 正式合入主线。sched_ext 允许用 BPF 程序实现自己的调度策略这听起来和“触发负载均衡”没关系实际上关系极大。默认情况下内核使用的仍是 CFS/EEVDFselect_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_pct125表示组间负载差超过 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_currftrace 确认这些迁移都起源于newidle_balance而不是唤醒均衡看调度域 flags确认 MC 域开了SD_BALANCE_NEWIDLE把 Redis 进程绑到固定的几个 CPUcpuset同时允许软中断在其他 CPU 上处理问题消失。如果不动 cpuset也可以小心地去掉 MC 域的SD_BALANCE_NEWIDLE标志观察效果但这属于实验性操作不建议在正式环境直接改。7.4 我的排查习惯和几个建议最后给几条经验性建议算是给前面内容的总结也算是我个人踩坑之后的固定动作看到负载不均先看调度域输出不要急着调sched_migration_cost_ns用 ftrace 定位触发路径比盲调参数高效得多区分“利用率不均”和“任务数不均”有时候 CPU0 利用率 80%、CPU1 利用率 20%但任务数差不多这是短任务和长任务调度差异造成的不是均衡器失效对延迟敏感的业务优先考虑用 cpuset 明确隔离而不是依赖自动均衡升级到 6.12 后先把CONFIG_SCHED_DEBUG打开方便后续排查调度域标志。负载均衡的触发机制说到底就是这几条路径的组合。每次遇到“为什么任务不搬”的问题先问自己三个问题当前域有没有对应的均衡标志时间上有没有到均衡间隔负载差值有没有超过 imbalance 阈值三个问题过一遍大部分疑团都能解开。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →