0/3 已展开

LLM 分析

cpufreq/schedutil: 仅在所有 sg_cpu 初始化完成后才发布 util hook

系列概况

  • 标题: [PATCH] cpufreq: schedutil: Publish util hooks only after all sg_cpu are initialized
  • 作者: Zhongqiu Han zhongqiu.han@oss.qualcomm.com
  • 版本: 单封 PATCH,无 vN 序号,非系列
  • 规模: 1 file changed, 11 insertions(+), 0 deletions(-)
  • 修改文件: kernel/sched/cpufreq_schedutil.c
  • 代码统计: +11 / -0;新增一个 for_each_cpu 循环专做 cpufreq_add_update_util_hook(),原循环保留 memset 初始化部分
  • Message-ID: 20260716115159.848403-1-zhongqiu.han@oss.qualcomm.com
  • 完整性: 提交信息完整,含 Fixes tag(指向 16a03c71bba0)、Cc stable、Signed-off-by。线程内还有 Christian Loehle(Arm)与 Rafael J. Wysocki(Intel)各一封回复,正文均为对补丁 commit message 的全文或部分引用,在 Lore 上看起来都截断于引文末尾,未观察到明显新增的实质评论。

补丁目的

schedutil 是把 scheduler 的 util 信号转成 CPU 频率决策的 governor。对于 shared cpufreq policy(多 CPU 共享同一频率域,例如大核簇),多个 CPU 共享一个 sg_policy,并各自维护一份 sugov_cpu(per-CPU 数据)。sugov_start() 启动 governor 时需先初始化每个 per-CPU sugov_cpu,再向 scheduler 注册每个 CPU 的 util update hook。

该补丁修复一个回归:commit 16a03c71bba0 把"逐 CPU 初始化 + 注册 hook"合并到一个循环里,看似简洁,却无意中重新踩到了 ab2f7cf141aa 当年已经修过的那条 race。

旧流程的问题

旧实现把初始化和 hook 注册放在同一个 for_each_cpu 循环里:

for_each_cpu(cpu, policy->cpus) {
    struct sugov_cpu *sg_cpu = &per_cpu(sugov_cpu, cpu);
    memset(sg_cpu, 0, sizeof(*sg_cpu));
    sg_cpu->cpu       = cpu;
    sg_cpu->sg_policy = sg_policy;
    cpufreq_add_update_util_hook(cpu, &sg_cpu->update_util, uu);
}

问题链条:

  1. cpufreq_add_update_util_hook() 一旦对某个 CPU 注册成功,scheduler 在 RCU-sched 域立即可触发 sugov_update_shared()
  2. sugov_start() 持有 policy->rwsem;但 scheduler 的 util 路径根本不取这把锁,rwsem 拦不住更新端。
  3. 更新端持 sg_policy->update_lock;初始化端持 policy->rwsem两把锁没有公共部分,互不序列化。
  4. 当首个 sibling 的 hook 已发布,循环还在给其他 sibling 做 memset(0) 时,sugov_next_freq_shared()遍历整个 shared group,并发读/写其它 sibling 的 iowait_boostutilbw_min 等标量字段,踩到尚未填好的结构上。

后果:访问都是标量,今天不会立刻 crash,只会读到 stale 或 0 值,调频决策偏移、tracepoint 错乱;但这是真正的 data race,未来一旦在这条路径里解引用任何指针(例如 sg_cpu->sg_policy),就立刻变 latent crash。

新流程

恢复两阶段:

/* Phase 1: 初始化所有 sg_cpu,但不暴露给 scheduler */
for_each_cpu(cpu, policy->cpus) {
    struct sugov_cpu *sg_cpu = &per_cpu(sugov_cpu, cpu);
    memset(sg_cpu, 0, sizeof(*sg_cpu));
    sg_cpu->cpu       = cpu;
    sg_cpu->sg_policy = sg_policy;
}

/*
 * Publish the hooks only after all per-CPU data is initialized, so a
 * shared policy's sugov_update_shared() never reads an uninitialized
 * sibling sugov_cpu.
 */
for_each_cpu(cpu, policy->cpus) {
    struct sugov_cpu *sg_cpu = &per_cpu(sugov_cpu, cpu);
    cpufreq_add_update_util_hook(cpu, &sg_cpu->update_util, uu);
}

