sched discussion
[PATCH 0/3] sched/psi: Fix rtpoll teardown races
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-commit1a1757b76427f6201bfe0bf1bea9f7574f332a93 - 完整性:共 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 短暂并存。本系列修的正是这个窗口里的三处独立竞态:
- 共享的
rtpoll_wakeup被即将退出的旧 worker 消耗掉,替换 worker 收不到唤醒,rtpoll 停摆。 - 无锁的
psi_schedule_rtpoll_work()在 RCU 读端看到旧rtpoll_task,随后mod_timer()晚于timer_delete(),留下一个「幽灵 timer」。 rtpoll_scheduled在kthread_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/3 | Avoid losing wakeups during rtpoll worker replacement | wait 条件只 atomic_read 观察;stop 检查在消费之前;消费成功必跑 work | 461daba06bdc |
| 2/3 | Prevent stale timer rearm after rtpoll teardown | 持锁下先 synchronize_rcu() 再 timer_delete_sync() | 8f91efd870ea |
| 3/3 | Avoid clobbering rtpoll_scheduled during teardown | atomic_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:风险与注意点
- 复现困难,维护者当场质疑:Suren 在 2/3 的回复里明说「我看过一份 AI 生成的问题报告,觉得是合理的;但我注入延迟之后仍然复现不出来。你能复现吗?能给 reproducer 吗?」cover letter 里的 A/B 数据依赖临时 instrumentation(人为把 RCU 读者停留 10 ms 扩大窗口),且明确声明不随系列提交。要进 tip tree,几乎肯定需要补一份可共享的复现脚本或 selftest。
- changelog 文风被点名:Suren 直接吐槽
quiesced? Don't you just love these AI generated changelogs?。v2 应改用平实措辞,少用 quiesced / unpublish / publish a worker 这类抽象词。 - 术语歧义:1/3 的
both workers被要求写清楚是「正在被停止的旧 worker」和「psi_trigger_create()新建的 worker」,否则读者会误以为是两个 cgroup 的 worker。 - 锁内长操作:patch 2 把
synchronize_rcu()和timer_delete_sync()都塞进rtpoll_trigger_lock。虽然是 mutex 且宽限期通常很短,但这是评审时最可能被追问的设计点——尤其在有大量 cgroup 同时销毁 trigger 的场景下,销毁延迟会明显变长,需要给出量化数据。 - 三处 Fixes 跨度大:461daba06bdc、8f91efd870ea、710ffe671e01 分属不同时期的 psi 改动,说明这块状态机被多次增量修补。stable 回移时需要按 patch 分别评估,且 3/3 依赖 2/3,不能单独 backport。
- 待跟进:目前 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。