【Linux内核四十三】进程管理模块:CFS唤醒选核(一):select_task_rq_fair 总入口与 wake_affine/select_idle_sibling

本文深入剖析Linux 5.15内核中CFS调度器的唤醒选核流程,聚焦select_task_rq_fair总入口,详解wake_affine(唤醒亲和)与select_idle_sibling(空闲兄弟核选择)两大核心路径。区分wakeup/fork/exec不同触发场景的路径选择逻辑,涵盖EAS截胡、宽窄唤醒判定(record_wakee/wake_wide)、缓存亲和性优化、LLC域内空闲核扫描策略及find_idlest_cpu慢路径机制,强调能耗感知与负载均衡协同设计。

内核版本:linux-5.15.140
涉及文件:kernel/sched/fair.c、kernel/sched/sched.h、kernel/sched/core.c

前言

第 39 篇收掉了负载均衡这条线——find_busiest_group 决策矩阵与 calculate_imbalance 决定的是"周期性/空闲时,把任务从最忙的组搬到最闲的组"。第 40 篇你在 QEMU 里把这一切跑了一遍:一个 helloworld 进程经历了 fork、exec、sched_switch、sched_migrate_task、nice、exit,内核日志里能看到它每次换核、每次被唤醒的痕迹。

第 41 篇我们把负载均衡的"搬运执行"半场(detach_tasks/can_migrate_task/attach_tasks)补齐,负载均衡专题至此彻底闭环。但实验日志里还有一块没讲透:一个任务被唤醒(wakeup)的那一刻,内核为什么选了这个核?本篇转入这条相对独立的路径——唤醒选核(wakeup placement):当一个任务被创建(fork)或被唤醒(wakeup)的那一刻,内核是怎么决定把它放到哪个 CPU 上的。

它和 39 篇是 CFS 里"任务放哪"的两条不同路径:

  • 39 篇是被动再均衡:任务已经在跑了,周期性地发现不均,再搬。
  • 本篇是主动放置:任务"出生"或"醒来"的瞬间,直接选一个最合适的目的地,尽量别让它一出生就触发后续搬运。

调用关系如下(本篇只拆 select_task_rq_fair 这一支,能量感知那一支放到第 43 篇):

try_to_wake_up()                       kernel/sched/core.c:4131
  └── select_task_rq(p, ..., WF_TTWU)  core.c:4131
        └── p->sched_class->select_task_rq == select_task_rq_fair  fair.c:7139  ← 本篇
              ├── if sched_energy_enabled()        fair.c:7157
              │     └── find_energy_efficient_cpu() fair.c:6995  ← 第 43 篇(EAS)
              ├── want_affine && SD_WAKE_AFFINE
              │     └── wake_affine()               fair.c:6182  ← 本篇
              ├── slow path: find_idlest_cpu()      fair.c:6265  ← 本篇(fork/exec)
              └── fast path: select_idle_sibling()  fair.c:6600  ← 本篇(wakeup)

注意:select_task_rq_fair 不只服务于 wakeup。fork(WF_FORK)和 exec(WF_EXEC)也走它,但走的是不同的分支——这正是本篇要讲清的第一个关键点。


一、select_task_rq_fair 总入口

先贴 5.15.140 的完整源码(kernel/sched/fair.c:7139):

