0/9 已展开

LLM 分析

sched/psi:修复 rtpoll 销毁路径上的三处竞态

系列概况

  • 标题:[PATCH 0/3] sched/psi: Fix rtpoll teardown races
  • 作者:Guopeng Zhang zhangguopeng@kylinos.cn(发信地址 guopeng.zhang@linux.dev
  • 版本:v1(标题无 vN 标记)
  • 规模:cover + 3 个 patch
  • 修改文件:kernel/sched/psi.c
  • 代码统计:28 行改动,19 insertions(+), 9 deletions(-)
  • Message-ID:cover.1784277342.git.zhangguopeng@kylinos.cn,base-commit 1a1757b76427f6201bfe0bf1bea9f7574f332a93
  • 完整性:共 9 封邮件 = cover + 3 patch + Suren Baghdasaryan 逐 patch 评审 3 封 + 作者回复 2 封;无 Reviewed-by/Acked-by,尚未合入

补丁目的

PSI(Pressure Stall Information)的 rtpoll 机制由一个 psimon kthread worker + 一个 one-shot timer 驱动。销毁最后一个 trigger 时,psi_trigger_destroy() 必须在 rtpoll_trigger_lock 下把 rtpoll_task 置 NULL,但 kthread_stop() 又必须在放锁之后调用——因为 worker 自己(psi_rtpoll_work())也要取同一把锁,否则死锁。

这就留下一个「已放锁、旧 worker 还没死」的窗口,新 trigger 可以在此期间装载替换 worker,形成新旧两个 worker 短暂并存。本系列修的正是这个窗口里的三处独立竞态:

  1. 共享的 rtpoll_wakeup 被即将退出的旧 worker 消耗掉,替换 worker 收不到唤醒,rtpoll 停摆。
  2. 无锁的 psi_schedule_rtpoll_work() 在 RCU 读端看到旧 rtpoll_task,随后 mod_timer() 晚于 timer_delete(),留下一个「幽灵 timer」。
  3. rtpoll_scheduledkthread_stop() 之后才清零,会覆盖替换 worker 已经建立的状态,导致后续 task change 无谓地重新 arm timer。

旧流程的问题

   psi_trigger_destroy()  (last trigger)          concurrent actors
   +---------------------------------------+
   | lock(rtpoll_trigger_lock)             |
   |   rcu_assign_pointer(rtpoll_task,NULL)|
   |   timer_delete(rtpoll_timer)   <-(a)  |
   | unlock                                |
   |   synchronize_rcu()            <-(b)  |
   |   kthread_stop(old_worker)            |--> new trigger installs
   |   atomic_set(rtpoll_scheduled,0)<-(c) |    replacement worker
   +---------------------------------------+
        |               |               |
        v               v               v
   (a)+(b) reader   old worker eats  (c) clears flag that
   mod_timer AFTER  the shared       belongs to the NEW
   delete => stale  wakeup, exits    worker => needless
   timer pending    => new worker    rearm on next
                    sleeps forever   task change

新流程

   psi_trigger_destroy()  (last trigger)
   +--------------------------------------------+
   | lock(rtpoll_trigger_lock)                  |
   |   rcu_assign_pointer(rtpoll_task, NULL)    |
   |   synchronize_rcu()      <- wait readers   |  [patch 2]
   |   timer_delete_sync()    <- drain callback |  [patch 2]
   |   atomic_set(rtpoll_scheduled, 0)          |  [patch 3]
   | unlock                                     |
   |   kthread_stop(task_to_destroy)            |
   |   if (!task_to_destroy) synchronize_rcu()  |
   +--------------------------------------------+
        |
        v
   replacement worker can only be published AFTER unlock;
   by then timer is drained and scheduled flag is clean

   psi_rtpoll_worker():                              [patch 1]
   +------------------------------------------------+
   | wait_event(rtpoll_wait,                        |
   |     atomic_read(rtpoll_wakeup) ||   <- observe |
   |     kthread_should_stop())                     |
   | if (kthread_should_stop()) break;   <- stop 1st|
   | if (cmpxchg(rtpoll_wakeup,1,0) != 1) continue; |
   | psi_rtpoll_work(group);             <- must run|
   +------------------------------------------------+

Patch 概览

#主题核心改动Fixes
1/3Avoid losing wakeups during rtpoll worker replacementwait 条件只 atomic_read 观察;stop 检查在消费之前;消费成功必跑 work461daba06bdc
2/3Prevent stale timer rearm after rtpoll teardown持锁下先 synchronize_rcu()timer_delete_sync()8f91efd870ea
3/3Avoid clobbering rtpoll_scheduled during teardownatomic_set(rtpoll_scheduled, 0)kthread_stop() 之后挪到持锁排空 timer 之后710ffe671e01

三个 patch 有顺序依赖:3/3 建立在 2/3 已把 synchronize_rcu()/timer_delete_sync() 移进锁内的前提上。

关键实现

Patch 1/3 — 共享 wakeup 不再被旧 worker 「偷吃」

while (true) {
    wait_event_interruptible(group->rtpoll_wait,
        atomic_read(&group->rtpoll_wakeup) ||   /* 只观察 */
        kthread_should_stop());

    if (kthread_should_stop())
        break;                                  /* 先判停,不消费 */

    /* 消费之后必须跑 work,替换 worker 才不会丢唤醒 */
    if (atomic_cmpxchg(&group->rtpoll_wakeup, 1, 0) != 1)
        continue;

    psi_rtpoll_work(group);
}

旧代码把 atomic_cmpxchg(&rtpoll_wakeup, 1, 0) 直接写在等待条件里:一旦条件求值成功,wakeup 就已经被吃成 0。若此时 kthread_should_stop() 也成立,旧 worker 会直接 break 退出——那个 wakeup 就凭空消失了。而 one-shot timer 已经 fire、rtpoll_scheduled 仍为 1,替换 worker 于是永远等不到唤醒,rtpolling 停摆。

新代码把「醒来判断」和「消费动作」拆开,并保证 cmpxchg 成功即执行 psi_rtpoll_work()

Patch 2/3 — RCU 宽限期必须跨越 timer 删除

rcu_assign_pointer(group->rtpoll_task, NULL);
/*
 * 等待 psi_schedule_rtpoll_work() 要么看到 NULL,
 * 要么已经完成 mod_timer;持锁同时挡住新 trigger 复用 timer
 */
synchronize_rcu();
timer_delete_sync(&group->rtpoll_timer);
...
mutex_unlock(&group->rtpoll_trigger_lock);

/* last-trigger 路径上面已经等过 RCU 读者了 */
if (!task_to_destroy)
    synchronize_rcu();

psi_schedule_rtpoll_work()rcu_read_lock() 下读 rtpoll_task,读到非 NULL 就 mod_timer()。旧顺序是「先 timer_delete() → 放锁 → synchronize_rcu()」,读者完全可能在 delete 之后才 arm timer。新顺序把 synchronize_rcu() 提到 delete 之前且放在锁内:宽限期结束后不可能再有新的 mod_timer(),随后 timer_delete_sync() 把已在执行的回调也 drain 干净。持锁还保证新 trigger 拿不到锁、无法在旧 timer 清理完成前复用它。

Patch 3/3 — rtpoll_scheduled 在锁内清零

    synchronize_rcu();
    timer_delete_sync(&group->rtpoll_timer);
    atomic_set(&group->rtpoll_scheduled, 0);   /* 移到这里,锁内 */
}
mutex_unlock(&group->rtpoll_trigger_lock);
...
kthread_stop(task_to_destroy);                 /* 之后不再动 flag */

