0/8 已展开

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 路径上的两个独立并发缺陷:

  1. rt_mutex_schedule()current->sched_rt_mutex 未建立时就触发 WARN。requeue PI 让 futex_q 暂时离开 plistfutex_do_wait() 误判为"被唤醒"而跳过 schedule(),但 waiter 紧接着要在 rt_mutex_wait_proxy_lock() 里再次阻塞,导致这次 rtmutex 调度前没有 pre_schedule 准备。
  2. 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() 前后,绕开 futex schedule() 缺失,保证 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/2Q_REQUEUE_PI_DONE 分支前后包 rt_mutex_pre_schedule / rt_mutex_post_schedulertmutex 二次阻塞前的调度准备
2/2futex_requeue_pi_complete()LOCKED 跳过 rcuwait_wake_up防止栈 q 的 use-after-free

关键实现

patch 1 改动点(kernel/futex/requeue.cfutex_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_PROGRESSDONEWAIT)继续走 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) 覆盖 rcuwait TASK_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 的语义。