static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
	int sync = (wake_flags & WF_SYNC) && !(current->flags & PF_EXITING);
	struct sched_domain *tmp, *sd = NULL;
	int cpu = smp_processor_id();
	int new_cpu = prev_cpu;
	int want_affine = 0;
	/* SD_flags and WF_flags share the first nibble */
	int sd_flag = wake_flags & 0xF;

	/*
	 * required for stable ->cpus_allowed
	 */
	lockdep_assert_held(&p->pi_lock);
	if (wake_flags & WF_TTWU) {
		record_wakee(p);

		if (sched_energy_enabled()) {
			new_cpu = find_energy_efficient_cpu(p, prev_cpu);
			if (new_cpu >= 0)
				return new_cpu;
			new_cpu = prev_cpu;
		}

		want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr);
	}

	rcu_read_lock();
	for_each_domain(cpu, tmp) {
		/*
		 * If both 'cpu' and 'prev_cpu' are part of this domain,
		 * cpu is a valid SD_WAKE_AFFINE target.
		 */
		if (want_affine && (tmp->flags & SD_WAKE_AFFINE) &&
		    cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) {
			if (cpu != prev_cpu)
				new_cpu = wake_affine(tmp, p, cpu, prev_cpu, sync);

			sd = NULL; /* Prefer wake_affine over balance flags */
			break;
		}

		if (tmp->flags & sd_flag)
			sd = tmp;
		else if (!want_affine)
			break;
	}

	if (unlikely(sd)) {
		/* Slow path */
		new_cpu = find_idlest_cpu(sd, p, cpu, prev_cpu, sd_flag);
	} else if (wake_flags & WF_TTWU) { /* XXX always ? */
		/* Fast path */
		new_cpu = select_idle_sibling(p, prev_cpu, new_cpu);
	}
	rcu_read_unlock();

	return new_cpu;
}

逐段拆:

(1) 唤醒标志决定走哪条路。 wake_flags 的低 4 位直接复用了 SD_BALANCE_* 标志(sched.h:2054):

#define WF_EXEC     0x02 /* Wakeup after exec; maps to SD_BALANCE_EXEC */
#define WF_FORK     0x04 /* Wakeup after fork; maps to SD_BALANCE_FORK */
#define WF_TTWU     0x08 /* Wakeup;            maps to SD_BALANCE_WAKE */
#define WF_SYNC     0x10 /* Waker goes to sleep after wakeup */

sd_flag = wake_flags & 0xF 把这三位的语义透传进调度域遍历。后面 for_each_domain 找的就是"带对应 SD_BALANCE_* 标志的域"。

(2) 只有 wakeup(WF_TTWU)才进"快路径"逻辑。 三个动作都包在 if (wake_flags & WF_TTWU) 里:

  • record_wakee(p):记录唤醒关系,供 wake_wide 判断(见第二节)。
  • sched_energy_enabled() 为真就先调 find_energy_efficient_cpu(第 43 篇)。返回合法 CPU 直接 return,连 wake_affine 都不走。这是 EAS 的"截胡"。
  • want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr):决定这次唤醒要不要尝试"唤醒亲和"。

而 WF_FORK/WF_EXEC(fork/exec)不会进这个 if。意味着:fork 和 exec 不走 EAS,也不做 wake_affine,直接落到慢路径 find_idlest_cpu(见第五节)。原因源码注释写过——新任务还没有可用的 util 数据,EAS 算不出有意义的能耗差。

(3) 调度域遍历的三岔口。 for_each_domain(cpu, tmp) 从当前 CPU 的底层域向上扫:

  • 若 want_affine 且当前域带 SD_WAKE_AFFINE 且 prev_cpu 也在这个域里 → 调 wake_affine 尝试把任务放到当前 CPU 或 prev_cpu 旁边,然后 break(优先于 balance 标志)。
  • 否则若域带 sd_flag(即 SD_BALANCE_WAKE/FORK/EXEC 对应的那一级)→ 记下 sd = tmp,继续向上找更高层的平衡域。
  • 若既不匹配,且 !want_affine → break 不再往上。

(4) 收尾二选一。

  • if (unlikely(sd)) —— 找到了平衡域,走慢路径 find_idlest_cpu:在域层级里挑最闲的 CPU。fork/exec 基本都走这。
  • else if (wake_flags & WF_TTWU) —— 没有平衡域(通常意味着小系统或亲和已搞定),走快路径 select_idle_sibling:在本 LLC 内挑个空闲兄弟核。wakeup 大多走这。

一句话概括本函数:先让 EAS 截胡;否则 wakeup 优先试唤醒亲和,不行再在本域挑空闲核,再不行就沿调度域向上找最闲核。


二、唤醒宽窄:record_wakee 与 wake_wide

want_affine 里那个 !wake_wide(p) 是很多人忽略的开关。它的作用是:识别"一对多"的唤醒关系,避免把一堆任务都往一个核上堆。

