技术指南:从自旋锁到睡眠锁——RISC-V阻塞锁与PREEMPT_RT优先级继承的Linux实践
一、引言
前两篇分别完成了硬件原子原语到内核自旋锁、AMO 宽度扩展到 Zabha/Zacas 的衔接:qspinlock 借助子字原子与 CAS 把自旋等待造成的缓存流量压到最低,但它仍然属于忙等。自旋锁成立的前提是临界区足够短——短于一次调度事件、短于中断处理路径;当持锁代码本身可能睡眠,或者系统以最坏情况延迟而非平均吞吐作为目标时,忙等必须让位于阻塞。本文讨论阻塞侧的实现:rt_mutex 的优先级继承(Priority Inheritance,PI)协议、支撑用户态 pthread 互斥量的 PI futex,以及 PREEMPT_RT 配置下锁版图的整体改写与 RISC-V 的启用路径。
二、忙等锁的适用边界与优先级反转
内核自旋锁分两层:raw_spinlock_t 保持自旋语义,临界区内不允许睡眠、不允许被抢占;spinlock_t 在非实时配置下退化为 raw_spinlock_t。这一约束源自实现而非风格:等待者占用 CPU 空转,若持有者进入睡眠,等待时间便不受临界区长短控制。选择原则因此明确:临界区内需要睡眠或执行耗时操作时必须改用阻塞锁;临界区极短且上下文不可调度时才允许自旋。
阻塞侧的另一约束是持有者可被抢占。mutex 的持有者随时可能被换出,临界区不再隐含关抢占语义;per-CPU 数据的保护需要 local_lock 或迁移禁用配合,而不能依赖锁本身——local_lock 在实时配置下映射为保持可抢占的 per-CPU 自旋锁,承担的正是这一职责。
优先级反转是阻塞锁引入后的核心问题。设三个任务:高优先级 A、中优先级 B、低优先级 C,C 持有 A 所需的锁。A 阻塞后 C 得以运行,但 B 随时可以抢占 C——此时被延迟的实际上是优先级更高的 A,且延迟时长取决于 B 的行为,无法给出上界。这一问题在忙等场景同样存在:等待者在自旋,持有者被 B 换出,自旋等待退化为无界等待。区别在于,阻塞场景下内核有机会在调度层面修正它,这正是 rt_mutex 存在的理由。
三、rt_mutex 与优先级继承协议
PI 协议的规则是:任务阻塞在锁上时,其调度参数临时传播给锁的持有者;持有者释放锁后立即恢复原优先级。前述场景中,A 阻塞在 C 持有的锁上,C 继承 A 的优先级,B 不再能抢占 C,无界反转被限制为有界。PI 沿锁的所有权构成链:任务最多阻塞在一个锁上,因此链只合并、不分叉;持有者本身阻塞在另一把锁上时,继承的优先级继续向上传播。
rt_mutex 用锁的 owner 字段跟踪状态:NULL 表示空闲,task 指针表示持有,bit0 置位表示存在等待者。无竞争的获取与释放走快路径,单次 cmpxchg 完成,不触碰任何自旋锁;这一路径要求架构提供 cmpxchg 原语。RISC-V 上的 cmpxchg 建立在 AMO 与 LR/SC 之上,上篇引入的 amocas 正是这条基线的一部分。竞争发生后进入慢路径:等待者进入按优先级排序的红黑树,由 wait_lock 保护,内核在每次最高优先级等待者变化时重算持有者的有效优先级。解锁时,锁的潜在所有权直接分配给最高优先级等待者,由其被唤醒后完成获取——这一处理避免了高优先级任务在锁释放点与原持有者之间的反复切换。当前主线中 mutex 的阻塞路径即建立在 rt_mutex 之上,这也意味着优先级继承不是 PREEMPT_RT 专属能力,而是阻塞锁的常规设施。
四、PI futex:用户态阻塞锁的内核慢路径
用户态 pthread 互斥量在启用 PTHREAD_PRIO_INHERIT 属性后走 PI futex 路径。快路径完全在用户态完成:锁值为 0 表示空闲,值为线程 TID 表示持有,原子操作切换;这与普通 futex 锁一致。快路径失败后进入内核:FUTEX_LOCK_PI 为该锁地址挂接 PI 状态并在其下构造 rt_mutex,把持有者设为 rt_mutex 的 owner、在 futex 字中置 FUTEX_WAITERS 位,随后阻塞方在 rt_mutex 上排队,继承协议随之生效;FUTEX_UNLOCK_PI 则由内核代为解锁并唤醒等待者。持有者意外退出时,内核清理 rt_mutex 并把锁移交给下一个等待者,futex 字中置 FUTEX_OWNER_DIED 位交由用户态善后。robust 与 PI 是互斥量两个正交属性,四种组合均合法。
条件变量的 PI 支持需要额外的重排队操作:pthread_cond_broadcast 若逐个唤醒等待者再竞争互斥量,会出现 rt_mutex 有等待者而无持有者的窗口,破坏继承逻辑。FUTEX_WAIT_REQUEUE_PI 与 FUTEX_CMP_REQUEUE_PI 配对使用,在内核内把等待者从非 PI futex 直接迁移到 PI futex 并完成代理加锁,消除该窗口。
五、PREEMPT_RT 下的锁版图与 RISC-V 现状
PREEMPT_RT 对锁的改造分两个节点合入主线。5.15 合入核心锁代码("locking/rtmutex: Add mutex variant for RT",commit bb630f9f7a7d):启用 PREEMPT_RT 时,mutex、ww_mutex、rw_semaphore、spinlock、rwlock 均被替换为基于 rtmutex 的变体。6.12 是启用节点:printk 线程化(nbcon 控制台)补齐最后一个缺口后,x86、arm64、RISC-V 三个架构声明 ARCH_SUPPORTS_RT,CONFIG_PREEMPT_RT 首次可在主线内核对这三个架构开启,RISC-V 侧对应提交 2638e4e6b182("riscv: Allow to enable PREEMPT_RT");LoongArch 在 6.13 跟进,ARM 与 PowerPC 此前均未达到条件。
实时配置下的语义变化有三点。其一,spinlock_t 成为睡眠锁:持锁时仅禁用 CPU 迁移,竞争时任务阻塞并把调度参数沿 PI 传播给持有者;raw_spinlock_t 保留自旋语义,其实现即前两篇讨论的 ticket 与 queued spinlock 路径。其二,中断处理强制线程化,处理线程默认以 SCHED_FIFO、优先级 MAX_RT_PRIO/2(即 50)运行;线程化期间中断源需在控制器中被遮蔽,遮蔽能力属平台中断控制器规格,选型时以具体产品文档为准,例如玄铁 C950 产品页公开的 AIA 1.0 中断架构与每核 IMSIC 配置。其三,调度器接管尽可能多的执行上下文,剩余不可抢占区域收缩至入口代码、调度器自身与低层中断处理;RCU 在该配置下改为可抢占实现,回调处理交由独立线程执行。
六、坑位与关键结论
坑位:
- rt_mutex 快路径要求 cmpxchg。RISC-V 上这取决于硬件 AMO 能力与工具链编码,仅有 Kconfig 选项不能保证快路径生效,能力判定需落到运行期探测。
- PI 只沿 rt_mutex 系锁传播。普通信号量的持有者被抢占时不参与继承;混用阻塞原语时,反转保护并不覆盖全部路径。
- spinlock_t 与 raw_spinlock_t 在实时配置下语义分叉。为非实时内核写的驱动若在自旋锁临界区内引入可睡眠调用,切换实时配置后会暴露;反之在 raw 区域调用可睡眠代码同样违规,两类锁不可凭习惯互换。
- PI futex 的锁值编码为线程 TID,语义由内核维护,用户态不得自行改写持有者字段;跨实现的兼容行为以内核文档为准。
- CONFIG_PREEMPT_RT 在 Kconfig 中可见不等于架构可用:主线仅对 select ARCH_SUPPORTS_RT 的架构开放该选项,交叉编译时需确认目标架构的合入版本。
关键结论:从自旋锁到睡眠锁不是单个锁类型的替换,而是"持有者必须可被调度"这一前提的系统性改造。PI 协议把优先级沿锁所有权链向上传播,把无界反转压回有界;PREEMPT_RT 把尽可能多的内核上下文交给调度器。对 RISC-V 平台而言,6.12 起实时路径在主线可用,rt_mutex 快路径所需的 cmpxchg 与前篇引入的 amocas 落在同一条原子指令基线上——硬件扩展与锁实现的衔接至此闭合。
浙公网安备 33010602011771号