内核版本: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;
}
它的候选顺序非常讲究"就近 + 缓存热",优先级从近到远:
target(new_cpu,通常就是prev_cpu)本身空闲 → 直接返回,零迁移、缓存最热。prev(上一次运行的 CPU)空闲且和 target 共享缓存 → 回prev。注释原话"don’t be stupid":上次的核还热着,别乱跑。- per-cpu kthread 的特殊快路径:若唤醒者是 per-cpu kthread、且 prev 就是当前 CPU、当前 rq 只有 ≤1 个任务在跑(典型 IO 完成场景),直接堆到
prev。 recent_used_cpu:任务最近用过的另一个核,若空闲且共享缓存,优先用它(把prev记进recent_used_cpu供下次用)。- 大小核系统(
sched_asym_cpucap_active):走select_idle_capacity在sd_asym_cpucapacity域里挑一个能装下任务利用率的空闲核。 - SMT 兄弟:若开了 SMT 且没有整核空闲,先试同核的另一个超线程
select_idle_smt。 - 扫描整个 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_new | select_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 分支,偏好堆叠 |
小结
select_task_rq_fair(fair.c:7139)是唤醒选核总入口:EAS 截胡 → wake_affine 试亲和 → 慢路径 find_idlest_cpu → 快路径 select_idle_sibling。- 路径选择由
wake_flags低 4 位决定:wakeup 走快路径/亲和/可能 EAS;fork/exec 走慢路径 find_idlest_cpu,因为新任务没有 util 数据。 wake_wide(fair.c:6087)识别一对多唤醒,专一关系才允许亲和,防止任务堆核——和第 39 篇imbalance_pct一样的"防乒乓"思想。wake_affine优先留在prev_cpu(缓存热),WA_BIAS用imbalance_pct给 prev 加权。select_idle_sibling(fair.c:6600)的候选顺序是 target → prev → recent → 大小核 → SMT → 扫描 LLC,全程"就近 + 缓存热"。find_idlest_cpu复用第 36 篇的"域→组→核"层级,自顶向下找最闲处,是 fork/exec 的落点。
下一篇(第 43 篇)回到 select_task_rq_fair 里被截胡的那一支:find_energy_efficient_cpu 与 compute_energy,讲内核怎么把"功耗"也变成选核依据。
本文深入剖析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慢路径机制,强调能耗感知与负载均衡协同设计。
30

被折叠的 条评论
为什么被折叠?