先看 record_wakee(fair.c:6053):

static void record_wakee(struct task_struct *p)
{
	/*
	 * Only decay a single time; tasks that have less then 1 wakeup per
	 * jiffy will not have built up many flips.
	 */
	if (time_after(jiffies, current->wakee_flip_decay_ts + HZ)) {
		current->wakee_flips >>= 1;
		current->wakee_flip_decay_ts = jiffies;
	}

	if (current->last_wakee != p) {
		current->last_wakee = p;
		current->wakee_flips++;
	}
}

逻辑很简单:每个任务记一个 wakee_flips(唤醒对象切换次数)和 last_wakee。每次唤醒的对象和上次不同,wakee_flips++;每秒(HZ)衰减一半。所以 wakee_flips 近似"最近一秒内这个任务唤醒过多少个不同对象"。

再看 wake_wide(fair.c:6087),源码注释把意图讲得很清楚——它要抓出 M:N 的唤醒关系(比如一个 dispatcher 唤醒一堆 worker):

static int wake_wide(struct task_struct *p)
{
	unsigned int master = current->wakee_flips;
	unsigned int slave = p->wakee_flips;
	int factor = __this_cpu_read(sd_llc_size);

	if (master < slave)
		swap(master, slave);
	if (slave < factor || master < slave * factor)
		return 0;
	return 1;
}

判定:master/slave 取 waker 和 wakee 两者的 flip 数(大的当 master)。若较小的那个低于 LLC 大小(sd_llc_size),或者大的不到小的 factor 倍,就认为关系"专一"(return 0),可以放心做唤醒亲和;否则判定为"非专一、伙伴数超过 socket 规模"(return 1),want_affine 变 0,放弃亲和,改走慢路径让负载铺开。

这正是第 40 篇实验里你看到的:单进程 helloworld 自己唤醒自己、偶尔被父进程唤醒,flip 数很低,wake_wide 返回 0,亲和能成立;而服务器里一个主线程狂唤醒上百个工作线程,就会被判定为 wide,打散到不同核。


三、唤醒亲和:wake_affine

当 want_affine 成立、prev_cpu 和当前 CPU 在同一个 SD_WAKE_AFFINE 域里,就进入 wake_affine(fair.c:6182):

static int wake_affine(struct sched_domain *sd, struct task_struct *p,
		       int this_cpu, int prev_cpu, int sync)
{
	int target = nr_cpumask_bits;

	if (sched_feat(WA_IDLE))
		target = wake_affine_idle(this_cpu, prev_cpu, sync);

	if (sched_feat(WA_WEIGHT) && target == nr_cpumask_bits)
		target = wake_affine_weight(sd, p, this_cpu, prev_cpu, sync);

	schedstat_inc(p->stats.nr_wakeups_affine_attempts);
	if (target != this_cpu)
		return prev_cpu;

	schedstat_inc(sd->ttwu_move_affine);
	schedstat_inc(p->stats.nr_wakeups_affine);
	return target;
}

它先试 wake_affine_idle(只看"现在"谁闲),没结论(target == nr_cpumask_bits)再试 wake_affine_weight(看负载权重)。最终若 target != this_cpu(即没选当前 CPU),就退回 prev_cpu;否则返回选中的 target。

wake_affine_idle(fair.c:6113)——只看即时空闲与缓存亲和:

static int
wake_affine_idle(int this_cpu, int prev_cpu, int sync)
{
	if (available_idle_cpu(this_cpu) && cpus_share_cache(this_cpu, prev_cpu))
		return available_idle_cpu(prev_cpu) ? prev_cpu : this_cpu;

	if (sync && cpu_rq(this_cpu)->nr_running == 1)
		return this_cpu;

	if (available_idle_cpu(prev_cpu))
		return prev_cpu;

	return nr_cpumask_bits;
}

优先级:① 若当前 CPU 闲且和 prev_cpu 共享缓存,优先留在 prev_cpu(缓存热数据不丢);② 若 sync(唤醒者唤醒后立刻睡)且当前 CPU 只有一个任务在跑,直接放当前 CPU;③ prev_cpu 闲就放 prev_cpu;④ 都不行返回 nr_cpumask_bits 表示"我没主意"。

