4、低功耗自旋锁设计:自适应自旋锁、基于事件的自旋锁、超时机制与休眠唤醒策略
多核同步,锁是绕不开的话题。但传统自旋锁有个毛病——它会让CPU空转。你想想看,一个核在那死等,功耗却一点没降。这在嵌入式设备里是致命的。我早年做一款工业网关,四核Cortex-A53,就因为自旋锁空转,整机功耗硬生生多了1.2W。后来痛定思痛,才有了今天要聊的这套低功耗自旋锁方案。
4.1 传统自旋锁的功耗陷阱
先看一个典型场景:核A持有锁,核B想获取同一把锁。传统自旋锁会让核B在一个循环里反复读取锁变量,直到锁释放。
// 传统自旋锁——核B的视角
while (atomic_read(&lock->val) != 0) {
// CPU空转,功耗拉满
cpu_relax(); // 只是给编译器一个提示,硬件照转
}
这个 cpu_relax() 在ARMv8上就是个 yield 指令,它告诉微架构可以放松一下流水线。但说实话,效果有限。我实测过,一个核空转等待100μs,功耗大约是同频率下执行有效计算的70%。如果系统里有多个核都在自旋,那功耗就相当可观了。
核心问题:自旋等待期间,CPU流水线、缓存、TLB都在全速运转,但没产出任何有效结果。这是典型的“忙等待”功耗浪费。
4.2 自适应自旋锁
怎么解决?第一个思路是:别让CPU一直傻转,给它一个“自适应”策略。
自适应自旋锁的核心思想很简单:根据锁持有时间的历史统计,动态决定是自旋等待还是直接休眠。如果锁通常很快释放(比如几个指令周期),那就自旋;如果锁通常被持有较长时间(比如几十微秒以上),那就别转了,直接让出CPU。
struct adaptive_spinlock {
atomic_t val;
u64 avg_hold_ns; // 平均持有时间(纳秒)
u32 sample_cnt; // 采样计数
};
void adaptive_spin_lock(struct adaptive_spinlock *lock)
{
// 如果平均持有时间小于阈值(比如5μs),自旋等待
if (lock->avg_hold_ns < 5000) {
while (atomic_xchg(&lock->val, 1) != 0) {
// 短自旋,用WFE优化
asm volatile("wfe" ::: "memory");
}
} else {
// 长持有,直接休眠
schedule();
}
}
这里有个关键点:阈值怎么定? 我个人习惯的做法是,在系统启动阶段先跑一段校准。让锁持有者记录实际持有时间,然后算出一个滑动平均值。阈值一般设在5~10μs之间。为什么是这个范围?因为一次上下文切换的开销大约在1~3μs,如果锁持有时间小于切换开销,那自旋反而更划算。
经验之谈:自适应自旋锁的采样窗口不要太大。我一般用最近32次采样的滑动平均,这样既能反映近期趋势,又不会因为一次异常值导致策略剧烈抖动。
4.3 基于事件的自旋锁
自适应自旋锁虽然好,但它有个前提——你得知道锁的平均持有时间。有些场景下这个信息很难获取,或者锁持有时间变化很大。这时候,基于事件的自旋锁就派上用场了。
说白了,就是让等待者不主动轮询,而是等持有者释放锁时发一个“事件信号”来唤醒它。ARM架构里有个现成的指令对:WFE(Wait For Event)和 SEV(Send Event)。
// 基于WFE/SEV的自旋锁——等待者
void wfe_spin_lock(atomic_t *lock)
{
while (1) {
// 先尝试获取锁
if (atomic_xchg(lock, 1) == 0)
break;
// 没拿到,进入WFE等待
while (atomic_read(lock) != 0) {
asm volatile("wfe" ::: "memory");
}
}
}
// 释放者
void wfe_spin_unlock(atomic_t *lock)
{
atomic_set(lock, 0);
// 发送事件,唤醒所有WFE等待的核
asm volatile("sev" ::: "memory");
}
WFE指令会让CPU进入一种低功耗的“事件等待”状态。在这个状态下,CPU的时钟门控会打开,流水线基本停摆,功耗能降到正常运行的10%~20%。直到收到SEV事件或者外部中断,CPU才会被唤醒继续执行。
注意:WFE/SEV是核间通信机制,不是全局广播。如果你有4个核都在等同一把锁,释放者发一次SEV,所有等待核都会被唤醒。但只有一个能拿到锁,其他三个又会进入WFE。这会产生“惊群效应”。我建议在锁竞争激烈时,配合一个等待队列来精确唤醒。
4.4 超时机制
不管是自适应自旋还是WFE等待,都可能遇到一个尴尬情况——锁被持有者死锁了,或者持有者被更高优先级任务抢占了。这时候等待者会无限期等下去。所以,超时机制是必须的。
超时自旋锁的设计思路:给每次自旋等待设一个最大时间,超时了就放弃,返回错误码。调用方可以根据返回值决定是重试、降级还是报错。
int timed_spin_lock(atomic_t *lock, u64 timeout_ns)
{
u64 deadline = get_current_ns() + timeout_ns;
while (1) {
if (atomic_xchg(lock, 1) == 0)
return 0; // 获取成功
if (get_current_ns() >= deadline)
return -ETIMEDOUT; // 超时
// 短自旋,带WFE优化
asm volatile("wfe" ::: "memory");
}
}
超时值怎么设?我一般遵循这个原则:
| 场景 | 建议超时值 | 说明 |
|---|---|---|
| 中断上下文 | 10~50 μs | 中断处理必须快,超时后直接报错 |
| 任务上下文(实时) | 100~500 μs | 实时任务有deadline约束 |
| 任务上下文(非实时) | 1~10 ms | 可以容忍一定延迟 |
| DMA/硬件同步 | 1~100 ms | 硬件操作通常较慢 |
避坑指南:我曾经在一个项目中把超时设成了1ms,结果锁持有者被一个更高优先级的中断抢占了500μs,导致大量超时重试。后来我把超时值改成了2ms,同时给锁持有者所在的中断提高了优先级。问题就解决了。记住:超时值要综合考虑锁持有时间、中断延迟和调度延迟。
4.5 休眠唤醒策略
如果超时了还没拿到锁,怎么办?继续自旋显然不划算。这时候应该让等待者休眠,把CPU让给其他任务。这就是休眠唤醒策略。
休眠唤醒的核心是等待队列。每个锁维护一个等待队列,拿不到锁的任务把自己挂到队列上,然后调用 schedule() 让出CPU。锁释放时,从队列中唤醒一个(或所有)等待者。
struct sleep_spinlock {
atomic_t val;
wait_queue_head_t wq; // 等待队列
};
void sleep_spin_lock(struct sleep_spinlock *lock)
{
while (1) {
if (atomic_xchg(&lock->val, 1) == 0)
return; // 拿到锁了
// 没拿到,准备休眠
DEFINE_WAIT(wait);
prepare_to_wait(&lock->wq, &wait, TASK_UNINTERRUPTIBLE);
// 再次检查锁状态(防止唤醒后锁又被抢走)
if (atomic_read(&lock->val) != 0)
schedule(); // 休眠
finish_wait(&lock->wq, &wait);
}
}
void sleep_spin_unlock(struct sleep_spinlock *lock)
{
atomic_set(&lock->val, 0);
wake_up(&lock->wq); // 唤醒一个等待者
}
这里有个细节:prepare_to_wait 和 schedule 之间有一个竞态窗口。锁可能在检查完 atomic_read 之后、schedule 之前被释放。所以必须在 schedule 返回后再次检查锁状态。这就是所谓的“唤醒丢失”问题。
我的习惯:在休眠唤醒锁里,我通常会用 TASK_UNINTERRUPTIBLE 而不是 TASK_INTERRUPTIBLE。为什么?因为信号量中断的唤醒会让锁获取逻辑变得复杂。除非你的应用需要响应信号,否则用不可中断休眠更安全。
4.6 四种策略的对比与选择
说了这么多,到底该用哪种?我整理了一个对比表,方便你根据场景选择:
| 策略 | 功耗节省 | 延迟 | 适用场景 |
|---|---|---|---|
| 传统自旋锁 | 无 | 最低(μs级) | 锁持有时间极短(<1μs),且功耗不敏感 |
| 自适应自旋锁 | 中等(30%~50%) | 低(μs级) | 锁持有时间可预测,变化不大 |
| 基于事件自旋锁 | 高(70%~80%) | 中等(μs~10μs) | 锁持有时间不确定,但竞争不激烈 |
| 休眠唤醒锁 | 最高(90%+) | 高(ms级) | 锁持有时间长,或任务可被调度 |
你可能会问:能不能把这几种策略组合起来?当然可以。我做过一个混合锁:先自旋一小段时间(比如10μs),如果没拿到就进入WFE等待,再超时了就休眠。这种三级策略在功耗和延迟之间取得了很好的平衡。
核心原则:没有银弹。低功耗自旋锁的设计,本质上是在“功耗”和“延迟”之间做权衡。你的系统对哪个更敏感,就偏向哪个方向。我个人的建议是:中断上下文用自适应自旋锁,任务上下文用休眠唤醒锁,不确定的场景用混合策略。
4.7 知识结构图
下面这张图总结了本章的核心逻辑:从传统自旋锁的功耗问题出发,引出四种低功耗优化策略,以及它们之间的演进关系。
从这张图可以看得很清楚:四种策略不是互斥的,而是层层递进的关系。传统自旋锁是基础,自适应和基于事件是优化,超时机制是安全网,休眠唤醒是终极方案。在实际项目中,我通常会把它们组合成一个“混合锁”,根据运行时状态动态切换。
最后提醒一句:低功耗自旋锁的调优一定要结合硬件平台。不同架构的WFE行为、缓存一致性延迟、中断响应时间都不一样。我建议你在目标板上跑一个微基准测试,把实际数据拿到手再决策。别光看理论,实践出真知。