kthread_stop() 只能在锁外调用,但从 unlock 到 stop 返回之间,替换 worker 可能已经发布并 arm 好自己的 timer。此时旧的 atomic_set(rtpoll_scheduled, 0) 就把新 worker 的状态抹掉:flag=0 而 timer pending,下一次 task change 看到 flag=0 就再 mod_timer() 一次,纯属浪费。挪进锁内后,新 trigger 拿到锁时看到的一定是干净且一致的状态。

类比

把 rtpoll worker 想成共用一个门铃和一本值班簿的夜班保安

  • 老板要换班:先在办公室(持锁)划掉旧保安的名字,但旧保安走人(kthread_stop)必须等老板开门(放锁)——因为旧保安进办公室交接也要用同一把钥匙。这段空档里,新保安可能已经刷卡上岗,两人同时在岗。
  • Patch 1:门铃只有一个按钮。旧保安听到铃声不该顺手按掉——他要下班了,按掉铃就等于把新保安的呼叫吞了,新保安在值班室睡到天亮。改成:先看自己是不是要下班,是就直接走;只有决定去干活的人才按掉铃,而且按掉就必须真去开门。
  • Patch 2:楼道监控回放(RCU 读端)里还看得见旧保安的工牌,路人(task change)照着回放去按门铃。老板必须等监控回放全部结束(synchronize_rcu)再拆门铃(timer_delete_sync),否则拆完还会有人按响一个没人应答的铃。
  • Patch 3:值班簿只有一本。旧保安下班时划掉自己那一栏本没错,但如果新保安已经写上自己的班次,这一划就把新记录抹了。改成在老板开门放人之前、门铃已拆之后就把簿子擦净,新保安进门写的内容再没人动。