wake_affine_weight(fair.c:6140)——过载场景下看有效负载:

static int
wake_affine_weight(struct sched_domain *sd, struct task_struct *p,
		   int this_cpu, int prev_cpu, int sync)
{
	s64 this_eff_load, prev_eff_load;
	unsigned long task_load;

	this_eff_load = cpu_load(cpu_rq(this_cpu));

	if (sync) {
		unsigned long current_load = task_h_load(current);
		if (current_load > this_eff_load)
			return this_cpu;
		this_eff_load -= current_load;
	}

	task_load = task_h_load(p);

	this_eff_load += task_load;
	if (sched_feat(WA_BIAS))
		this_eff_load *= 100;
	this_eff_load *= capacity_of(prev_cpu);

	prev_eff_load = cpu_load(cpu_rq(prev_cpu));
	prev_eff_load -= task_load;
	if (sched_feat(WA_BIAS))
		prev_eff_load *= 100 + (sd->imbalance_pct - 100) / 2;
	prev_eff_load *= capacity_of(this_cpu);

	if (sync)
		prev_eff_load += 1;

	return this_eff_load < prev_eff_load ? this_cpu : nr_cpumask_bits;
}

它把"当前 CPU 的有效负载"和"prev_cpu 的有效负载"各算一笔账:把被唤醒任务 p 的负载 task_h_load(p) 加到目标侧,从源侧减去(因为 sync 时唤醒者会立刻让出)。WA_BIAS 用 imbalance_pct(默认 117,见第 39 篇)给 prev_cpu 侧加权,偏好留在 prev_cpu(利用热缓存)。最后比较两者,谁有效负载小就放谁;否则返回 nr_cpumask_bits 放弃。

这里能和第 39 篇闭环:imbalance_pct 这个"迟滞系数"不只用在负载均衡,也用在了唤醒亲和的偏置里——内核在每一个"放哪"的决策点都刻意留一点粘性,防止任务在核间乒乓。


四、选空闲兄弟核:select_idle_sibling

若 wake_affine 没把任务落地(或 want_affine 不成立),wakeup 走快路径 select_idle_sibling(fair.c:6600)——在 LLC(末级缓存)域内找最闲的兄弟核:

static int select_idle_sibling(struct task_struct *p, int prev, int target)
{
	bool has_idle_core = false;
	struct sched_domain *sd;
	unsigned long task_util, util_min, util_max;
	int i, recent_used_cpu;

	if (sched_asym_cpucap_active()) {
		sync_entity_load_avg(&p->se);
		task_util = task_util_est(p);
		util_min = uclamp_eff_value(p, UCLAMP_MIN);
		util_max = uclamp_eff_value(p, UCLAMP_MAX);
	}

	lockdep_assert_irqs_disabled();

	if ((available_idle_cpu(target) || sched_idle_cpu(target)) &&
	    asym_fits_cpu(task_util, util_min, util_max, target))
		return target;

	/*
	 * If the previous CPU is cache affine and idle, don't be stupid:
	 */
	if (prev != target && cpus_share_cache(prev, target) &&
	    (available_idle_cpu(prev) || sched_idle_cpu(prev)) &&
	    asym_fits_cpu(task_util, util_min, util_max, prev))
		return prev;

	if (is_per_cpu_kthread(current) &&
	    in_task() &&
	    prev == smp_processor_id() &&
	    this_rq()->nr_running <= 1 &&
	    asym_fits_cpu(task_util, util_min, util_max, prev)) {
		return prev;
	}

	/* Check a recently used CPU as a potential idle candidate: */
	recent_used_cpu = p->recent_used_cpu;
	p->recent_used_cpu = prev;
	if (recent_used_cpu != prev &&
	    recent_used_cpu != target &&
	    cpus_share_cache(recent_used_cpu, target) &&
	    (available_idle_cpu(recent_used_cpu) || sched_idle_cpu(recent_used_cpu)) &&
	    cpumask_test_cpu(p->recent_used_cpu, p->cpus_ptr) &&
	    asym_fits_cpu(task_util, util_min, util_max, recent_used_cpu)) {
		return recent_used_cpu;
	}

	if (sched_asym_cpucap_active()) {
		sd = rcu_dereference(per_cpu(sd_asym_cpucapacity, target));
		if (sd) {
			i = select_idle_capacity(p, sd, target);
			return ((unsigned)i < nr_cpumask_bits) ? i : target;
		}
	}

	sd = rcu_dereference(per_cpu(sd_llc, target));
	if (!sd)
		return target;

	if (sched_smt_active()) {
		has_idle_core = test_idle_cores(target, false);

		if (!has_idle_core && cpus_share_cache(prev, target)) {
			i = select_idle_smt(p, sd, prev);
			if ((unsigned int)i < nr_cpumask_bits)
				return i;
		}
	}

	i = select_idle_cpu(p, sd, has_idle_core, target);
	if ((unsigned)i < nr_cpumask_bits)
		return i;

	return target;
}

