简介:这份PDF聚焦Linux内核CFS调度器中的PELT算法,即按实体负载跟踪机制,面向正在阅读内核源码、研究调度器实现的中高级Linux开发者。资源从Linux 3.8引入PELT之前的运行队列级负载跟踪缺陷讲起,详细解释了为何旧方法无法确定负载来源且波动较大,并基于Linux 4.18.0源码,逐步展开PELT的核心原理。内容涵盖1024微秒周期划分、衰减因子y的取值与含义、负载贡献递推公式的推导,以及decay_load()函数和runnable_avg_yN_inv数组的生成和优化实现。文中配有具体数值示例,例如一个任务连续运行4096微秒后睡眠的负载变化计算,帮助读者直观理解递推过程。同时梳理了关键函数与常量表,读者可以对照源码快速掌握PELT的工程实现思路。资源为1个PDF文件,大小358KB,内容紧凑,适合离线阅读和反复查阅,已有572人学习。对于需要深入内核调度细节、准备技术分享或进行性能调优的工程师,是一份很好的参考资料。
1. PELT:让 CFS 调度器第一次看得懂历史的负载数字
CFS 调度器里最容易被误读的数字是load_avg。它不像 CPU 利用率那么直观——一个满载 CPU 的负载值不是 1.0,而是一个接近但小于 1 的分数;一个刚被唤醒的线程要花几百毫秒才能把负载爬到真实水平;一个已经被移走或销毁的线程还会在 CPU 上留下一段时间的“阴影”。这套数字背后的机制就是 PELT,全称 Per Entity Load Tracking,按调度实体粒度追踪负载,用指数衰减让历史负载持续说话。这篇文章讲清楚 PELT 的数学原理、代码路径、负载均衡怎么消费这些数字,以及我这些年用下来最常踩的五个坑。读完之后你能亲手验证一条负载曲线的生成过程,也能判断线上一个“负载高”的报警到底该不该扩容。
2. 为什么需要 per-entity 负载追踪:从平均负载到 PELT 的演进逻辑
2.1 调度实体是负载追踪的最小单元
在 PELT 出现之前,内核追踪负载的粒度要么是全局的,要么是单任务的。全局平均负载看的是整个系统所有 CPU 的活跃线程数总和,它对单核场景有效,但对多核、多容器、多层级 cgroup 的场景几乎没有任何参考价值。一个容器内跑满 8 个线程,另一个容器空转,全局负载窗口根本分不清哪个 CPU 该迁移哪个不该迁移。
PELT 把追踪粒度缩到了调度实体(sched_entity,简称 SE)。一个 SE 可以是普通任务,也可以是一个 task_group——也就是一组任务组成的集合。这样带来的直接好处是:任何一个负载数字都有明确的归属。CFS 运行队列(cfs_rq)上的负载等于所有可运行 SE 负载之和,CPU 的负载等于该 CPU 的cfs_rq贡献,而每个 task_group 又可以把负载聚合给父级 task_group。这套聚合关系天然贴合 cgroup 树。
我在读内核源码时习惯先画一张层级图:最底层是任务,中间是 task_group,再往上是对应到每个 CPU 的cfs_rq,最顶层是调度域里的 CPU 负载。PELT 就是在这一串从叶子到根的结构上,给每个节点各自维护一段衰减后的历史负载。任何一次任务入队、出队、tick 周期、迁移,都会沿着这条链路把负载增量向上传播。
2.2 把 PELT 独立于 CFS 类之外:接口与数据结构
PELT 不是一段孤立代码,它藏在kernel/sched/fair.c中,但数据结构和计算函数是复用的。每个sched_entity内嵌了一个sched_avg结构体,里面主要维护三个字段:load_avg表示权重加权后的平均负载,util_avg表示实际利用率,runnable_avg表示可运行时间占比。新读者最容易困惑的是load_avg和util_avg的区别——前者乘了任务的调度权重(nice 值),后者只是纯时间占比,不乘权重,所以跑满一个核的 nice 0 任务,util_avg接近 1024,而load_avg可能是 1024 也可能因为权重不同而更高。
这套机制在计算上由三个核心函数驱动:update_load_avg()、enqueue_task_fair()和dequeue_task_fair()。update_load_avg()负责把时间增量折算成衰减后的负载增量;enqueue_task_fair()在任务加入运行队列时触发一次更新;dequeue_task_fair()在任务离开时触发。除此之外,调度器 tick 也会周期性调用更新函数,保证即使没有任务切换,运行队列的负载也在持续衰减。
2.3 在本地内核里验证 PELT 是否生效:一条命令看追踪状态
理论说再多不如亲眼看一次。我一般会先打开内核的调度调试节点,确认 PELT 数据真的在更新:
cat /proc/sched_debug | sed -n '/cpu#0/,/cpu#1/p' | head -40输出里每个 CPU 段落下会有一行.load和.util,这就是 PELT 聚合后的数字。load约等于该 CPU 上所有调度实体load_avg之和,util与之类似但只算利用率。再配合top查看进程占用,你会发现一个满载的 CPU 的.load并不等于 1.0,而是接近 0.977 附近的一个值——因为负载是几何衰减的稳态,不是瞬时占空比。
注意,/proc/sched_debug在部分发行版上需要内核开启CONFIG_SCHED_DEBUG。如果节点不存在,另一个更可靠的入口是 ftrace 的sched_load_avg_load事件,下面章节会给具体用法。确认数字在跳动,说明 PELT 在你的内核上是活的,接下来才能放心做实验。
3. PELT 的数学原理与计算路径:从 decay_load 到 __update_load_avg
3.1 指数衰减模型:1024us 周期与半衰期 32ms
PELT 的核心思想是:当前负载 = 上一时刻负载经过时间衰减后留下的部分 + 本周期新增的负载。这里用的是指数加权移动平均,数学形式非常简单:
L(t + delta) = L(t) * y^(delta / period) + delta_contribution其中period取 1024 微秒,也就是约一个调度 tick 的时间长度;y是衰减系数,满足y^32 = 0.5,即每经过 32 个周期(约 32ms),历史负载贡献减半。计算可得y ≈ 0.978544582656。这个 32ms 半衰期是 PELT 对“历史多久算数”的回答:5ms 前发生的事情大约还剩 90% 的贡献,100ms 前的事情只剩约 11%。
这个选择很讲究。半衰期太长,负载反应迟钝,调度器看不到短期的突发,迁移决策滞后;半衰期太短,负载抖动剧烈,一个任务每次睡眠唤醒都会让负载数字大起大落,调度器会频繁做无意义的均衡。32ms 在桌面交互和服务器批量任务之间取了一个平衡点,这也是为什么你会看到 PELT 对毫秒级短任务的“感知”天生迟钝——短任务还没积累起负载就结束了。
3.2 手算一个 load_avg 序列:验证衰减系数
为了消除黑匣子感,我建议你亲手算一遍。假设一个任务每 1024us 增加 100 的负载贡献,初始负载为 0,前四个周期的负载序列如下:
周期1: 0 * y + 100 = 100 周期2: 100 * y + 100 ≈ 100 * 0.97854 + 100 ≈ 197.85 周期3: 197.85 * y + 100 ≈ 293.61 周期4: 293.61 * y + 100 ≈ 387.34如果任务持续跑下去,负载会收敛到一个稳态值:100 * (1 / (1 - y)) ≈ 100 * 46.6 ≈ 4660。注意这里单位不是百分比,而是调度权重相关的内部度量。如果任务是 nice 0,最大权重是 1024,对应满负载稳态正好约 1024,除以 1024 就得到约 0.977——这解释了为什么 /proc/sched_debug 里满载 CPU 的 load 不是 1.0。
代码里decay_load(val, n)就是返回val * y^n,只不过内核用定点数查表法来避免浮点运算,精度上会有细微差异。熟悉了这套手算,你再去看任何讲 PELT 输出结果的资料,心里就有底了:所有数值都不是瞬间值,而是以 32ms 为半衰期的高通滤波结果。
3.3 关键代码路径:enqueue/dequeue/picker 三个入口
PELT 的计算不是孤立发生的,它挂载在三个关键入口上。第一个是任务入队,enqueue_task_fair()里会调用update_load_avg()更新即将入队的 SE 及其父级cfs_rq,随后把该 SE 的load_avg加到cfs_rq上。第二个是任务出队,dequeue_task_fair()对称地做一次更新和扣除。第三个是调度器 tick,即使没有任务切换,task_tick_fair()也会驱动更新,保证空转 CPU 的负载在衰减。
// 去掉锁和统计分支后的核心逻辑示意 static void update_load_avg(struct cfs_rq *cfs_rq, struct sched_entity *se) { u64 now = sched_clock(); u64 delta = now - se->last_update_time; // 限制 delta 不超过一个窗口上限,避免长睡眠后数值爆炸 if (unlikely(delta > 32LL * 1024 * 1024)) delta = 32LL * 1024 * 1024; se->last_update_time = now; // 核心:把 delta 折算成周期数,代入衰减函数累加 __update_load_avg(se->avg, delta); }这段示意代码里最重要的细节是“长睡眠裁剪”:如果任务睡了几秒才醒来,直接拿整段 delta 计算会让负载瞬间飙高,所以内核限制最多只回看约 32ms。这就是为什么长睡唤醒的任务不会让 CPU 负载瞬间爆表——PELT 只承认最近的历史,更早的时间贡献直接作废。参数上,__update_load_avg内部会把 delta 拆成完整周期和零头,完整周期用查表衰减,零头按比例衰减,最终得到一个精度较高的定点数。
4. 用 PELT 做负载均衡:脏数据会骗人,别只看一个值
4.1 load_avg 与 runnable_avg 的语义差异
到了真正的负载均衡现场,只盯着load_avg一个数字会翻车。load_avg统计的是“这个实体曾经在 CPU 上运行和准备运行的总时间”,它包含了阻塞态的等待时间;而runnable_avg只统计“处于可运行状态”的时间。一个任务大部分时间在等 I/O,它的load_avg可能很高,但runnable_avg很低。负载均衡器如果只看load_avg,会觉得这个 CPU 很忙,但实际 CPU 可能大部分时间在 idle 等待。
我在做某跨平台系统的 CPU 分配时遇到过:某个 CPU 的load_avg显示 0.8,但util_avg只有 0.2,因为上面挂了 4 个几乎全阻塞的 I/O 密集型线程。如果调度器依据load_avg做迁移,会把其他 CPU 的任务迁过来,反而制造不必要的 cache miss 和 NUMA 跳变。结论是:做 resize 和迁移决策时,优先看util_avg和runnable_avg的趋势,load_avg更适合做长周期的容量规划。
4.2 调度域里的统计值:CPU 过载判断与迁移方向
CFS 在调度域层做负载均衡时,读取的是sched_group聚合后的统计值。每条路径都会经过update_sg_lb_stats(),它扫描每个 CPU 的cfs_rq,把load_avg累加为 group_load,同时记录 group_util。迁移决策不是看单个值超过阈值,而是看两个 group 之间的差值是否超过sched_group_capacity的一定比例。
我常用的一个判定办法是:当 busiest group 与 local group 的load_avg差值乘以各自容量比例后,超过busy_threshold才触发迁移。busy_threshold的一个经验值是 0.6 左右——低于 0.6 说明两边都还不算过载,迁了反而可能因为 cache 失效增加延迟。这个值不是内核直接暴露的 sysctl,而是通过capacity_margin间接控制,新版本里被改成sched_rr_timeslice和 sysctl_sched_min_granularity 的联动逻辑,需要点进源码才能看到实际算法。
4.3 一个典型现场:为什么负载为 0.8 的 CPU 反而不该被抢占
来看一个具体例子。CPU0 和 CPU1 同属一个调度域,CPU0 上有 6 个线程,每个线程util_avg约 0.15,load_avg约 0.15,合计约 0.9;CPU1 上有 1 个线程,util_avg0.4,load_avg0.4。按直觉,CPU0 应该迁走一个线程到 CPU1。但把线程迁过去之后,CPU1 的原线程和新线程共享 L2,两个线程的竞争导致缓存命中率下降,整体吞吐反而下滑。
内核不会考虑 L2 共享这种细粒度因素,它只看到负载差值。这时手工干预的方式是设置 cpuset 或手动绑定 CPU,把高频线程固定在某个核上,避免调度器按纯负载做迁移。PELT 数字在这里是必要条件,但不是充分条件。表格里给出一个粗略判断矩阵:
| 观察到的组合 | 含义 | 建议动作 |
|---|---|---|
util_avg高、load_avg高 | CPU 真忙,线程在运行 | 可以扩容迁移 |
util_avg低、load_avg高 | 阻塞等待为主的负载 | 迁移无益,看 I/O 路径 |
util_avg高、load_avg低 | 短促高频任务 | 考虑是否能合并或绑核 |
util_avg低、load_avg低 | CPU 空闲 | 不用动 |
4.4 实际读数的落地点:perf sched 与 tracepoint
静态读数之外,我最常用 perf sched 拉一段现场数据,看的是调度延迟而不是负载瞬时值。perf sched record抓几秒数据,然后perf sched latency能看到每个任务的调度延迟分布。延迟高的任务不一定util_avg高,反而可能是runnable_avg高但总被其他短任务抢占。这时候回到 PELT 的runnable_avg,把高延迟任务找出来,看它的兄弟任务是不是都在同一 CPU 上忙等。
我还习惯每 100ms 采样一次/proc/sched_debug里的cpu#N.runnable_avg,绘制成曲线。如果曲线的上升沿总是很陡、下降沿要拖好几秒,说明任务集里有长周期突发任务。这些数据在做容量规划时比单一时刻的load_avg可靠得多。注意采集频率不能太高,100ms 采一次已经能覆盖半衰期 32ms 的绝大部分变化,再密就是浪费日志空间。
5. PELT 避坑与排查:5 个负载追踪的常见翻车现场
5.1 把 load_avg 当 CPU 利用率算扩容,容器配额越调越乱
现象:某开发者按load_avg给容器设置 CPU 请求,结果容器总是被判定为负载过高,频繁触发扩容到两倍规格,账单翻倍,业务却没有任何变快。
原因:load_avg包含权重和阻塞历史,一个等待锁的线程也会维持高load_avg,但实际没有消耗 CPU。按这个数字扩容,本质上是把等待时间当成了计算时间。
解决:改用util_avg或runnable_avg作为扩容依据,而且必须看时间窗内的趋势,不能看某一个瞬间。如果容器内大量线程在等网络响应,util_avg会明显低于load_avg,这时候扩容是无效动作。我一般把util_avg连续 5 分钟超过 80% 才列为扩容候选。
5.2 短任务脉冲在 PELT 里消失不见
现象:一个实时性要求很高的短任务,每 5ms 执行一次、每次 100us,业务侧监测 CPU 占用率约 2%,很健康,但偶发延迟飙到 30ms,跨团队排查无果。
原因:100us 的脉冲放在 32ms 半衰期窗口里,折算到稳态负载增量非常小,PELT 负载曲线根本看不出脉冲的存在。CFS 的调度决策也不会为这种短期脉冲预留 CPU,因为wakeup_preempt_entity用的是 current 和 wakee 的虚拟运行时间差,而不是 PELT 的load_avg。
解决:这种场景不该依赖 CFS 默认行为,需要把短任务绑定到独立 CPU,或用SCHED_FIFO这类实时策略绕过 CFS。排查时也不要再盯着load_avg,改用 ftrace 抓sched_wakeup事件,统计任务从唤醒到真正上 CPU 的间隔,才能定位延迟来源。
5.3 线程被迁移后新 CPU 负载残影:活跃负载该走不走
现象:把一批任务从 CPU0 迁移到 CPU1 之后,CPU1 的load_avg没有立刻升高,而是花了约 100ms 慢慢爬升;CPU0 的load_avg也没有立刻归零,持续衰减了 300ms 才降下来。
原因:PELT 本质是历史加权,迁移只能重置last_update_time,但无法抹掉旧 CPU 上的历史贡献。旧 CPU 上的cfs_rq负载会按半衰期继续衰减,看起来像“残影”。迁移后新 CPU 负载从零开始积累,也受半衰期限制上升缓慢。
解决:这是算法特性,不是 bug。做迁移验证时,至少要观察迁移后 200ms 以上的负载数据,短于这个时间窗口的读数都会误导判断。我自己的习惯是迁移后等 500ms 再采样,至少覆盖 15 个半衰期,负载曲线才接近稳定。
5.4 内核版本差异:sched_avg 字段语义漂移
现象:A 同学照着几个不同时期的内核博客分析/proc/sched_debug,发现有些版本里.load和.util数值对不上,博客作者说的“util 就是 CPU 占用率”在本地跑出来完全不同。
原因:PELT 连同sched_avg结构体一直在演进。早期版本只有load_avg,后来增加了util_avg,再后来拆分出runnable_avg,不同版本里update_load_avg()的调用时机和统计口径都变过。对着旧文章排查新内核,连字段含义都对不上,更别说数值验证。
解决:以源码为准,不要以博客为准。拿到一块内核后先去kernel/sched/fair.c里搜struct sched_avg的定义,确认当前版本每个字段的注释和更新路径。做数据分析时,优先用带字段名的 tracepoint 输出,而不是靠位置解析/proc文本。
5.5 调试手段本身改变了测量结果
现象:挂上 ftrace 的sched_load_avg事件后,trace 文件每秒产生几十万行记录,抓取 10 秒就多占用了 2 个 CPU 核,原本的负载曲线被系统自身的 ftrace 开销拉高。
原因:PELT 的 tracepoint 在每次update_load_avg()被调用时触发,一个繁忙系统每秒调用数万次,写入 trace ring buffer 本身就要消耗 CPU,形成测量干扰。
解决:降低采样频率,不要全程开着。我是先设一个 3 秒的时间窗,用tracing_on控制只抓窗口内的数据;如果只需要观测长期曲线,用/proc/sched_debug每 100ms 读一次就足够,不必开 tracepoint。碰到可疑窗口再开事件做深挖,抓完立刻关闭。
6. 沿着 PELT 继续向下挖:验证负载曲线与两个进阶方向
6.1 用 tracepoint 抓 load_avg 变化序列
先做一次完整的事件捕获,把 PELT 的数值变化原原本本拉出来:
cd /sys/kernel/debug/tracing echo 0 > tracing_on echo > trace echo 1 > events/sched/sched_load_avg_load/enable echo 1 > tracing_on sleep 2 echo 0 > tracing_on cat trace | grep -E 'cpu#[0-9]+' | head -30输出行里能看到每个 CPU 的cfs_rq负载变化记录。字段包括时间戳、CPU 编号、实体地址、load与util值。用 awk 把同一条 CPU 的连续记录抽出来,就能画出一条负载爬升和衰减的曲线。对照第 3 章的手算结果,你会发现内核输出和理论稳态值几乎一致,误差只在定点数舍入的范围内。
6.2 用 Python 验证衰减曲线与内核行为对齐
抄作业阶段,我写了一个最小模拟器,复现单个调度实体的衰减过程:
import math Y = 0.978544582656 PERIOD_US = 1024 def pelT_simulate(events_us, initial=0): load = initial last_t = 0 out = [] for t in events_us: delta = t - last_t periods = delta / PERIOD_US load = load * (Y ** periods) out.append(load) last_t = t load += 1024 # 假设期间满了 1024 权重,可替换成真实贡献值 return out events = [0, 5000, 10000, 20000, 40000, 80000] print([round(x, 2) for x in pelT_simulate(events)])这段代码用浮点逼近内核的定点逻辑。运行后会看到负载在事件点衰减然后跳升,趋势和 tracepoint 抓出来的一致。Y ** periods对应内核的decay_load查表;load += 1024是对“满负载运行一个周期”的模拟,实际应替换成该实体当前的调度权重乘以运行比例。用这个脚本可以快速试不同的半衰期参数,看负载曲线对任务的敏感度变化,不用每次都在内核上做实验。
6.3 往里走的两个方向:从负载追踪到调度器演进
验证完 PELT 曲线,再往下就是两个很有意思的方向。第一个是 EEVDF,新版调度器演进中对虚拟运行时间的重新定义——它不是抛弃负载追踪,而是把负载追踪的粒度进一步细化到延迟感知,让调度决策不再只依赖过去的负载,也依赖未来的截止时间;第二个是 EAS,能量感知调度,它大量消费 PELT 里的util_avg,结合 CPU 的容量模型和功耗模型,决定是否把任务迁移到能效更高的核上。这两个方向都建立在理解衰减序列的基础上,只是消费负载值时做了更细的建模。
我自己的习惯是:拿到一个新内核,第一件事不是开/proc/sched_debug读静态数值,而是先挂 tracepoint 拉一段负载曲线,再拿模拟器重放一遍,确认两边对得上,再去调参数。盲目信某个数字,是最容易翻车的做法。希望帮到你。
本文还有配套的精品资源,点击获取