第一阶段先把所有 sugov_cpu 内存清零并填好 cpu/sg_policy,确保结构完整、内部一致;第二阶段才发布 hook。第一个 hook 暴露时,所有 sibling 结构已就绪,scheduler 即时触发的 sugov_update_shared() 遍历到的每个 sugov_cpu 都已构造完成。

        shared policy (e.g. CPU4..CPU7)
        +----------------------------------------+
        | sg_policy -> update_lock : unique     |
        | policy->rwsem : init-side only        |
        +----------------------------------------+

   Old (bad) flow                New (good) flow
   ----------------              -----------------
   for cpu in cpus:              Phase 1: memset all sg_cpu
     memset(sg_cpu)                for cpu in cpus:
     fill cpu/sg_policy              memset(sg_cpu)
     add_hook(cpu)                    fill cpu/sg_policy
      |
      v
   update path wakes up           Phase 2: publish hooks
   immediately on first             for cpu in cpus:
   published hook, sees other       add_hook(cpu)
   siblings still half-zero         |
                                    v
                                   update path only sees
                                   fully-initialized sg_cpu

关键实现

补丁只动一处:在原循环后再插入一个 for_each_cpu 循环专做 cpufreq_add_update_util_hook(),原循环保留 memset 初始化。关键约束:

  • 没有新增任何锁;两阶段靠 happens-before 发布语义(hook 注册本身就是 RCU 侧的发布动作)打破 race,不是靠锁。
  • policy->cpus 在两个循环期间不会变(仍受 policy->rwsem 保护),所以两个循环看到的 CPU 集合完全一致。
  • 纯结构重构:对其他 governor 类型无影响,公共调度路径语义不变。

锁与发布关系示意:

          sugov_start()                          sugov_update_shared()
          -----------                            ----------------------
   acquire policy->rwsem                RCU-sched (no policy->rwsem)
   memset(sg_cpu)  .............. race .  read sg_cpu->iowait_boost
   fill cpu/sg_policy                            sg_cpu->util
   add_hook()  -- publish --->  (atomic)  --> sg_cpu->bw_min
   release policy->rwsem                acquire sg_policy->update_lock
                                        release sg_policy->update_lock

Patch 概览

内容
触发 commit16a03c71bba0 (Merge init in single loop)
之前修复ab2f7cf141aa (Fix sugov_start vs sugov_update_shared race)
改动定位kernel/sched/cpufreq_schedutil.c 函数 sugov_start()
改动行数11 insertions(+), 0 deletions(-)
关键标签Fixes: 16a03c71bba0Cc: stable@vger.kernel.org

类比

把它想象成剧场的开门放人

  1. 老做法(坏):工作人员把检票闸机一个一个地打开,每开一个就放人进去。第一个闸机后面的座位还没摆椅子,后到的观众只能坐在地上看戏——他们以为坐的是"摆好的椅子",其实椅子此刻还在仓库里被工人搬进来。椅子全是硬物、没有指针,所以没人摔骨折,但全场观众坐错位置、看错场次。
  2. 新做法(好):先把所有椅子都摆好、把场景搭完,最后才统一打开闸机放观众入场。第一个观众进场时,所有座位已经到位,不再出现"坐错椅子"的情况。
  3. 中间那两把没对齐的锁policy->rwsem vs sg_policy->update_lock)就像"剧场大门"的钥匙和"仓库门"的钥匙——两把钥匙开的不是同一扇门,保安拿哪一把都拦不住对方进出。真正的解法是把摆椅子的工作全部完成才开门,而不是寄希望于两把钥匙。

Highlight:风险与注意点

  • 今天不 crash ≠ 没有 bug:标量写入看起来"安全",但 KCSAN/TSAN 仍会把它标为 data race;它正潜伏等待下一次有人在这函数里解引用指针就立刻引爆。
  • 历史已踩过同类问题ab2f7cf141aa 当年修过同样形态的 race,说明 sugov_start() 的初始化顺序本来就对 race 高度敏感;16a03c71bba0 的"合并循环"看似重构,实则回退了那次修复。
  • stable 标注:补丁带 Cc: stable@vger.kernel.org,因为问题在所有受 16a03c71bba0 影响的 stable 分支上同样存在,回流优先级高。
  • 测试覆盖难点:单纯 functional 测试很难发现这条 race,因为读到的"几乎为零"的标量只会让频率略偏,肉眼难察。需要 KCSAN、stress + cpufreq shared policy 的组合测试才能稳定复现。
  • 后续关注:Christian Loehle 与 Rafael J. Wysocki 的两封回复正文均为引用 patch 文本,正文未显示实质评论;需要关注后续 v2 是否带 Acked-by / Reviewed-by,以及是否有测试 patch 跟进。

一句话总结

sugov_start() 的"初始化 + 注册 hook"重新拆成两阶段,先把每个 sibling 的 sugov_cpu 全部 memset 完整,再统一通过 cpufreq_add_update_util_hook() 暴露给 scheduler,避免 sugov_update_shared() 在 RCU-sched 路径下读到尚未初始化的标量字段,关闭 16a03c71bba0 重新引入的 latent race。