它的候选顺序非常讲究"就近 + 缓存热",优先级从近到远:

  1. target(new_cpu,通常就是 prev_cpu)本身空闲 → 直接返回,零迁移、缓存最热。
  2. prev(上一次运行的 CPU)空闲且和 target 共享缓存 → 回 prev。注释原话"don’t be stupid":上次的核还热着,别乱跑。
  3. per-cpu kthread 的特殊快路径:若唤醒者是 per-cpu kthread、且 prev 就是当前 CPU、当前 rq 只有 ≤1 个任务在跑(典型 IO 完成场景),直接堆到 prev。
  4. recent_used_cpu:任务最近用过的另一个核,若空闲且共享缓存,优先用它(把 prev 记进 recent_used_cpu 供下次用)。
  5. 大小核系统(sched_asym_cpucap_active):走 select_idle_capacity 在 sd_asym_cpucapacity 域里挑一个能装下任务利用率的空闲核。
  6. SMT 兄弟:若开了 SMT 且没有整核空闲,先试同核的另一个超线程 select_idle_smt。
  7. 扫描整个 LLC:兜底 select_idle_cpu 遍历 LLC 域找空闲核。

其中第 6/7 步的 select_idle_cpu(fair.c:6462)是真正"扫一遍"的地方,它受两个调度特性(SCHED_FEAT)调节:

  • SIS_PROP:用 sd->avg_scan_cost 和 rq->avg_idle 估算"扫描成本 vs 平均空闲时长",动态决定最多扫几个核(nr),避免在大系统上为了找一个空闲核而扫遍几十个 CPU 拖慢唤醒。
  • SIS_UTIL:若 LLC 整体过载(nr_idle_scan 趋近 0),直接放弃扫描返回 -1,让上层决策去搬任务。

__select_idle_cpu(fair.c:6318)是底层判据:核必须 available_idle_cpu || sched_idle_cpu 且 cookie 匹配(核心隔离 sched_core)。

把这一节和第 40 篇实验对一下:你日志里的 sched_migrate_task、核号跳变,本质就是这里的"target 不空闲 → 退回 prev → prev 也不闲 → 扫描 LLC 找到另一个空闲核 → 迁移"。对单进程 helloworld 来说,系统几乎全闲,绝大部分唤醒会落在第 1/2 步(留在原核),所以你看到的迁移次数很少,符合预期。


五、慢路径:find_idlest_cpu(fork/exec 走这)

fork(WF_FORK)和 exec(WF_EXEC)不进 WF_TTWU 分支,want_affine 始终为 0,于是 for_each_domain 不会触发 wake_affine,遍历到底把带 sd_flag(SD_BALANCE_FORK/SD_BALANCE_EXEC)的那一级域记进 sd,最后走 find_idlest_cpu(fair.c:6265):

