Linux CFS 负载均衡:从 CPU 拓扑到任务迁移

文章目录

  • [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 负载均衡是分不同层次、分多个步骤完成的任务:

  1. 硬件拓扑 / capacity:决定 CPU 之间的关系和 cache 局部性,capacity 描述能力基准。
  2. build_sched_domains() :把 CPU 组织成多层 sched_domain/sched_group,cpu_attach_domain() 用 rcu_assign_pointer() 把 domain 链挂到 rq->sd。
  3. update_curr() / PELT / enqueue-dequeue :在运行、入队、出队路径上持续维护 load、util、nr_running。
  4. trigger_load_balance() / idle_balance():tick 到期或 CPU 进 idle 时触发一次负载均衡。
  5. update_sd_lb_stats() / find_busiest_group() :把 CPU 级统计汇总成 group 级,用 imbalance_pct 判断是否值得均衡。
  6. calculate_imbalance() :算期望迁移的负载量 env->imbalance。
  7. 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 负载,却让它更频繁访问远端内存,调度器要同时权衡两者,比单一共享内存域复杂。

相关推荐
水饺编程1 小时前
第2章:Linux 基础,关机命令
linux·c语言·汇编
91刘仁德1 小时前
计算机网络核心协议全解析:从MAC帧到HTTP
linux·网络·网络协议·tcp/ip·计算机网络·http·udp
Brilliantwxx2 小时前
【Linux】IO(上)全篇:文件本质、C 接口、系统调用与文件描述符
linux·运维·服务器·c语言
承渊政道2 小时前
Linux网络学习【深入理解应用层HTTP协议、Cookie与Session机制及HTTPS协议原理】
linux·网络·学习·http·cookie·session
北风toto2 小时前
Linux 服务器通过 tgz 离线部署 Docker
linux·服务器·docker
重生之小比特2 小时前
【Linux系统】13 基础IO(上)
linux·运维·服务器
CCCCCCCCharlie2 小时前
从0到1:一个字节的磁盘之旅
linux·开发语言·c++
运维小斌2 小时前
麒麟 V10 ARM64 离线部署 OceanBase:8 步实施手册
linux·数据库·oceanbase·麒麟
着迷不白2 小时前
生产环境标准的Linux系统安装到服务器
linux·云计算