Highlight:风险与注意点

  1. 复现困难,维护者当场质疑:Suren 在 2/3 的回复里明说「我看过一份 AI 生成的问题报告,觉得是合理的;但我注入延迟之后仍然复现不出来。你能复现吗?能给 reproducer 吗?」cover letter 里的 A/B 数据依赖临时 instrumentation(人为把 RCU 读者停留 10 ms 扩大窗口),且明确声明不随系列提交。要进 tip tree,几乎肯定需要补一份可共享的复现脚本或 selftest。
  2. changelog 文风被点名:Suren 直接吐槽 quiesced? Don't you just love these AI generated changelogs?。v2 应改用平实措辞,少用 quiesced / unpublish / publish a worker 这类抽象词。
  3. 术语歧义:1/3 的 both workers 被要求写清楚是「正在被停止的旧 worker」和「psi_trigger_create() 新建的 worker」,否则读者会误以为是两个 cgroup 的 worker。
  4. 锁内长操作:patch 2 把 synchronize_rcu()timer_delete_sync() 都塞进 rtpoll_trigger_lock。虽然是 mutex 且宽限期通常很短,但这是评审时最可能被追问的设计点——尤其在有大量 cgroup 同时销毁 trigger 的场景下,销毁延迟会明显变长,需要给出量化数据。
  5. 三处 Fixes 跨度大:461daba06bdc、8f91efd870ea、710ffe671e01 分属不同时期的 psi 改动,说明这块状态机被多次增量修补。stable 回移时需要按 patch 分别评估,且 3/3 依赖 2/3,不能单独 backport。
  6. 待跟进:目前 9 封邮件中没有任何 Reviewed-by/Acked-by,作者的两封回复也只回了 1/3 和 2/3,3/3 的评审意见尚无公开答复;系列处于「等 v2」状态。

版本变化

线程内只有 v1(无 vN 标记)。评审已积累三类明确要求:改写 changelog 文风、澄清 both workers 指代、提供可复现的 reproducer。若发 v2,预期改动集中在 commit message 与测试材料,代码逻辑本身尚未被否定。

一句话总结

本系列用三步(唤醒先判停后消费且消费必执行、持锁下 synchronize_rcu() + timer_delete_sync()rtpoll_scheduled 清零挪进锁内)修掉 psi_trigger_destroy() 放锁窗口里新旧 rtpoll worker 并存导致的丢唤醒、幽灵 timer 和状态互踩三处竞态;维护者认可问题合理但无法复现,要求补 reproducer 并重写 changelog。