static inline int find_idlest_cpu(struct sched_domain *sd, struct task_struct *p,
				  int cpu, int prev_cpu, int sd_flag)
{
	int new_cpu = cpu;

	if (!cpumask_intersects(sched_domain_span(sd), p->cpus_ptr))
		return prev_cpu;

	if (!(sd_flag & SD_BALANCE_FORK))
		sync_entity_load_avg(&p->se);

	while (sd) {
		struct sched_group *group;
		struct sched_domain *tmp;
		int weight;

		if (!(sd->flags & sd_flag)) {
			sd = sd->child;
			continue;
		}

		group = find_idlest_group(sd, p, cpu);
		if (!group) {
			sd = sd->child;
			continue;
		}

		new_cpu = find_idlest_group_cpu(group, p, cpu);
		if (new_cpu == cpu) {
			sd = sd->child;
			continue;
		}

		cpu = new_cpu;
		weight = sd->span_weight;
		sd = NULL;
		for_each_domain(cpu, tmp) {
			if (weight <= tmp->span_weight)
				break;
			if (tmp->flags & sd_flag)
				sd = tmp;
		}
	}

	return new_cpu;
}

它的思路是自顶向下遍历调度域层级:在每一级带 sd_flag 的域里,用 find_idlest_group 找最闲的组,再用 find_idlest_group_cpu 在组里挑最闲的核;选中的核若比当前层更靠下,就下沉到该核的对应子域继续找,直到某一级"本层最闲的就是当前 CPU"为止。这正好和第 36~39 篇的负载均衡域层级结构(见第 36 篇 sched_domain/sched_group 构建)同源——fork/exec 时直接复用这套"域→组→核"的层级来找最闲处,而不是唤醒时那种"就近贴缓存"的贪心。

find_idlest_group_cpu(fair.c:6208)在组内挑核的优先级也很明确:优先 sched_idle_cpu(整个 CPU 是 idle 调度类)→ 再挑 available_idle_cpu 里退出延迟最小的(缓存最热)→ 都没有才挑 cpu_load 最低的。


六、与第 40 篇实验的对应关系

把本篇的函数和第 40 篇日志里的 tracepoint 对上,你就能从"看到现象"升级到"看懂原因":

第 40 篇日志事件本篇对应的选核逻辑
sched_process_fork 后子进程首次被放核find_idlest_cpu 慢路径(WF_FORK),沿调度域找最闲核
sched_wakeup / sched_wakeup_newselect_task_rq_fair 入口,WF_TTWU 分支
唤醒后核号不变(留在 prev)select_idle_sibling 第 1/2 步:target 或 prev 空闲即原地
sched_migrate_task 核号跳变select_idle_cpu 扫描 LLC 找到别的空闲核,或 find_idlest_cpu 下沉
唤醒者唤醒后立刻睡(WF_SYNC)wake_affine_idle/wake_affine_weight 里的 sync 分支,偏好堆叠

小结

  1. select_task_rq_fair(fair.c:7139)是唤醒选核总入口:EAS 截胡 → wake_affine 试亲和 → 慢路径 find_idlest_cpu → 快路径 select_idle_sibling。
  2. 路径选择由 wake_flags 低 4 位决定:wakeup 走快路径/亲和/可能 EAS;fork/exec 走慢路径 find_idlest_cpu,因为新任务没有 util 数据。
  3. wake_wide(fair.c:6087)识别一对多唤醒,专一关系才允许亲和,防止任务堆核——和第 39 篇 imbalance_pct 一样的"防乒乓"思想。
  4. wake_affine 优先留在 prev_cpu(缓存热),WA_BIAS 用 imbalance_pct 给 prev 加权。
  5. select_idle_sibling(fair.c:6600)的候选顺序是 target → prev → recent → 大小核 → SMT → 扫描 LLC,全程"就近 + 缓存热"。
  6. find_idlest_cpu 复用第 36 篇的"域→组→核"层级,自顶向下找最闲处,是 fork/exec 的落点。

下一篇(第 43 篇)回到 select_task_rq_fair 里被截胡的那一支:find_energy_efficient_cpu 与 compute_energy,讲内核怎么把"功耗"也变成选核依据。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

新兴AI民工

码字不易,各位看客随意

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值