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 知识结构图

下面这张图总结了本章的核心逻辑:从传统自旋锁的功耗问题出发,引出四种低功耗优化策略,以及它们之间的演进关系。

低功耗自旋锁设计——知识结构图 传统自旋锁(忙等待) CPU空转,功耗浪费70% 核心问题:空转无产出,功耗与性能失衡 自适应自旋锁 根据历史持有时间动态决策 基于事件自旋锁 WFE/SEV指令对,事件驱动 超时机制 设定最大等待时间,超时返回 滑动平均统计持有时间 阈值比较:自旋 vs 休眠 典型阈值:5~10μs WFE进入低功耗等待 SEV唤醒等待核 注意惊群效应 中断上下文:10~50μs 实时任务:100~500μs 非实时任务:1~10ms 休眠唤醒策略(终极方案) 等待队列 + schedule() + wake_up()

从这张图可以看得很清楚:四种策略不是互斥的,而是层层递进的关系。传统自旋锁是基础,自适应和基于事件是优化,超时机制是安全网,休眠唤醒是终极方案。在实际项目中,我通常会把它们组合成一个“混合锁”,根据运行时状态动态切换。

最后提醒一句:低功耗自旋锁的调优一定要结合硬件平台。不同架构的WFE行为、缓存一致性延迟、中断响应时间都不一样。我建议你在目标板上跑一个微基准测试,把实际数据拿到手再决策。别光看理论,实践出真知。