文章目录
- [1. 为什么需要负载均衡?](#1. 为什么需要负载均衡?)
- [2. 硬件基础:CPU 拓扑与 capacity](#2. 硬件基础:CPU 拓扑与 capacity)
-
- [2.1 物理核 vs 逻辑 CPU](#2.1 物理核 vs 逻辑 CPU)
- [2.2 CPU capacity](#2.2 CPU capacity)
- [3. sched_domain / sched_group: CPU 硬件拓扑的软件抽象](#3. sched_domain / sched_group: CPU 硬件拓扑的软件抽象)
-
- [3.1 结构定义](#3.1 结构定义)
- [3.2 数据关系](#3.2 数据关系)
- [3.3 拓扑举例](#3.3 拓扑举例)
- [3.4 调度域的建立过程](#3.4 调度域的建立过程)
- [3.5 CPU rq 的 domain (rq->sd) 设定](#3.5 CPU rq 的 domain (rq->sd) 设定)
- [4. 负载统计](#4. 负载统计)
-
- [4.1 CPU load VS util](#4.1 CPU load VS util)
- [4.2 统计维护](#4.2 统计维护)
-
- [4.2.1 PELT 核心:先衰减历史,再加入新贡献](#4.2.1 PELT 核心:先衰减历史,再加入新贡献)
- [4.2.2 PELT 在哪些路径上被触发](#4.2.2 PELT 在哪些路径上被触发)
- [4.3 统计汇总:从 per-CPU 的 avg 到 group 级 sg_lb_stats](#4.3 统计汇总:从 per-CPU 的 avg 到 group 级 sg_lb_stats)
- [5. 什么时候触发均衡](#5. 什么时候触发均衡)
-
- [5.1 周期性均衡:从 tick 到 softirq](#5.1 周期性均衡:从 tick 到 softirq)
- [5.2 新空闲均衡:CPU 要进 idle 前拉任务](#5.2 新空闲均衡:CPU 要进 idle 前拉任务)
- [5.3 两条路径汇合到 load_balance()](#5.3 两条路径汇合到 load_balance())
- [6. 核心算法:找繁忙组、算迁移量](#6. 核心算法:找繁忙组、算迁移量)
-
- [6.1 load_balance() 主干](#6.1 load_balance() 主干)
- [6.2 find_busiest_group():判断是否失衡、哪个组作为迁移来源](#6.2 find_busiest_group():判断是否失衡、哪个组作为迁移来源)
- [6.3 calculate_imbalance():计算迁移量](#6.3 calculate_imbalance():计算迁移量)
- [7. 执行任务迁移](#7. 执行任务迁移)
-
- [7.1 find_busiest_queue():调度组内选迁移源 CPU](#7.1 find_busiest_queue():调度组内选迁移源 CPU)
- [7.2 detach_tasks() / attach_tasks():任务迁出、迁入](#7.2 detach_tasks() / attach_tasks():任务迁出、迁入)
- [7.3 决定迁移 ≠ 一定迁移成功](#7.3 决定迁移 ≠ 一定迁移成功)
- [8. 总结](#8. 总结)
本文分析 Linux CFS 负载均衡,源码以开源的
linux-sunxi-4.14.y(VERSION=4 PATCHLEVEL=14 SUBLEVEL=111)为准。文中代码片段直接摘自该源码树,只做了删减(略去统计计数schedstat_*、NUMA 分支、与主干无关的错误处理),不改写逻辑。另外,本文只分析对称多处理(SMP)的普通多核,不展开 NUMA。
1. 为什么需要负载均衡?
假设一台四核设备上,CPU0 跑着几个持续计算的任务,CPU1 的任务经常睡眠,CPU2、CPU3 偶尔空闲。把 CPU0 的部分任务挪到空闲 CPU 上,整体吞吐可能更高,个别任务的等待时间也更短。这就是负载均衡要做的事:各 CPU 忙闲不均时,调度器把可迁移的任务从忙的一侧挪到闲的一侧。
负载均衡的目标并不是简单的使"每个 CPU 任务数相同":
- 任务对 CPU 的需求不同。一个持续计算的任务和一个经常睡眠的任务,不能按个数等价。
- CPU 的能力(capacity)可能不同。即使承载相似任务,大核和小核完成的工作量也不同。
- 任务迁移是有代价的。任务换核后,原核上的 cache 局部性丢失。
- 任务不一定能迁移。CPU affinity、cpuset、CPU online 状态都会限制可运行的 CPU 集合。
- 负载是估计值。算法依赖过去一段时间的统计数据,无法预知任务未来行为。
因而内核不能简单地比较 nr_running,也不应追求使 CPU 利用率相等。负载均衡系统通过硬件拓扑给出的层级结构,从中搜集负载数据统计,然后判断是否应该从某个调度组拉取负载,并计算出一个负载迁移量,最后去挑选要迁移的任务。
负载均衡流程梗概如下:
text
硬件拓扑 / CPU capacity
│
▼
build_sched_domains() 建立 sched_domain、sched_group,rq->sd 连接
│
▼
PELT 与 rq 负载状态在运行/入队/出队路径上持续更新 - ___update_load_avg
│
▼
scheduler_tick / idle 触发一次负载均衡
│
▼
update_sd_lb_stats 汇总各组 → find_busiest_group 选繁忙组 → calculate_imbalance 算迁移量
│
▼
find_busiest_queue 选源 CPU → detach_tasks / attach_tasks 迁移
后文将按这条链路从上到下一一展开。
2. 硬件基础:CPU 拓扑与 capacity
2.1 物理核 vs 逻辑 CPU
调度器按逻辑 CPU 编号管理可运行任务。一个逻辑 CPU 通常对应一个硬件执行上下文;开了 SMT(Simultaneous Multithreading) 的处理器上,一个物理核会暴露多个逻辑 CPU;多个物理核之间又可能共享某级缓存。这些关系决定了任务迁移代价:同核的 SMT 线程竞争执行资源,跨核迁移要重建缓存局部性,跨共享缓存域迁移代价更高。调度器通过 sched_domain 层级和 domain flags,让不同范围的均衡采用不同策略。
2.2 CPU capacity
CPU capacity 表示 CPU 能提供的相对计算能力,SCHED_CAPACITY_SCALE(1024)是单个 CPU 的满额基准。理解负载均衡时要分清三类量:
- 原始 CPU capacity:CPU 的能力基准(
rq->cpu_capacity_orig)。 - 调度组 capacity:
sched_group_capacity::capacity,由调度组内 CPU 能力汇总。 - load / util:分别用来描述任务需求(load)或 CPU 使用情况(util)的统计数据。
举个例子,AllWinner T507 有 4 个 A53、同簇、无 SMT,各 CPU 的 capacity 一致(均接近 1024)。所以 T507 上的负载均衡主要看 load 和 util,capacity 不构成区分因素------这点和大小核平台不同,因为所有 CPU 的 capacity 都一样。
3. sched_domain / sched_group: CPU 硬件拓扑的软件抽象
3.1 结构定义
sched_domain 表示某个 CPU 集合上的一层调度域,定义在 include/linux/sched/topology.h(只摘录关键字段):
c
/* include/linux/sched/topology.h */
struct sched_domain {
/* These fields must be setup */
struct sched_domain *parent; /* top domain must be null terminated */
struct sched_domain *child; /* bottom domain must be null terminated */
struct sched_group *groups; /* the balancing groups of the domain */
unsigned long min_interval; /* Minimum balance interval ms */
unsigned long max_interval; /* Maximum balance interval ms */
unsigned int busy_factor; /* less balancing by factor if busy */
unsigned int imbalance_pct; /* No balance until over watermark */
unsigned int cache_nice_tries; /* Leave cache hot tasks for # tries */
/* ... busy_idx/idle_idx 等负载索引、flags、level ... */
int flags; /* See SD_* */
int level;
unsigned long last_balance; /* init to jiffies. units in jiffies */
unsigned int balance_interval; /* initialise to 1. units in ms. */
unsigned int nr_balance_failed; /* initialise to 0 */
/* ... */
unsigned int span_weight;
unsigned long span[0]; /* domain 覆盖的 CPU 集合,变长数组 */
};
static inline struct cpumask *sched_domain_span(struct sched_domain *sd)
{
return to_cpumask(sd->span);
}
sched_group 和 sched_group_capacity 定义在 kernel/sched/sched.h:
c
/* kernel/sched/sched.h */
struct sched_group_capacity {
atomic_t ref;
unsigned long capacity; /* 组的汇总 capacity,SCHED_CAPACITY_SCALE 为单 CPU 满额 */
unsigned long min_capacity; /* Min per-CPU capacity in group */
unsigned long next_update;
int imbalance; /* 组级不平衡标记(与 capacity 无关,复用此结构) */
unsigned long cpumask[0]; /* balance mask */
};
struct sched_group {
struct sched_group *next; /* Must be a circular list */
atomic_t ref;
unsigned int group_weight;
struct sched_group_capacity *sgc;
int asym_prefer_cpu; /* cpu of highest priority in group */
unsigned long cpumask[0]; /* 该 group 覆盖的 CPU 集合,变长数组 */
};
static inline struct cpumask *sched_group_span(struct sched_group *sg)
{
return to_cpumask(sg->cpumask);
}
两处 [0] 变长数组都是把 cpumask 附加在结构体尾部,按启动 CPU 数动态分配。
3.2 数据关系
把上面数据结构字段连起来,就是负载均衡数据结构的骨架:
rq->sd指向最底层、也即覆盖 CPU 范围最小的那层调度域(sched_domain)------这层调度域只包含和该 CPU 距离最近的几个 CPU(比如同一物理核的 SMT 兄弟 CPU,或同一簇共享缓存的几个核)。负载均衡时从这里出发,沿sd->parent逐层扩大比较范围。sd->parent向上指向更大范围(即包含更多 CPU 集合)的 domain,sd->child指向下层更小范围(即包含更少 CPU 集合)的 domain 走;最顶层 domain 的parent==NULL,最底层 domain 的child==NULL。domain 的parent和child各自形成了两条单向链表 :由底往上方向从 CPU 的rq->sd出发,一路沿parent走到最顶层 domain;由顶往下方向则从顶层 domain 沿着child一路往下,直到 CPU 的rq->sd结束。这两条路径都是 per-CPU 的,这和 per-CPU 的 rq 是一一对应的。sd->groups指向本层 domain 的 group 链表,group 之间用sg->next连成循环链表。sched_domain_span(sd)是 domain 覆盖的 CPU 集合;sched_group_span(sg)是一个 group 覆盖的 CPU 集合。一个 group 的 span 等于它下一层 domain 的 span。sg->sgc指向该sched_group的 capacity 结构(即sched_group_capacity),负载均衡时用它判断组是否过载。
负载均衡核心数据结构的一般性关系如下图。顶层 domain 把 CPU 划成较大的 group,底层 domain 按单 CPU 分组;每个 CPU 的 rq->sd 挂在它自己的底层 domain 上。
3.3 拓扑举例
AllWinner T507 是四核 Cortex-A53、单簇、共享 L2、无 SMT、非大小核。这种拓扑通常只建立一层 MC(multi-core)调度域:span 覆盖 CPU0~CPU3,sd->groups 指向四个 group,每个group 覆盖一个 CPU;没有 SMT 下层,也没有跨簇上层,parent 和 child 都是 NULL。
这张图是结构示意,实际层级、group span、flags 由内核配置、CPU topology 回调 / 设备树、online CPU、cpuset 分区决定。在目标板上核实:
bash
# 物理拓扑
for c in /sys/devices/system/cpu/cpu[0-9]*; do
echo "== $c =="
cat "$c/topology/physical_package_id" "$c/topology/core_id" \
"$c/cache/index2/shared_cpu_list" 2>/dev/null
done
# 启用 CONFIG_SCHED_DEBUG 后可查调度域信息
cat /proc/schedstat
3.4 调度域的建立过程
调度域的建立过程主干逻辑在 kernel/sched/topology.c 的 build_sched_domains()。下面是实现代码(略去分配状态封装 __visit_domain_allocation_hell() 的细节和 debug 打印):
c
kernel_init_freeable()
// 解析系统 CPU 拓扑到 cpu_topology[NR_CPUS]
smp_prepare_cpus()
init_cpu_topology()
// 逐个启动所有非 BOOT CPU
smp_init();
// 设置 CPU rq 的 sched_domain
sched_init_smp()
sched_init_domains()
build_sched_domains()
c
/* kernel/sched/topology.c */
static int
build_sched_domains(const struct cpumask *cpu_map, struct sched_domain_attr *attr)
{
enum s_alloc alloc_state;
struct sched_domain *sd;
struct s_data d;
struct rq *rq = NULL;
int i, ret = -ENOMEM;
alloc_state = __visit_domain_allocation_hell(&d, cpu_map);
if (alloc_state != sa_rootdomain)
goto error;
/*
* 1. 按 topology level 为每个 CPU 建立 domain 层级,
* sched_domain 是 per-CPU 的,所以必须逐 CPU 建。
*/
for_each_cpu(i, cpu_map) {
struct sched_domain_topology_level *tl;
sd = NULL;
for_each_sd_topology(tl) {
sd = build_sched_domain(tl, cpu_map, attr, sd, i);
if (tl == sched_domain_topology)
*per_cpu_ptr(d.sd, i) = sd; /* 记下最底层 domain */
if (tl->flags & SDTL_OVERLAP)
sd->flags |= SD_OVERLAP;
if (cpumask_equal(cpu_map, sched_domain_span(sd)))
break; /* 已覆盖全部 CPU,不再往上建 */
}
}
/* 2. 为每层 domain 建立 sched_group */
for_each_cpu(i, cpu_map) {
for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) {
sd->span_weight = cpumask_weight(sched_domain_span(sd));
if (sd->flags & SD_OVERLAP) {
if (build_overlap_sched_groups(sd, i))
goto error;
} else {
if (build_sched_groups(sd, i))
goto error;
}
}
}
/* 3. 初始化 group capacity(从高层到低层) */
for (i = nr_cpumask_bits-1; i >= 0; i--) {
if (!cpumask_test_cpu(i, cpu_map))
continue;
for (sd = *per_cpu_ptr(d.sd, i); sd; sd = sd->parent) {
claim_allocations(i, sd);
init_sched_groups_capacity(i, sd);
}
}
/* 4. 把 domain 链挂接到各 CPU 的 rq */
rcu_read_lock();
for_each_cpu(i, cpu_map) {
rq = cpu_rq(i);
sd = *per_cpu_ptr(d.sd, i);
if (rq->cpu_capacity_orig > READ_ONCE(d.rd->max_cpu_capacity))
WRITE_ONCE(d.rd->max_cpu_capacity, rq->cpu_capacity_orig);
cpu_attach_domain(sd, d.rd, i);
}
rcu_read_unlock();
ret = 0;
error:
__free_domain_allocs(&d, alloc_state, cpu_map);
return ret;
}
四个阶段对应得很清楚:逐 CPU 逐层建 domain → 逐层建 group → 算 group capacity → 把 domain 挂到 rq。for_each_sd_topology(tl) 遍历架构给出的 topology level 数组(ARM 平台见 arch/arm64/kernel/topology.c),所以有几层 domain 取决于平台的topology level 定义,像 AllWinner T507 这种单簇四核只有一层 MC。
3.5 CPU rq 的 domain (rq->sd) 设定
上面 build_sched_domains() 注释的阶段 4 调用 cpu_attach_domain() 设置了 CPU rq 的 domain (rq->sd)。它先删掉"没用的层",再把 domain 链挂到 rq。
什么叫"没用的层":一层 sched_domain 存在的意义,是在它管的 CPU 范围内把 CPU 分成多个 sched_group 来比较、迁移。满足下面任一条就说明这层起不到这个作用,可以删:
- 这层只管一个 CPU(span 里只有一个 CPU),没有可比较的对象;
- 这层只有一个 group(
sd->groups == sd->groups->next,循环链只有一个节点),同样没法在组间比较; - 这层和它的上一层管的 CPU 范围、分组都一样,上一层相对这一层没多出任何东西。
前两种由 sd_degenerate() 判断,第三种(上下层重复)由 sd_parent_degenerate() 判断。比如 AllWinner T507 四核 A53 没有 SMT,topology 模板里的 SMT 层每个只管一个 CPU,就会被删掉,最后只剩 MC 一层------这就是前面 AllWinner T507 图里只画一层的原因。
你可能会想,删掉退化层是不是可有可无的优化?答案是必须这么做。因为每多一层,负载均衡时 rebalance_domains() 逐层向上就多遍历、多比较一次,这会带来不必要的开销。优化清理后再把精简的 domain 链挂到 rq:
c
/* kernel/sched/topology.c,cpu_attach_domain() 末尾 */
static void
cpu_attach_domain(struct sched_domain *sd, struct root_domain *rd, int cpu)
{
...
sched_domain_debug(sd, cpu);
rq_attach_root(rq, rd);
tmp = rq->sd;
rcu_assign_pointer(rq->sd, sd); /* 这里设置 CPU 的 rq->sd */
dirty_sched_domain_sysctl(cpu);
destroy_sched_domains(tmp); /* 释放旧的 domain 链 */
update_top_cache_domain(cpu);
}
由上可见,per-CPU 的 rq->sd 是在调度域建立/重建时用 rcu_assign_pointer() 挂接。调度域(sched_domain)会在系统初始化、CPU hotplug、cpuset 分区变化时重建,所以 rq->sd 是运行时可更新的 RCU 指针,读它要在 rcu_read_lock() 下,或用 for_each_domain() 这类封装。负载均衡运行时就从当前 CPU 的 rq->sd 起,沿 sd->parent 逐层向上。
4. 负载统计
负载均衡的决策建立在一组统计数据上。统计分为两个动作:
- 统计维护:是任务运行、入队(如唤醒、优先级变更、调度策略改变等)、出队(阻塞睡眠等)时持续更新这些数据。
- 统计汇总:是某次均衡时把 CPU 级信号聚合成 group 级状态。
前者的入口 分散在各条调度路径上(update_curr()、enqueue_entity()、dequeue_entity()、scheduler_tick() 等),但更新 PELT 时都调到 update_load_avg(),再收敛到 __update_load_avg_se()(更新 sched_entity)或 __update_load_avg_cfs_rq()(更新 cfs_rq),二者底层都是公共计算核心 ___update_load_avg()(三下划线)。后者的汇总则集中在 update_sd_lb_stats()。也就是说,维护侧的实际计算收敛到 ___update_load_avg(),汇总侧收敛到 update_sd_lb_stats(),一边一个汇聚点。
4.1 CPU load VS util
load_avg:加权的 runnable 需求估计,受任务权重(nice 值)影响。util_avg:对 CPU 实际使用需求的估计,主要反映任务处于 running 的时间。nr_running:运行队列里的可运行任务数,反应对 CPU 的运行需求。group_capacity:组内 CPU 能力的汇总基准。
两个都持续满负荷运行的 CPU 密集任务,如果 nice 值不同,对 load 的贡献不同,但对 util 的贡献接近。所以 load 和 util 有各自的用途:均衡比较负载用 load,判断组是否过载、CPU 是否还有余量时才看 util 与 capacity 的关系。
Linux 4.14.111 的 struct sched_avg(include/linux/sched.h):
c
struct sched_avg {
u64 last_update_time;
u64 load_sum;
u32 util_sum;
u32 period_contrib;
unsigned long load_avg;
unsigned long util_avg;
};
注意这版没有后来高版本内核的 runnable_load_sum/runnable_load_avg 字段(Linux 4.14 里 runnable load 以 cfs_rq::runnable_load_sum 等单独维护,不在 sched_avg 内)。
4.2 统计维护
统计维护负责把 load、util 这些数据随任务运行实时算出来并存到各 CPU 的 cfs_rq->avg 上。我们先看看 PELT 的计算,再看它在哪些调度路径上被触发。
4.2.1 PELT 核心:先衰减历史,再加入新贡献
PELT(Per-Entity Load Tracking)把时间切成 1024us(约 1ms)的周期,用几何级数对历史贡献做衰减:越近的活动权重越大,越早的越衰减。核心在 kernel/sched/fair.c 的 accumulate_sum():
c
/* kernel/sched/fair.c,accumulate_sum() 主体 */
static __always_inline u32
accumulate_sum(u64 delta, int cpu, struct sched_avg *sa,
unsigned long weight, int running, struct cfs_rq *cfs_rq)
{
...
delta += sa->period_contrib; /* 接上上次不足一个周期的残余 */
periods = delta / 1024; /* 跨过的完整 1024us 周期数 */
if (periods) {
/* Step 1:历史 *_sum 按周期数衰减 load_sum *= y^periods */
sa->load_sum = decay_load(sa->load_sum, periods);
if (cfs_rq)
cfs_rq->runnable_load_sum =
decay_load(cfs_rq->runnable_load_sum, periods);
sa->util_sum = decay_load((u64)(sa->util_sum), periods);
/* Step 2:把这段时间拆成 残余d1 + 完整周期d2 + 最新残余d3 的贡献 */
delta %= 1024;
contrib = __accumulate_pelt_segments(periods,
1024 - sa->period_contrib, delta);
}
sa->period_contrib = delta; /* 记下残余,留给下次 */
contrib = cap_scale(contrib, scale_freq);
if (weight) {
sa->load_sum += weight * contrib; /* 加入本次贡献(按权重) */
if (cfs_rq)
cfs_rq->runnable_load_sum += weight * contrib;
}
if (running)
sa->util_sum += contrib * scale_cpu; /* 加入本次贡献(按 running) */
return periods;
}
accumulate_sum() 维护的是内部累计量 load_sum / runnable_load_sum / util_sum:load_sum 的输入乘了 weight(受任务权重影响),util_sum 只在 running 时累加(反映 CPU 实际占用量),都会先经 decay_load() 衰减旧值再加新贡献。它的上层调用 ___update_load_avg() 再把这三个累计量归一化成调度器直接用的三个平均值:
c
/* kernel/sched/fair.c,___update_load_avg() 末尾的归一化 */
static __always_inline int
___update_load_avg(u64 now, int cpu, struct sched_avg *sa,
unsigned long weight, int running, struct cfs_rq *cfs_rq)
{
...
sa->load_avg = div_u64(sa->load_sum,
LOAD_AVG_MAX - 1024 + sa->period_contrib);
if (cfs_rq) {
cfs_rq->runnable_load_avg =
div_u64(cfs_rq->runnable_load_sum,
LOAD_AVG_MAX - 1024 + sa->period_contrib);
}
sa->util_avg = sa->util_sum /
(LOAD_AVG_MAX - 1024 + sa->period_contrib);
...
}
维护侧每次更新最终产出三个量,含义不同,汇总侧(§4.3)会分别用到:
sa->load_avg:调度实体或队列的整体负载,包含已睡任务的阻塞态衰减部分。cfs_rq->runnable_load_avg:只累加当前 runnable 调度实体的负载,不含已睡任务。sa->util_avg:CPU 实际被占用的估计,只在任务 running 时累加。
4.2.2 PELT 在哪些路径上被触发
PELT 的更新入口分散在调度路径各处,但都经 update_load_avg() 收敛到上面的 ___update_load_avg()。下面是几类代表场景。
场景一:tick 等调度点。
scheduler_tick() 走到 CFS 的 task_tick_fair() → entity_tick(),由 entity_tick() 调 update_load_avg(curr, UPDATE_TG) 推进当前实体的 PELT。
场景二:任务入队 / 出队。
任务变 runnable、被唤醒、睡眠或离开队列时,走 enqueue_task_fair() → enqueue_entity() 或 dequeue_task_fair() → dequeue_entity(),内部都会调 update_load_avg()。迁移任务也走这条路:先 deactivate_task() 从源队列出队,再在目标队列入队,PELT 贡献随之从源 cfs_rq 转到目标 cfs_rq,所以迁移不仅仅修改 task->cpu。另外,改优先级、改调度策略、唤醒、睡眠、迁移、加入或退出 cgroup 这些操作,内核大多靠"先 dequeue_task 再 enqueue_task "实现,所以都走入队/出队这条路,并非各有一条独立的更新路径。
场景三:负载均衡前,刷新全部任务已睡、无人触发更新的 cfs_rq ------
update_blocked_averages()。
场景一、二只在有任务运行、入队、出队时更新统计。但一个任务睡眠后会从 cfs_rq 出队,它过去积累的 PELT 负载并不立刻清零,而是继续记在 cfs_rq->avg 里、按 PELT 规则逐周期衰减------这部分叫阻塞态负载(blocked load)。问题在于:如果一个 cfs_rq 上的任务全睡了,就再没有 enqueue/dequeue/tick 事件来触发它更新,cfs_rq->avg 会停在最后一次更新的值,不再随时间衰减。
负载均衡要拿这个值做决策,读到一个没有衰减、偏大的旧值就会误判。所以周期性做负载均衡(rebalance_domains())和 CPU 即将进入空闲做负载均衡(idle_balance())时、寻找最繁忙的调度组之前,都先调用 update_blocked_averages(cpu),遍历本 CPU 上所有 cfs_rq(根 cfs_rq 和各 task group 的子 cfs_rq),按已经过去的时间把它们的 PELT 补算到当前,再开始负载均衡。
4.3 统计汇总:从 per-CPU 的 avg 到 group 级 sg_lb_stats
一次负载均衡开始时,update_sd_lb_stats() 遍历当前 domain 的每个 group,对每个 group 调用 update_sg_lb_stats() 把组内各 CPU 的负载状态汇总成 sg_lb_stats。核心是遍历 sched_group_span(group) 中参与本次均衡的 CPU 求和:
c
/* kernel/sched/fair.c,update_sg_lb_stats() 的汇总循环 */
static inline void update_sg_lb_stats(struct lb_env *env,
struct sched_group *group, int load_idx,
int local_group, struct sg_lb_stats *sgs,
bool *overload)
{
...
memset(sgs, 0, sizeof(*sgs));
for_each_cpu_and(i, sched_group_span(group), env->cpus) {
struct rq *rq = cpu_rq(i);
/* local group 用 target_load(偏向本地),其余用 source_load */
if (local_group)
load = target_load(i, load_idx);
else
load = source_load(i, load_idx);
sgs->group_load += load; /* 组负载累加 */
sgs->group_util += cpu_util(i); /* 组利用率累加 */
sgs->sum_nr_running += rq->cfs.h_nr_running;
nr_running = rq->nr_running;
if (nr_running > 1)
*overload = true;
sgs->sum_weighted_load += weighted_cpuload(rq);
if (!nr_running && idle_cpu(i))
sgs->idle_cpus++; /* 统计空闲 CPU 数 */
}
/* 组的 capacity、avg_load、group_type 等由后续代码据上面的和算出 */
...
}
这段里的 group_load、group_util、sum_weighted_load,正是把 §4.2 维护出来的三个 per-CPU avg 读上来累加的。顺着取值函数往下看就能看清对应关系。
group_load 经 source_load() / target_load(),二者都取自 weighted_cpuload(),而它返回的是 runnable_load_avg (不是 load_avg):
c
/* kernel/sched/fair.c */
static unsigned long weighted_cpuload(struct rq *rq)
{
return cfs_rq_runnable_load_avg(&rq->cfs); /* = rq->cfs.runnable_load_avg */
}
static unsigned long source_load(int cpu, int type)
{
struct rq *rq = cpu_rq(cpu);
unsigned long total = weighted_cpuload(rq);
if (type == 0 || !sched_feat(LB_BIAS))
return total;
return min(rq->cpu_load[type-1], total); /* source 取较小者,偏保守 */
}
/* target_load() 同理,只是把 min 换成 max */
group_util 经 cpu_util(),取的是 util_avg,再按 CPU capacity 封顶:
c
/* kernel/sched/fair.c */
static int cpu_util(int cpu)
{
unsigned long util = cpu_rq(cpu)->cfs.avg.util_avg; /* = sa->util_avg */
unsigned long capacity = capacity_orig_of(cpu);
return (util >= capacity) ? capacity : util;
}
据此理清维护侧三个量 cfs_rq->runnable_load_avg、sa->load_avg、sa->util_avg 到汇总侧的去向:
runnable_load_avg→weighted_cpuload()→source_load()/target_load()→group_load、sum_weighted_load。group_load是组间比较负载大小的主依据。之所以用runnable_load_avg而不是load_avg,是因为均衡要搬的是 runnable 任务,不该把已睡任务的阻塞态负载算进来。util_avg→cpu_util()→group_util。group_util不是比较负载用的,而是喂给group_has_capacity()(group_capacity * 100 > group_util * imbalance_pct,判断组还有余量)和group_is_overloaded()(判断组过载)。这两个判断定出 group 的group_type,find_busiest_group()据此决定某个组能否任务当迁移来源、优先级多高。load_avg不直接进group_load,但不是没用:一个 runnable 实体入队时,它的load_avg被累加进所在 cfs_rq 的runnable_load_avg(cfs_rq->runnable_load_avg += sa->load_avg),出队时减掉。所以load_avg是runnable_load_avg的"原料",间接影响均衡比较;此外它还汇成tg->load_avg(update_tg_load_avg()),供 task group 按权重分配份额。
另外,source_load()/target_load() 里的 rq->cpu_load[type-1],它是对 weighted_cpuload()(即 cfs_rq->runnable_load_avg)做的多级时间平滑。__cpu_load_update()(tick 路径的 cpu_load_update_active() 调用)把 cpu_load[0] 设为当前的 weighted_cpuload,再按 cpu_load[i] = (old*(scale-1) + new) >> i(scale = 2^i)算出 cpu_load[1..4]------i 越大,平滑窗口越长、反应越慢。source_load/target_load 的 type(由 update_sg_lb_stats() 的 load_idx 传入)就是选第几档平滑:不同 idle 状态选不同档位,用更平滑的历史值抑制负载抖动。
update_sg_lb_stats() 消费的是已经维护在各 rq 上的 avg,不重新从零计算任务历史。local_group 用 target_load(取当前值与历史负载的较大者,偏保守,倾向不把任务拉过来)、非 local 用 source_load(取较小者,倾向不把任务推出去),这组非对称偏置让均衡不至于来回抖动。汇总结果最终交给 find_busiest_group() 比较。
5. 什么时候触发均衡
5.1 周期性均衡:从 tick 到 softirq
scheduler_tick() 每个 tick 调一次 trigger_load_balance()(kernel/sched/fair.c),到期就抛 SCHED_SOFTIRQ:
c
/* kernel/sched/fair.c */
void trigger_load_balance(struct rq *rq)
{
/* 运行队列不属于任何调度域(NULL domain),不做均衡 */
if (unlikely(on_null_domain(rq)))
return;
if (time_after_eq(jiffies, rq->next_balance)) /* 到了均衡检测时间点 */
raise_softirq(SCHED_SOFTIRQ);
#ifdef CONFIG_NO_HZ_COMMON
if (nohz_kick_needed(rq))
nohz_balancer_kick();
#endif
}
SCHED_SOFTIRQ 类型 softirq 的处理函数是 run_rebalance_domains():
c
/* kernel/sched/fair.c */
static __latent_entropy void run_rebalance_domains(struct softirq_action *h)
{
struct rq *this_rq = this_rq();
enum cpu_idle_type idle = this_rq->idle_balance ?
CPU_IDLE : CPU_NOT_IDLE;
nohz_idle_balance(this_rq, idle); /* 代替停了 tick 的 idle CPU 做均衡 */
rebalance_domains(this_rq, idle);
}
rebalance_domains() 从当前 CPU 的 rq->sd 起沿 sd->parent 逐层向上,对每层按 sd->balance_interval 判断是否到期,到期才调 load_balance():
c
/* kernel/sched/fair.c,rebalance_domains() 主干 */
static void rebalance_domains(struct rq *rq, enum cpu_idle_type idle)
{
...
for_each_domain(cpu, sd) {
/* ... 省略 continue_balancing、need_serialize 等判断 ... */
interval = get_sd_balance_interval(sd, idle != CPU_IDLE);
if (time_after_eq(jiffies, sd->last_balance + interval)) {
if (load_balance(cpu, rq, sd, idle, &continue_balancing)) {
idle = idle_cpu(cpu) ? CPU_IDLE : CPU_NOT_IDLE;
}
sd->last_balance = jiffies;
interval = get_sd_balance_interval(sd, idle != CPU_IDLE);
}
}
...
}
因此调度 tick 本身不做负载均衡,只负责触发负载均衡;是否真迁移要看后续判断。
5.2 新空闲均衡:CPU 要进 idle 前拉任务
当前 CPU 要进 idle 时,pick_next_task_fair() 发现没有可运行的 CFS 任务,会调用 idle_balance(),尝试在睡下前从别的 CPU 拉任务过来,避免一边空闲一边积压。idle_balance() 以 CPU_NEWLY_IDLE 调 load_balance(),SD_BALANCE_NEWIDLE flag 决定某层 domain 是否允许这种均衡。
5.3 两条路径汇合到 load_balance()
无论周期性还是新空闲,核心都是在某层 sched_domain 上执行:
text
load_balance()
├─ should_we_balance() 当前 CPU 是否该做本层均衡
├─ find_busiest_group() 选繁忙组;内部触发 calculate_imbalance()
├─ find_busiest_queue() 组内选源 CPU
├─ detach_tasks() 从源 rq 摘任务
└─ attach_tasks() 挂到目标 rq
6. 核心算法:找繁忙组、算迁移量
6.1 load_balance() 主干
load_balance() 的主干(略去 active balance、LBF_DST_PINNED 重试、锁细节):
c
/* kernel/sched/fair.c,load_balance() 主干 */
static int load_balance(int this_cpu, struct rq *this_rq,
struct sched_domain *sd, enum cpu_idle_type idle,
int *continue_balancing)
{
...
redo:
if (!should_we_balance(&env)) { /* 本 CPU 不该做本层均衡 */
*continue_balancing = 0;
goto out_balanced;
}
group = find_busiest_group(&env); /* 找最繁忙的 sched_group */
if (!group)
goto out_balanced;
busiest = find_busiest_queue(&env, group); /* 组内找最繁忙的 rq */
if (!busiest)
goto out_balanced;
env.src_cpu = busiest->cpu;
env.src_rq = busiest;
ld_moved = 0;
if (busiest->nr_running > 1) {
env.flags |= LBF_ALL_PINNED;
env.loop_max = min(sysctl_sched_nr_migrate, busiest->nr_running);
more_balance:
rq_lock_irqsave(busiest, &rf);
update_rq_clock(busiest);
cur_ld_moved = detach_tasks(&env); /* 从源 rq 摘任务 */
rq_unlock(busiest, &rf);
if (cur_ld_moved) {
attach_tasks(&env); /* 挂到目标 rq */
ld_moved += cur_ld_moved;
}
/* ... LBF_NEED_BREAK / LBF_DST_PINNED 重试 ... */
}
...
}
找繁忙组、从繁忙组找繁忙 CPU、从繁忙 CPU 迁移任务是三个独立阶段,职责分明。
6.2 find_busiest_group():判断是否失衡、哪个组作为迁移来源
find_busiest_group() 不是简单找 load 最大的组,它先通过 update_sd_lb_stats() 汇总各组统计数据,再用 imbalance_pct 阈值判断失衡是否够显著,最后才算迁移量。关键的判断如下:
c
/* kernel/sched/fair.c,find_busiest_group() */
static struct sched_group *find_busiest_group(struct lb_env *env)
{
...
/*
* 如果当前 CPU 已经处于 idle 状态,而不是刚进入 idle,
* 采用专门针对 CPU_IDLE 的判断。
*/
if (env->idle == CPU_IDLE) {
/*
* 对已经空闲的 CPU:
*
* 1. 如果候选繁忙组没有被判定为 group_overloaded;
* 2. 并且本地组的空闲 CPU 数量不比候选组少 2 个或更多;
*
* 那么不急于拉取任务。
*
* local->idle_cpus <= busiest->idle_cpus + 1
* 表示本地组的空闲 CPU 数量最多只比候选组多 1。
*
* 如果此时迁移任务,可能只是把空闲 CPU 的不平衡
* 从一个组转移到另一个组,而没有明显改善整体状况。
*/
if ((busiest->group_type != group_overloaded) &&
(local->idle_cpus <= (busiest->idle_cpus + 1)))
goto out_balanced;
} else {
/*
* CPU_NEWLY_IDLE 和 CPU_NOT_IDLE 走这里。用 imbalance_pct 保守判断,
* 避免仅因两组存在较小的平均负载差异就频繁迁移。
* 下面的不等式等价于:
* busiest->avg_load / local->avg_load <= imbalance_pct / 100
* 比较的是归一化后的 avg_load,不是原始 group_load,也不是 util_avg。
*/
if (100 * busiest->avg_load <=
env->sd->imbalance_pct * local->avg_load)
goto out_balanced;
}
force_balance:
/* 确认需要均衡,计算实际要拉取的负载量,写入 env->imbalance */
calculate_imbalance(env, &sds);
return sds.busiest; /* 返回候选繁忙组(还没迁移任务) */
out_balanced:
env->imbalance = 0;
return NULL;
}
imbalance_pct 是每层 domain 的失衡阈值(典型值 117,即繁忙组要比本地组高出约 17% 才值得动)。只有超过阈值才进入 calculate_imbalance()。
6.3 calculate_imbalance():计算迁移量
即使确认来源组更忙,也不能把"来源组负载 − 目标组负载"的全部差值搬过去。迁移量受组平均负载差、group capacity、每任务估计负载、来源组是否过载等约束。calculate_imbalance() 负载计算要迁移的负载量:
c
/* kernel/sched/fair.c,calculate_imbalance() 开头 */
static inline void calculate_imbalance(struct lb_env *env, struct sd_lb_stats *sds)
{
unsigned long max_pull, load_above_capacity = ~0UL;
struct sg_lb_stats *local, *busiest;
local = &sds->local_stat;
busiest = &sds->busiest_stat;
if (busiest->group_type == group_imbalanced) {
/*
* group_imbalanced 时组级平均负载值不可靠(affinity 等约束使任务无法
* 自由分布),修正 load_per_task 为 min(原每任务平均负载, 域平均负载),
* 避免用过大的每任务负载估计判断"迁一个任务要多少负载"。
*/
busiest->load_per_task =
min(busiest->load_per_task, sds->avg_load);
}
/* ... 据 avg_load / group_capacity / load_per_task 算 env->imbalance,
* 失衡很小时调 fix_small_imbalance() 修正 ... */
}
函数计算的结果写在 env->imbalance,表示本次希望拉取的负载量。注意,它算的是负载目标,不保证能找到任务组合刚好等于这个值------因为迁移单位是整个任务,任务 load 各不相同,又受 affinity 约束,最后只能近似达到。
7. 执行任务迁移
7.1 find_busiest_queue():调度组内选迁移源 CPU
find_busiest_group() 选的是一个 sched_group,不是具体 CPU。find_busiest_queue() 在该组里按 wl / capacity 最大选出源 CPU 的 rq:
c
/* kernel/sched/fair.c,find_busiest_queue() 选 rq */
static struct rq *find_busiest_queue(struct lb_env *env,
struct sched_group *group)
{
...
for_each_cpu_and(i, sched_group_span(group), env->cpus) {
/* ... rt 分类过滤 ... */
capacity = capacity_of(i); /* 获取 CPU @i 的 capacity */
wl = weighted_cpuload(rq); /* 获取 rq 的负载 */
/* 只有一个任务且其负载不足迁移目标、CPU capacity 未被明显削减,跳过 */
if (rq->nr_running == 1 && wl > env->imbalance &&
!check_cpu_capacity(rq, env->sd))
continue;
/* 找 max(wl_i / capacity_i),交叉相乘避免除法 */
if (wl * busiest_capacity > busiest_load * capacity) {
busiest_load = wl;
busiest_capacity = capacity;
busiest = rq;
}
}
return busiest;
}
组的统计是组内各 CPU 负载的聚合值,而真正的任务挂在各 CPU 的 rq 上,所以必须从 group 级下落到具体 rq,find_busiest_queue() 完成了这一任务。
7.2 detach_tasks() / attach_tasks():任务迁出、迁入
detach_tasks() 从源 rq 的 cfs_tasks 链表里摘任务,直到累计负载接近 env->imbalance,实现代码主干如下:
c
/* kernel/sched/fair.c,detach_tasks() 主干 */
static int detach_tasks(struct lb_env *env)
{
struct list_head *tasks = &env->src_rq->cfs_tasks;
struct task_struct *p;
unsigned long load;
int detached = 0;
if (env->imbalance <= 0)
return 0;
while (!list_empty(tasks)) {
/* idle 均衡时不把源 rq 抽到只剩 1 个,避免来回 livelock */
if (env->idle != CPU_NOT_IDLE && env->src_rq->nr_running <= 1)
break;
p = list_first_entry(tasks, struct task_struct, se.group_node);
/* ... loop_max / loop_break 限制单次扫描数量 ... */
if (!can_migrate_task(p, env)) /* 检查 affinity、是否正在运行、cache hot */
goto next;
load = task_h_load(p);
if (sched_feat(LB_MIN) && load < 16 && !env->sd->nr_balance_failed)
goto next;
if ((load / 2) > env->imbalance) /* 迁了会超出目标,跳过 */
goto next;
detach_task(p, env); /* 从源 rq 摘下 */
list_add(&p->se.group_node, &env->tasks);
detached++;
env->imbalance -= load; /* 扣减剩余迁移目标 */
/* ... CONFIG_PREEMPT 下 NEWLY_IDLE 摘一个就停,缩短临界区 ... */
next:
list_move_tail(&p->se.group_node, tasks);
}
return detached;
}
上面真正修改任务归属的是 detach_task():
c
/* kernel/sched/fair.c,detach_task() 核心 */
static void detach_task(struct task_struct *p, struct lb_env *env)
{
lockdep_assert_held(&env->src_rq->lock);
p->on_rq = TASK_ON_RQ_MIGRATING; /* 标记迁移中 */
deactivate_task(env->src_rq, p, DEQUEUE_NOCLOCK);/* 从源 rq 出队 */
set_task_cpu(p, env->dst_cpu); /* 改 CPU 归属 */
}
attach_tasks() 反向把 env->tasks 里的任务通过 activate_task() 移动到目标 rq,然后触发 check_preempt_curr()。整个过程要正确处理源/目标 rq 的锁和任务 on_rq 状态,不是简单的对 task->cpu 赋个值就完事。
7.3 决定迁移 ≠ 一定迁移成功
决策阶段(find_busiest_group() -> calculate_imbalance())只算期望迁移量 env->imbalance,执行阶段(find_busiest_queue() + detach_tasks())还要找到满足约束的任务。源 CPU 上的任务可能正在运行、affinity 不允许迁到目标 CPU、或均衡过程中 rq 状态变了,实际迁移量就可能小于预期甚至为零。所以负载均衡必须不断结合运行时状态检查,不能只凭一次组统计就保证理想分布。
8. 总结
Linux 4.14.111 的 CFS 负载均衡是分不同层次、分多个步骤完成的任务:
- 硬件拓扑 / capacity:决定 CPU 之间的关系和 cache 局部性,capacity 描述能力基准。
build_sched_domains():把 CPU 组织成多层sched_domain/sched_group,cpu_attach_domain()用rcu_assign_pointer()把 domain 链挂到rq->sd。update_curr()/ PELT / enqueue-dequeue :在运行、入队、出队路径上持续维护 load、util、nr_running。trigger_load_balance()/idle_balance():tick 到期或 CPU 进 idle 时触发一次负载均衡。update_sd_lb_stats()/find_busiest_group():把 CPU 级统计汇总成 group 级,用imbalance_pct判断是否值得均衡。calculate_imbalance():算期望迁移的负载量env->imbalance。find_busiest_queue()/detach_tasks()/attach_tasks():落到具体 CPU 和任务,完成迁移。
负载均衡的核心不是追求每个 CPU 上负载具有相同数值,而是在能力、需求、局部性、迁移约束之间,基于有限的统计数据做出可执行的折中。
负载均衡虽然带来了收益,但凡事皆有两面,它也带来了一些问题:周期性+层级遍历的均衡依赖 PELT 历史估计、反应滞后且对异构/能耗不敏感。对这些问题,已有改进是 Linxu 4.15+ 内核用 load_balance 的 util/EAS(能量感知调度)+ 后来的 runnable_avg 重构让大小核和功耗纳入决策;计划中的改进方向是 sched_ext(BPF 可编程调度,把均衡策略下放到用户态按场景定制)。
关于 NUMA
限于笔者有限的见识,嵌入式设备里,除了像 ARM 服务器、少数高端边缘/网络芯片之类的设备,笔者没见过带有 NUMA 的设备,所以本文没有展开。NUMA 在 CPU 拓扑之外还引入内存节点距离和远端访问代价:迁移任务可能改善 CPU 负载,却让它更频繁访问远端内存,调度器要同时权衡两者,比单一共享内存域复杂。