这篇文章前前后后啃了半个月的 Linux 内核调度器源码,终于把 CFS 负载跟踪的 PELT 机制理顺了。特别是层级传播和挂载摘除那一块,之前看资料总是一知半解,真正动手在源码里翻了一遍,又用 tracepoint 实测了几轮,才算把整条链路串起来。这篇就按我的理解,把 PELT 从基础原理到实际应用的完整路径拆开讲清楚,适合已经了解 CFS 基本概念、想深入调度器内部实现的朋友,也适合正在排查负载不均衡问题、需要理解为什么 CPU 占用和负载值对不上的同学。
先说结论放前面:PELT 这套机制最核心的设计思想,是把时间衰减函数加进负载计算里,再用一条从 task 到顶层 cfs_rq 的传播链,让负载信息在上层调度决策里可用。挂载做的是把子实体负载叠加到父实体上,摘除做的是把进程退出或迁走时的负载从父实体里扣掉。这两个动作如果做不对,你看到的 load 值就会出现奇怪的跳变,而负载均衡器基于这个数值做出的迁移决策也就跟着出错。
1. CFS 负载跟踪的前世今生:为什么有了调度器还要单独做 PELT
1.1 没有 PELT 的时代:平均负载是怎么被玩坏的
早期 Linux 内核算负载用的是调度域里的nr_running和cpu_power这类静态值,本质上是在数“当前有多少进程在排队”。这种做法的最大问题在于,它只看瞬间状态,不看历史趋势。一个进程刚被唤醒时,nr_running瞬间加 1,负载值立刻往上跳;等它跑完一个时间片进入睡眠,负载又瞬间掉下来。这个抖动在 CPU 数量少的时候还能忍,一旦到了大型 NUMA 系统或者多核虚拟化环境,调度器根据这种跳来跳去的数值做迁移决策,结果就是进程在不同的 CPU 之间反复震荡,缓存亲和性被破坏得很厉害。
后来有人提过一套叫作sched_avg的机制,用指数移动平均(EMA)来平滑负载。这个方案的思路方向是对的,但它有个致命问题:它统计的是整个 runqueue 的平均负载,而不是单个调度实体的负载。什么意思呢?假设一个 runqueue 上有 10 个进程,其中一个进程突然变得繁忙,另外 9 个都在睡眠,EMA 算出来的结果会把 10 个进程的负载混在一起,调度器根本没办识别出到底是谁在消耗 CPU。这直接影响了两个核心调度功能:一个是负载均衡,因为它没办法判断“哪棵子树底下承载了更多压力”;另一个是 CFS 带宽控制和频率调节,因为它们需要知道“这个 task_group 到底用了多少 CPU 配额”。
1.2 PELT 的核心思想:让负载跟着实体走,而不是跟着队列走
PELT,全称 Per-Entity Load Tracking,关键就在“Per-Entity”。在 PELT 之前,负载是挂在 runqueue 上的集体指标;PELT 之后,负载变成了每个调度实体的个人属性。这里的调度实体包括 task(进程)和 task_group(进程组,对应 cgroup 的 cpu 子系统)。每个实体维护自己的sched_avg结构,记录自己的负载贡献,然后逐级向上传播到父级cfs_rq。
为什么要按实体跟踪?核心原因是现代 Linux 内核用 cgroup 做资源隔离的场景太常见了。一个 Kubernetes 节点上,每个 Pod 对应一个 cpu 子系统的 cgroup,容器里的所有进程都属于同一个 task_group。PELT 能算出这个 cgroup 整体的负载贡献,而不是只看到单个进程的负载。更重要的是,PELT 的负载值是带了时间衰减系数的,它反映的是“一段历史窗口内的平均负载”,而不是某一瞬间的突发值。这一点对调度决策至关重要,因为调度器不希望因为一个进程短暂醒来 10 毫秒就立刻做出迁移决定,它需要判断这个负载是否具有持续性。
PELT 的负载计算公式可以简化为:L = L0 * y^t + Δ,其中y是衰减因子,t是时间,Δ是当前周期的新增负载。每过一个周期(1024 微秒),旧负载按y比例衰减一次。内核里定义y^32 ≈ 0.5,也就是说 32 个周期(约 32 毫秒)前的负载衰减到一半。这么做的效果是:负载值本质上是一个带遗忘因子的加权历史平均。
2. 数据结构和算法基础:读懂 PELT 的三个核心组件
2.1sched_avg结构体:负载的“记忆单元”
内核里每个调度实体都内嵌了一个struct sched_avg,定义在include/linux/sched.h。这个结构体是 PELT 的心脏,理解了它,后面所有代码分析都能顺下来:
struct sched_avg { u64 last_update_time; // 上次更新时间点,核心时间戳 u64 load_sum; // 负载的累加值(未规范化) u32 util_sum; // 利用率累加值(未规范化) u32 period_contrib; // 当前周期内已经累计的时间(未对齐部分) unsigned long load_avg; // 平均负载,由 load_sum 经过数学变换得到 unsigned long util_avg; // 平均利用率,由 util_sum 经过数学变换得到 };这里要特别解释两个容易混淆的概念:load_sum和util_sum的差异。load_sum是把调度实体的权重weight考虑进来的值,它除以LOAD_AVG_MAX(一个满载周期序列的理论最大累加值)就得到load_avg。这个值对 CFS 的负载均衡器是核心输入,因为它反映了“这个实体在系统中承担的权重比例”。而util_sum不考虑权重,纯粹是时间上的占用率,单位是SCHED_CAPACITY_SCALE(默认 1024),对应一个 CPU 核在满载时一个周期内的贡献。util_avg被频率调节(schedutil)算法直接使用,它告诉调频器当前 CPU 的需求有多高。
last_update_time是同步的关键。PELT 的所有计算都是增量式的:只有距离上次更新时间足够长时,才需要重新计算衰减。如果两次更新之间只隔了很短时间(比如小于一个 tick),内核会直接跳过计算,避免频繁的浮点和乘法运算消耗太多性能。这个设计也符合真实世界调度器的诉求——精度够用就行,但开销必须可控。
2.2sched_entity:PELT 的载体
struct sched_entity定义在include/linux/sched.h,它是一个调度实体在 CFS 中的抽象。首先它包含了struct load_weight load表示权重,也就是我们在nice值里看到的那一套映射。其次它内嵌了struct sched_avg avg,这就是 PELT 的个人负载档案。
看sched_entity源码时,有on_rq标志需要重点关注。它表示这个调度实体当前是否在 runqueue 上。on_rq的状态直接影响 PELT 的更新路径:实体在on_rq上时,每次 tick 都会更新负载;实体被摘除(dequeue)时,PELT 会记录摘除时刻的快照,但不会清零,而是保留衰减后的历史值。这个设计考虑的是休眠进程的负载处理——一个进程睡眠 10 秒后重新唤醒,它的负载不应该从零开始,而是应该基于之前的历史负载按时间衰减。这样调度器在唤醒进程时就能正确判断:这个进程虽然休眠了很久,但历史上是个负载大户,需要给它分配合适的 CPU 资源。
parent指针把单个sched_entity连接到它的父级实体上,而cfs_rq指针指向它所在的 runqueue。一个 task_group 在系统中的关系是这样的:每个 task_group 在每个 CPU 上有一个对应的cfs_rq,这些cfs_rq又通过sched_entity挂到上一层。PELT 的负载传播就是沿着sched_entity的parent一路向上:task 的负载先更新到它的se->avg,然后加到所在 CPU 的cfs_rq->avg,如果这层cfs_rq本身是某个 task_group 的,就通过它对应的se再往上传播。
2.3cfs_rq结构里的avg:CPU 视角的负载聚合
struct cfs_rq是 CFS 的运行队列结构,定义在kernel/sched/sched.h。它的 PELT 关键字段如下:
struct cfs_rq { ... struct load_weight load; struct sched_avg avg; // 整个 rq 的负载聚合值 ... u64 removed_load_avg; // 待移除的负载贡献记录 u64 removed_util_avg; ... };cfs_rq->avg是 PELT 在 per-CPU 视角的聚合结果。每次更新时,cfs_rq会把它下面所有sched_entity的load_avg累加起来,再加上它自身的权重对时间的贡献,然后通过 PELT 的数学变换得到cfs_rq->avg.load_avg。
注意removed_load_avg这个字段,它很关键。因为负载摘除可能发生在一些无法立即更新的上下文中——比如负载均衡器正在遍历某个cfs_rq,此时一个 task 被换出或退出。为了不在锁冲突上耗性能,内核先把待摘除的负载暂存在removed_load_avg里,等下一次update_cfs_rq_load_avg时统一从总负载中减去。这个机制保证了 PELT 更新的一致性,但也带来一个问题:如果你在 tracepoint 里看到某个时刻负载值偏大,很可能是因为removed_load_avg还没来得及被处理。
3. 挂载与摘除的完整链条:从任务创建到 cpu 负载更新
3.1 任务生命周期事件:task_new、task_dead、migrate
整个 PELT 挂载摘除逻辑,起始于任务的生命周期事件。CFS 在四个关键节点调用了 PELT 相关函数:
task_new(新任务首次入队):
task_new_fair()被调用时,CFS 会调用post_init_entity_util_avg()来初始化新实体的avg结构,然后用attach_entity_load_avg()把初始负载挂到对应的cfs_rq上。注意新任务的初始util_avg并不是 0,而是sd->sysctl_sched_min_granularity相关的一组默认值,具体逻辑在内核 4.14 后从NICE_0_LOAD的固定比例变成了更优雅的估算值,目的是避免任务刚创建时负载从 0 爬得太慢导致调度器对新手任务不够敏感。task_dead(任务退出):
task_dead_fair()里调用remove_entity_load_avg(),这个函数把se->avg中的负载从cfs_rq->avg中减掉。如果该se还有父级(即它属于某个 task_group),再通过update_tg_load_avg()更新上一层的负载值。这个路径一定不能漏,否则 cgroup 的负载统计会残留大量已经死掉的进程的负载,导致调度器认为某个子 cgroup 还有很高的负载需求。任务迁移(migrate):任务被负载均衡器选中并迁移到另一个 CPU 时,
detach_task_cfs_rq()会解除该任务在源 runqueue 上的sched_entity链接,并首先调用detach_entity_load_avg()把负载从源cfs_rq摘除;随后attach_task_cfs_rq()在目标 runqueue 上重建实体,并调用attach_entity_load_avg()把负载挂到目标cfs_rq。这里一个常见的性能陷阱是:如果频繁触发迁移,每次迁移都要做一次衰减计算和父级传播,开销很大;所以 PELT 的更新频率在 4.14 之后有了更精细的控制,避免负载均衡器高频运行时产生大量无谓的传播更新。
3.2 调度实体状态切换:enqueue 与 dequeue 的 PELT 位置
任务在运行队列上的状态切换也是 PELT 挂载摘除的重要触发点。enqueue_entity()和dequeue_entity()里各有一组 PELT 相关的调用:
当enqueue_entity()把一个实体的on_rq置为 1 时,会调用enqueue_load_avg()。这个函数做的事情包括:检查实体的last_update_time是否需要重新同步;更新cfs_rq->avg,把se->avg.load_sum累加进cfs_rq->avg.load_sum;如果se->avg.load_sum之前为 0,还需要初始化cfs_rq->avg的last_update_time为当前时间。这种初始化的目的是为了让新唤醒的任务从正确的起点开始累积负载。
dequeue_entity()则相反,它调用dequeue_load_avg(),逻辑主要是把se->avg的负载从cfs_rq->avg中减掉。但注意一个细节:如果任务只是普通睡眠,它的调度实体并不会从树里删除,只是on_rq变为 0,PELT 的dequeue_load_avg并不会把实体的load_sum清零,只是把本次贡献从队列聚合值中扣掉。这样一来,任务在睡眠期间,它自己的avg值依然按照时间衰减继续更新,但不再对cfs_rq的聚合负载做贡献——这个设计很优雅,既保持了队列负载的实时性,又保留了单个实体的历史信息。
3.3 传播入口:update_cfs_rq_load_avg 和 update_tg_load_avg
在 CFS 的主路径里,update_cfs_rq_load_avg()是每 tick 和负载均衡前必调的函数。它的核心逻辑是先处理removed_load_avg,再调用__update_load_avg()来更新cfs_rq->avg,最后检查是否把这个cfs_rq的负载变化传播到上层 task_group。
这个函数的后半段是层级传播的关键入口:
static inline void update_cfs_rq_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { ... if (se && cfs_rq->tg->parent) { int cpu = cpu_of(rq_of(cfs_rq)); cfs_rq->avg.last_update_time = se->avg.last_update_time; update_tg_load_avg(cfs_rq->tg, cpu, 0); } }可以看到,只有当cfs_rq->tg->parent存在时才会向上传播,这代表当前cfs_rq不是最顶层的 per-CPU 队列,而是挂在某个上层 task_group 下的。此时cfs_rq->avg.last_update_time会被强制同步为对应se->avg.last_update_time,保证父子和子队列的时间基准一致。update_tg_load_avg()则在必要的时候更新 task_group 级的load_avg计数。
再往下,update_tg_load_avg(unsigned long *load, int cpu, bool clear)实现如下(这是老版本里的实现,新版本有微调,但语义一致):
static inline void update_tg_load_avg(struct cfs_rq *cfs_rq, int force) { long delta = cfs_rq->avg.load_avg - cfs_rq->tg_load_avg_contrib; if (force || abs(delta) > cfs_rq->tg_load_avg_contrib / 64) { atomic_long_add(delta, &cfs_rq->tg->load_avg); cfs_rq->tg_load_avg_contrib = cfs_rq->avg.load_avg; } }这里需要注意一个细节:delta的阈值设置为tg_load_avg_contrib / 64,意味着负载变化小于当前值的 1/64 时,不会更新到 task_group 级别。这是刻意而为之的性能优化,避免每 tick 都锁全局的 tg 负载计数器。但它带来的副作用是:上层的load_avg可能会有最多 1/64 的滞后误差——在大规模 cgroup 系统里,这个滞后可能积少成多,表现为cpu.stat里的nr_periods和你在perf里看到的实际值存在偏差。
4. 核心路径源码级拆解:一次典型的负载传播是如何发生的
4.1attach_entity_load_avg与detach_entity_load_avg:挂载与摘除的直接实现
attach_entity_load_avg()和detach_entity_load_avg()是 PELT 层级挂载摘除中最小但最关键的操作单元。它们定义在kernel/sched/fair.c里,核心逻辑分别如下:
static void attach_entity_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { /* * 如果实体之前 `on_rq` 为 0(新任务或睡眠唤醒), * 它的 `load_sum` 可能被置 0 了;或者它从未被 attach 过。 * 此时要重新确保 cfs_rq 的 `last_update_time` 同步。 */ if (!se->avg.last_update_time) { se->avg.last_update_time = cfs_rq->avg.last_update_time; se->avg.load_sum = se->avg.util_sum = 0; } cfs_rq->avg.load_sum += se->avg.load_sum; cfs_rq->avg.util_sum += se->avg.util_sum; cfs_rq->avg.load_avg = calc_load_avg(cfs_rq->avg.load_sum); cfs_rq->avg.util_avg = calc_load_avg(cfs_rq->avg.util_sum); ... }注意attach_entity_load_avg这里的关键操作是累加而不是覆盖。它把se->avg的load_sum和util_sum叠加到cfs_rq->avg上。为什么是累加?因为一个cfs_rq承载了多个sched_entity,每个实体都在贡献负载,队列负载必须是所有子实体贡献的总和。这就像一个大公司里多个部门上报季度预算——CFO 会把每个部门的预算直接相加,而不是取平均值。
detach_entity_load_avg则正好相反,它做的是减法操作:
static void detach_entity_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { __update_load_avg(cfs_rq->avg.last_update_time, &cfs_rq->avg, se->on_rq * scale_load_down(se->load.weight), cfs_rq->curr == se, NULL); sub_positive(&cfs_rq->avg.load_avg, se->avg.load_avg); sub_positive(&cfs_rq->avg.util_avg, se->avg.util_avg); cfs_rq->avg.load_sum = calc_load_sum(cfs_rq->avg.load_avg); cfs_rq->avg.util_sum = calc_load_sum(cfs_rq->avg.util_avg); }细看这段代码,它其实包含了两层动作:先调用__update_load_avg把cfs_rq自身的负载值刷新到最新时间点(因为摘除前需要把队列里之前的负载贡献全部衰减到位,否则直接做减法会引入陈旧时间戳),然后才执行sub_positive把se->avg的值从cfs_rq->avg中扣掉。注意sub_positive是一个带保护的安全减法,结果不会小于 0,这是为了防止在极端并发场景下出现负负载。
4.2__update_load_avg:PELT 的核心数学引擎
__update_load_avg是 PELT 计算的核心函数,源码位置在kernel/sched/fair.c:
static __always_inline int __update_load_avg(u64 now, struct sched_avg *sa, unsigned long weight, int running) { u64 delta, periods; u32 contrib; int delta_w, decayed = 0; delta = now - sa->last_update_time; if ((s64)delta < 0) { return 0; } sa->last_update_time = now; if (!delta) { return 0; } /* 将 delta 拆分为完整周期(1024us)的一部分,period_contrib 是余数部分 */ delta_w = sa->period_contrib; if (delta + delta_w >= 1024) { decayed = 1; sa->period_contrib = 0; /* 先把当前窗口的贡献算完 */ delta_w = 1024 - delta_w; contrib = delta_w * weight; sa->load_sum += contrib; if (running) sa->util_sum += delta_w * scale_load_down(weight); delta -= delta_w; periods = delta / 1024; delta %= 1024; sa->load_sum += decay_load((unsigned long)(sa->load_sum), periods); if (running) sa->util_sum += decay_load((unsigned long)(sa->util_sum), periods); sa->period_contrib = delta; sa->load_sum += delta * weight; if (running) sa->util_sum += delta * scale_load_down(weight); return 1; } sa->period_contrib += delta; sa->load_sum += delta * weight; if (running) sa->util_sum += delta * scale_load_down(weight); return decayed; }这个函数的关键是把时间差delta拆成三部分来处理:小于当前完整周期剩余部分的“零头”、若干个完整周期、以及新周期里累计的“新零头”。每个完整周期内,旧负载都要乘以衰减因子y。decay_load(val, n)的实现就是val * y^n,内核用查表法和多项式展开做了优化。
为什么需要period_contrib这个字段?因为last_update_time可能落在任意时间点,不一定刚好对齐 1024 微秒的周期边界。如果不记录周期内已经累计的部分,每次更新都会丢失那些没有满一个周期的微小时间片,长期积累下来会造成漂移。period_contrib就是为了消除这个舍入误差而存在的。
再来看sa->load_sum += contrib那一段,这里的weight就是se->load.weight / scale_load_down()后的结果。对于普通任务,load等于NICE_0_LOAD(1024)乘以 nice 值对应的权重比;对于 task_group 的se,它的load.weight是任务组在父级cfs_rq上的权重,值取决于shares,由scale_shares()函数计算得到。
4.3 一次典型的 cgroup 层级传播全流程
假设一个层级结构:系统有 cgroup A(对应任务组 TG_A),A 下又建了 cgroup B(TG_B),B 里跑了一个任务 T。在 CPU 0 上:
root cfs_rq (CPU0) └─ se_A (TG_A 在 CPU0 上的 sched_entity) └─ cfs_rq_A (TG_A 在 CPU0 上的运行队列) └─ se_B (TG_B 在 CPU0 上的 sched_entity) └─ cfs_rq_B (TG_B 在 CPU0 上的运行队列) └─ se_T (任务 T 的 sched_entity)当 T 运行了一个周期后,PELT 的更新顺序是自下而上的:
首先
update_cfs_rq_load_avg(cfs_rq_B, se_T)被调用,内部执行__update_load_avg更新cfs_rq_B->avg。这次更新会加上 T 的权重在时间片上的贡献,cfs_rq_B->avg的load_sum和util_sum变大。然后
update_tg_load_avg(cfs_rq_B->tg, cpu, 0)被调用,如果cfs_rq_B的平均负载与上次记录在cfs_rq_B->tg_load_avg_contrib的差值超过阈值,就把变化量累加到 TG_B 的load_avg上。判断
se_B是否在运行,如果是,调用se_B对应cfs_rq_B的负载传播逻辑。在update_cfs_rq_load_avg里,cfs_rq_B的变化会接着携带到se_B->avg上。这一步经常让人困惑:se_B->avg究竟是怎么同步cfs_rq_B->avg的?答案是任务组实体的avg就是它对应的cfs_rq的avg,而不是单独再算一份,原因后面会说。同步完
se_B->avg后,调用attach_entity_load_avg(cfs_rq_A, se_B)(或者更准确地说,在运行路径上,这是通过update_cfs_rq_load_avg(cfs_rq_A, se_B)加上se_B->on_rq判断实现的),把se_B->avg累加到cfs_rq_A->avg上。继续向上,
cfs_rq_A的变化再同步到se_A->avg,然后累加到root cfs_rq。直到到达根cfs_rq,更新完成。
整个过程像水管里的一层层接力:任务贡献的水量先到达本层的蓄水池,然后溢出到上一层,逐级向上累积。只有最顶层的root cfs_rq没有对应的se,因为它不是任何 task_group 在某个 CPU 上的镜像。
关于第 3 步的补充:se_B->avg和cfs_rq_B->avg的关系是 PELT 实现里最容易让人绕晕的地方。看源码kernel/sched/fair.c里update_cfs_rq_load_avg的注册位置,会发现cfs_rq->avg和se->avg实际是同一个struct sched_avg的两个别名,通过cfs_rq->avg = se->avg来建立同步(注意这在较新内核里已经调整了,通过__sched_setscheduler等路径维护,但语义一致)。这就是为什么cfs_rq更新后,se能自动“感知”到变化。设计上这样做的原因是:一个 task_group 在某个 CPU 上的cfs_rq和它挂载到父层的se,本质上是同一个调度实体在不同层级的投影。
5. 实战排查:负载值异常背后的 PELT 问题
5.1 “CPU 占比 50%,但 load_avg 只有 0.3” 的原因分析
实际用起来,经常有人发现top里显示的 CPU 使用率和/proc/loadavg里的值对不上。其中一部分原因确实来自 PELT 的衰减机制本身:PELT 认为 32 毫秒前的负载只算一半,所以一个运行了 10 秒的进程,在它刚停止运行时,它的 PELT 负载不会是 0 也不是满载的 1,而是大约0.5^(10000/32),这个值已经小到几乎看不见了。所以loadavg和 PELT 的load_avg是两个不同的东西。
但更隐蔽的问题是挂载摘除路径上的 bug 或逻辑疏漏。比如任务频繁在 cgroup 层级间迁移时,如果摘除路径漏调了detach_entity_load_avg,那么父cfs_rq的load_sum就会一直残留旧贡献,导致父级负载虚高。实际排查时,可以通过trace_sched_load_update或trace_sched_util_est_cpu_tp一类 tracepoint 打点,直接观测每个实体的load_avg和util_avg随时间的轨迹变化,看摘除的瞬间数值是不是出现了不该有的“台阶”。
另一个常见的坑是removed_load_avg的累积。我在测试一个会频繁创建线程和退出线程的程序时发现,cfs_rq->avg偶尔会报告很高的负载,但实际 CPU 占用并不高。追查后确认线程退出时,remove_entity_load_avg把负载摘除挂到了removed_load_avg里,但下一次update_cfs_rq_load_avg调用因调度器路径的特殊性被跳过了,导致这些残留负载一直到下一个周期才被统一处理。处理时恰好同时有大量线程退出,就会产生一个瞬时尖峰。这个现象在高频短生命周期任务的场景下尤其明显。
5.2 从 tracepoint 看挂载摘除是否生效
实战建议:直接在目标内核上启用sched_load_avg相关的 tracepoint,比如:
cd /sys/kernel/debug/tracing echo 1 > events/sched/sched_load_avg/enable echo 1 > events/sched/sched_util_est_cpu_tp/enable echo 1 > tracing_on sleep 5 cat trace | grep -E "load_avg|util_avg" | tail -100在这些输出里,sched_load_avg事件会打印comm、pid、cpu、load_sum、util_sum、load_avg、util_avg等字段。我在追踪一个 K8s 节点上的多容器场景时,发现有一个容器组的cfs_rq->avg.load_avg在容器停止后依然没有被清零。逐层排查下来,发现问题出在容器对应 task_group 的se还在被父级cfs_rq持有——容器组里的进程全部退出后,task_group 并不会立即销毁,而 PELT 的attach_entity_load_avg也不会自动感知“组内无进程”这个状态。这个滞后会让调度器以为容器组还有负载贡献,直到 cgroup 被删除、task_group走完释放流程。
这几年追 PELT 问题,我养成了个习惯:凡是遇到调度不均衡、负载抖动、能耗调频异常,先打开 tracepoint 抓一段sched_load_avg数据,过滤出可疑实体的load_avg变化曲线。从曲线的形态入手反推挂载摘除路径有没有问题,比直接看汇总指标高效得多。
5.3 cgroup 层级的“负载孤岛”问题
另一种实际场景是:cgroup 树很深(比如 Docker + Kubernetes 多层嵌套 cpu 子分区),PELT 每一层传播都在扣掉一定的误差。由于update_tg_load_avg()有 1/64 的更新阈值,层级越深,底层负载变化越不容易传递到顶层。这在真实的生产环境里表现为:/sys/fs/cgroup/cpu.stat里的usage_usec很大,但/proc/stat里的 load 却不高,调度器的负载均衡判断因此出现偏差。
排查方法:直接看每层 cgroup 的cpu.stat和对应se->avg.load_avg。如果底层load_avg增长明显,但父层吞吐滞后,那基本可以判断是传播阈值在起作用。这种状态多数情况下不需要处理,因为内核对滞后有容忍度。但如果你的场景是对实时性要求很高的延迟敏感型任务(比如音视频编解码、高频交易),建议走cpu.weight的精细配置或者使用SCHED_DEADLINE这类硬实时调度器,不要依赖 PELT 的负载传播来保证 QoS。
6. 实操经验:我在调 PELT 相关问题时积累的几条心得
先说排查工具。内核里/sys/kernel/debug/sched_debug这个文件非常有用,它会把每个 CPU 的cfs_rq->avg.load_avg、util_avg以及每个se的对应值一层层列出来。配合sched_features里打开NO_HZ_FULL的调试输出,你可以直观看到每个实体的负载变化曲线。我在分析一个 NUMA 系统上的调度不均问题时,就是用sched_debug定位到某个se的load_avg明显高于其他se,然后发现它底下的cfs_rq有大量残留负载未摘除,最终顺着removed_load_avg找到了根因。
再说一个经验:不要盲目在cfs_rq上手动清负载。有些运维脚本或者早期的内核 patch 会尝试在 pick_next_task 路径上手动将cfs_rq->avg.load_avg置 0 来快速恢复负载均衡。这种做法非常危险,因为它破坏了衰减状态的一致性,后续所有__update_load_avg调用都可能计算出负值或异常大值,触发调度器内部的 BUG_ON 或者导致进程被迁移到不合适的 CPU 上。
最后聊一下内核版本差异。PELT 在不同内核版本中细节变化很大:4.14 引入了util_est(utilization estimation)机制,在sched_avg之上加了一个指数移动平均的估计缓存,用于低频频率调节和任务放置;5.8 开始加入了PELT对齐优化,把period_contrib的更新做了更精细的拆分,同时引入了__update_load_avg_blocked_load之类的新函数专门处理blocked负载;5.15 之后的版本里,load_avg计算从calc_load_avg改成了基于mul_u32_u32的定点运算,进一步降低了内核中 64 位到 32 位乘法的溢出风险。如果你在排查问题时碰到了和我上面完全一致的代码位置,但行为略有不同,先查一下你当前的kernel/sched/fair.c是不是已经加入了这些新改动。
根据这些年的经验,我的建议是做内核调优时先在低负载、可控场景下抓一份完整的 PELT tracepoint 基线数据,把各个实体负载的长期曲线存下来。遇到问题时先对照基线,比对着文档盲猜快得多。搞 PELT 这件事,光看理论永远不够,一定要在真实负载下反复验证过挂载摘除的完整性,才算真正吃透。