sched discussion
[PATCH v2 0/2] futex/requeue: Fix requeue PI races
LLM 分析
futex/requeue:修复 requeue PI 路径的两处并发缺陷
系列概况
- 标题: [PATCH v2 0/2] futex/requeue: Fix requeue PI races
- 作者: Yao Kai yaokai34@huawei.com
- 版本: v2(v1 → v2)
- 规模: 2 个补丁
- 修改文件:
kernel/futex/requeue.c(唯一一个) - 代码统计: patch 1:8+/0-;patch 2:10+/2-;合计 18+/2-
- Message-ID: 首封
20260722085140.1949077-1-yaokai34@huawei.com,共 8 封邮件 - 完整性: 补丁已发 v2,Peter Zijlstra 与 Sebastian Siewior 已介入评审,作者仍在回复中;尚未见 Reviewed-by / Acked-by
补丁目的
修掉 futex requeue PI 路径上的两个独立并发缺陷:
rt_mutex_schedule()在current->sched_rt_mutex未建立时就触发 WARN。requeue PI 让futex_q暂时离开plist,futex_do_wait()误判为"被唤醒"而跳过schedule(),但 waiter 紧接着要在rt_mutex_wait_proxy_lock()里再次阻塞,导致这次 rtmutex 调度前没有pre_schedule准备。futex_requeue_pi_complete()在发布Q_REQUEUE_PI_LOCKED之后还调用rcuwait_wake_up(),但futex_q是栈分配的,可能早已 out-of-scope,被解引用后触发 KASAN slab-out-of-bounds。
旧流程的问题
正常 rtmutex 等锁会先调 rt_mutex_pre_schedule() 设置 sched_rt_mutex,再 schedule(),保证二次阻塞时不撞 spinlock-nee-rtlock。
requeue PI 路径下,requeue 任务会先 plist_del(&q->list) 把 waiter 从哈希桶摘除、改成另一个状态机、再 plist_add(&q->list) 把它装回去。futex_do_wait() 在这中间检查 plist_node_empty(&q->list),误判为"已被唤醒",跳过 schedule()。但 waiter 紧接着要进入 rt_mutex_wait_proxy_lock() 再次阻塞,由于此前没走 rt_mutex_pre_schedule(),current->sched_rt_mutex 还是空的,触发 rt_mutex_schedule() 的 WARN。
另一面,futex_requeue_pi_complete() 把状态打成 LOCKED 之后才 rcuwait_wake_up(&q->requeue_wait)。栈上的 q 一旦 waiter 看到 LOCKED 就出 futex_wait_requeue_pi()、让 q 离开作用域,rcuwait_wake_up() 仍然会读 q->requeue_wait.task 并把悬空指针交给 try_to_wake_up(),KASAN 在 PREEMPT_RT 下抓到 slab-out-of-bounds。
新流程
- patch 1:在
futex_wait_requeue_pi()的Q_REQUEUE_PI_DONE分支里,直接把rt_mutex_pre_schedule()/rt_mutex_post_schedule()包在rt_mutex_wait_proxy_lock()前后,绕开 futexschedule()缺失,保证 proxy waiter 二次阻塞时调度准备到位。 - patch 2:在
futex_requeue_pi_complete()里,对new == Q_REQUEUE_PI_LOCKED跳过rcuwait_wake_up()。LOCKED仅由requeue_pi_wake_futex()发布;它在发布前已经保存q->task,并随后用wake_up_state(task, TASK_NORMAL)唤醒——TASK_NORMAL覆盖TASK_UNINTERRUPTIBLE,能把已阻塞在 rcuwait 的 waiter 顺带唤醒,不会丢唤醒。
Patch 概览
| Patch | 关键改动 | 修复点 |
|---|---|---|
| 1/2 | 在 Q_REQUEUE_PI_DONE 分支前后包 rt_mutex_pre_schedule / rt_mutex_post_schedule | rtmutex 二次阻塞前的调度准备 |
| 2/2 | futex_requeue_pi_complete() 对 LOCKED 跳过 rcuwait_wake_up | 防止栈 q 的 use-after-free |
关键实现
patch 1 改动点(kernel/futex/requeue.c 的 futex_wait_requeue_pi()):
case Q_REQUEUE_PI_DONE:
/* Requeue completed. Current is 'pi_blocked_on' the rtmutex */
pi_mutex = &q.pi_state->pi_mutex;
/*
* Requeue temporarily removes q from the hash bucket, so
* futex_do_wait() may skip schedule() even though the proxy
* waiter still has to block on the rtmutex.
*/
rt_mutex_pre_schedule();
ret = rt_mutex_wait_proxy_lock(pi_mutex, to, &rt_waiter);
rt_mutex_post_schedule();
并补 #include <linux/sched/rt.h>。
patch 2 改动点(futex_requeue_pi_complete()):
do {
/* ... existing cmpxchg publishing requeue_state ... */
} while (!atomic_try_cmpxchg(&q->requeue_state, &old, new));
#ifdef CONFIG_PREEMPT_RT
/*
* LOCKED is only published by requeue_pi_wake_futex(); it saves
* q->task and uses wake_up_state(TASK_NORMAL), which also wakes
* the TASK_UNINTERRUPTIBLE rcuwait waiter. Skip rcuwait_wake_up()
* here to avoid touching the stack-based futex_q after LOCKED.
*/
if (unlikely(old == Q_REQUEUE_PI_WAIT) &&
new != Q_REQUEUE_PI_LOCKED)
rcuwait_wake_up(&q->requeue_wait);
#endif
LOCKED 状态由 requeue_pi_wake_futex() 自己走 wake_up_state(task, TASK_NORMAL) 完成唤醒;其他完成态(IN_PROGRESS、DONE、WAIT)继续走 rcuwait_wake_up,保持原行为。
race 时序(patch 2 修复目标):
waiter requeue task
------ ------------
futex_wait_requeue_pi()
futex_do_wait()
schedule()
* timeout/signal wakes waiter *
futex_requeue_pi_wakeup_sync()
IN_PROGRESS -> WAIT
rcuwait_wait_event()
requeue_pi_wake_futex()
task = READ_ONCE(q->task)
futex_requeue_pi_complete()
WAIT -> LOCKED
return LOCKED
return
// q lifetime ends here
rcuwait_wake_up()
-> q->requeue_wait.task (STALE)
-> try_to_wake_up() *** KASAN: slab-out-of-bounds ***
修复后,futex_requeue_pi_complete() 看到 new == Q_REQUEUE_PI_LOCKED 直接跳过 rcuwait_wake_up,让 requeue 任务用早已保存的 task 指针走 wake_up_state,从根上断开悬空指针。
类比
把 futex_q 想成餐厅里一张临时放在客人桌上的"预约小票",按座位号(hash桶)登记。服务员(requeue 任务)短暂抽走小票去前台改派别的服务员(rtmutex 代理),客人瞄一眼桌面以为"叫号了"、起身准备离开(futex_do_wait 跳过 schedule()),结果又被领到另一个服务员那继续排队(rt_mutex_wait_proxy_lock),那时系统才后悔没登记这位客人的"rt 偏好"。patch 1 就是在客人转身之前补一张"特殊偏好登记",让后续排队不出错。
patch 2 则是服务员在把预约小票撕掉前,先把客人的手机号抄到本机上(task),之后只通过手机号联系客人,再也不会去翻那张已经销毁的小票——rcuwait_wake_up 就相当于翻小票找人,所以干脆不让它翻。
requeue PI 的状态机可以画成:
+-------------------+
| IN_PROGRESS | q 在 hash 上、还没派发
+---------+---------+
|
v
+-------------------+
| WAIT | requeue 已把 q 从 hash 摘掉
+---------+---------+ waiter 误判为唤醒、跳过 schedule()
|
v
+-------------------+
| DONE | requeue 完成、waiter 已挂在 rtmutex 上
+---------+---------+ (patch 1 在此补 pre_schedule)
|
v
+-------------------+
| LOCKED | 已被目标 rtmutex 持有
+-------------------+ (patch 2:跳过 rcuwait_wake_up)
Highlight:风险与注意点
- Sebastian 在第 7 封指出:
rt_mutex_pre_schedule()的语义是"在 waiter 入队前"准备,让blk_flush_plug()不撞 spinlock-nee-rtlock;而 patch 1 调用点 waiter 已经入队,作者尚未给出 changelog 解释为什么这里依然正确——这是当前最关键的待澄清点。 - Peter 提到
futex_lock_pi()里的同款调用是"为了让 assert 不报警",暗示 patch 1 可能是"压制告警"而非"根因修复",需要进一步区分二者意图,并确认是否需要在入队前就把sched_rt_mutex准备好。 - patch 2 假设
Q_REQUEUE_PI_LOCKED唯一发布点是requeue_pi_wake_futex(),需要确认没有其他路径发布该状态,否则会留下"丢唤醒"漏洞。 - 两个补丁都带
Fixes:与Cc: stable,但 patch 2 的"已保存 task 之后才发布 LOCKED"不变量应在 changelog 中显式串联说明,方便 stable 维护者理解耦合。 - 已有测试:non-RT / PREEMPT_RT 编译通过、原 WARN 不再触发、KASAN 报告消除、futex requeue PI selftest / stress 通过;缺乏 PREEMPT_RT 下的 lockdep / fuzzer 随机交叉测试覆盖。
版本变化
v1 → v2:
- patch 1:放弃"拆分 scheduler helper"的方案,改为直接在
rt_mutex_wait_proxy_lock()前后调用rt_mutex_pre_schedule()/rt_mutex_post_schedule(),更直接(Sebastian 建议)。 - patch 2:扩写注释和 changelog,明确
wake_up_state(TASK_NORMAL)覆盖 rcuwaitTASK_UNINTERRUPTIBLE的等待者,避免读者误以为丢唤醒。
一句话总结
v2 用两处小补丁分别修掉 requeue PI 让 futex 跳过 schedule() 后 rtmutex 调度准备缺失的 WARN,以及 futex_requeue_pi_complete() 在 LOCKED 之后访问栈 q->requeue_wait.task 触发的 KASAN use-after-free,但 patch 1 的调用时序仍需作者在 changelog 中进一步解释为何"waiter 已入队"仍满足 pre_schedule 的语义。