4. 多核同步机制:自旋锁、互斥锁、信号量、读写锁、原子操作与内存屏障

多核环境下,最头疼的问题是什么?我告诉你,不是性能,是同步。

两个核同时访问同一个变量,一个在读,一个在写,结果读出来的是半新不旧的数据——这种 bug 极其难复现,也极其致命。我在一个车载项目中就吃过这个亏,两个核抢一个 FIFO 队列,跑了一整天才崩一次,查了三天才定位到是缺少锁保护。

这一章,我把多核同步的几种核心机制掰开揉碎讲清楚。你学完就能明白:什么场景该用自旋锁,什么场景该用信号量,什么时候必须上内存屏障。

4.1 自旋锁:最简单的多核互斥

自旋锁的原理很简单:一个核想进临界区,先检查锁变量。如果锁是开的,就把它关上,然后进去干活。如果锁已经被别人占了,这个核就在原地死等——不停地轮询锁状态,直到拿到为止。

说白了,就是「忙等待」。不睡眠,不切换任务,CPU 一直转。

适用场景:临界区代码极短(几十条指令以内),且持有锁的时间可以预测。

我个人的习惯是:中断处理函数里用自旋锁,任务代码里尽量不用。为什么?因为自旋锁会浪费 CPU 时间。你想想看,一个核在等锁的时候啥正事也干不了,就一直在那转圈。

// 自旋锁的典型实现(ARMv8 汇编风格)
spin_lock:
    mov     w1, #1          // 准备写入 1
1:  ldaxr   w2, [x0]        // 独占加载锁变量
    cbnz    w2, 1b          // 如果非0,说明锁被占,继续等
    stxr    w2, w1, [x0]    // 尝试独占写入1
    cbnz    w2, 1b          // 写入失败则重试
    ret                     // 拿到锁,返回

spin_unlock:
    stlr    wzr, [x0]       // 释放锁,写0
    ret
小技巧:自旋锁的实现一定要用带「独占」和「屏障」的指令(如 ARM 的 ldaxr/stlr),否则多核之间看不到对方的修改。

4.2 互斥锁与信号量:让出 CPU 的同步

自旋锁是「死等」,互斥锁和信号量则是「等的时候去睡一觉」。任务拿不到锁,就主动挂起,让 CPU 去跑别的任务。等锁释放了,再被唤醒。

特性 自旋锁 互斥锁 信号量
等待方式 忙等待(轮询) 阻塞睡眠 阻塞睡眠
持有时间 极短 可长可短 可长可短
能否在中断中用 可以 不可以 不可以(部分RTOS支持)
资源计数 只有0/1 只有0/1 可计数(N)
优先级反转 无 有(需协议处理) 有

互斥锁和信号量最大的区别在于:互斥锁有「所有权」概念——谁拿了谁释放,而且支持优先级继承,能解决优先级反转问题。信号量更像一个计数器,可以用来管理多个资源。

我曾经在一个项目中,用信号量来管理 DMA 通道池。系统有 4 个 DMA 通道,信号量初始值设为 4。每个任务申请通道时 take(),用完 give()。简单、直观、不出错。

注意:不要在中断服务函数里调用互斥锁或信号量的阻塞 API。中断上下文不能睡眠,一睡就死。如果中断里需要同步,请用自旋锁或原子操作。

4.3 读写锁:读多写少的优化方案

你有没有遇到过这种场景:一个共享数据,大部分时候都是读,偶尔才写一次。如果用普通的互斥锁,读和读之间也要互相等——这太浪费了。

读写锁就是为这个场景设计的。它允许多个读者同时进入临界区,但写者必须独占。

// 读写锁的典型用法
rwlock_t my_lock;

void reader_task(void) {
    read_lock(&my_lock);
    // 多个读者可以同时在这里读数据
    read_unlock(&my_lock);
}

void writer_task(void) {
    write_lock(&my_lock);
    // 写者独占,此时不允许任何读者进入
    write_unlock(&my_lock);
}

读写锁的实现比普通锁复杂一些。它内部通常维护两个计数器:读者计数和写者标志。写者要等所有读者退出后才能进入,而新读者在看到有写者在等待时,可以选择等待或直接进入——这取决于实现策略。

嗯,这里要注意:读写锁不是万能的。如果写操作很频繁,读写锁反而比普通互斥锁更慢,因为每次写都要等所有读者退出,调度开销更大。我建议只有在读操作占比超过 80% 时才考虑用读写锁。

4.4 原子操作:无锁编程的基石

原子操作是硬件级别的「读-改-写」指令,中间不会被任何中断或其它核打断。比如 atomic_add、atomic_cmpxchg(比较并交换)这些。

为什么需要原子操作?你想想看,一个简单的 counter++,在汇编层面其实是三条指令:读内存、加1、写回内存。如果在读和写之间被另一个核插了一脚,计数就丢了。

原子操作就是解决这个问题的。它把这三步捆在一起,要么全做,要么不做。

// 原子操作示例(C11 标准)
#include <stdatomic.h>

atomic_int counter = 0;

void increment(void) {
    atomic_fetch_add(&counter, 1);
}

// 或者用更高效的 relaxed 语义(如果你确定不需要顺序一致性)
void increment_fast(void) {
    atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);
}
经验之谈:我习惯把原子操作用在统计计数、标志位、引用计数这些简单场景。一旦逻辑复杂了(比如需要保护一个结构体),还是老老实实用锁。无锁编程的水太深,容易翻车。

4.5 内存屏障:看不见的同步

这是多核编程里最容易被忽视、也最容易出问题的地方。

现代 CPU 和编译器为了性能,会乱序执行指令。你在代码里写的是 A 然后 B,但实际执行可能是 B 然后 A。单核下没问题,因为 CPU 保证结果一致。但多核下,另一个核看到的顺序可能和你期望的完全不一样。

内存屏障就是用来强制排序的。它告诉 CPU 和编译器:在这条指令之前的所有内存操作,必须在这条指令之后的所有内存操作之前完成。

// 内存屏障示例
volatile int flag = 0;
int data = 0;

// 核0:写数据,然后设置标志
data = 42;
__sync_synchronize();   // 全屏障:确保 data 写入在 flag 之前可见
flag = 1;

// 核1:检查标志,然后读数据
while (flag == 0);
__sync_synchronize();   // 全屏障:确保 flag 读取在 data 读取之前
int value = data;       // 此时 value 一定是 42

ARM 架构下有四种屏障指令:DMB(数据内存屏障)、DSB(数据同步屏障)、ISB(指令同步屏障),以及带 scope 的变体。RISC-V 也有 fence 指令。每种屏障的语义和开销都不一样。

避坑指南:我曾经在一个项目中,两个核之间用共享内存通信,没有加屏障。结果核0写完了数据,核1读到的全是旧值。查了两天,最后发现是编译器把写操作优化到了寄存器里,根本没写回内存。加上 volatile 和 __sync_synchronize() 之后才解决。

4.6 知识体系总览

下面这张图把本章的同步机制按「等待方式」和「适用场景」做了分类。你可以把它当作选型参考。

多核同步机制选型图 多核同步机制 忙等待(自旋) 阻塞睡眠 自旋锁 互斥锁 信号量 读写锁 原子操作 内存屏障 选型建议 • 临界区极短(<50条指令)→ 自旋锁 • 临界区较长或可能睡眠 → 互斥锁/信号量 • 读多写少(读>80%)→ 读写锁 • 简单计数/标志位 → 原子操作 + 内存屏障

4.7 选型决策清单

最后,我整理了一个快速决策清单。遇到同步问题时,按这个顺序问自己,就能找到合适的机制。

  1. 临界区代码有多长?——极短(几十条指令)→ 自旋锁;较长 → 互斥锁/信号量
  2. 能否在中断上下文中执行?——能 → 自旋锁或原子操作;不能 → 互斥锁/信号量
  3. 是读多写少吗?——是 → 读写锁;否 → 普通互斥锁
  4. 只是简单的计数或标志位?——是 → 原子操作 + 内存屏障
  5. 需要管理多个同类资源?——是 → 信号量(计数型)
  6. 担心优先级反转?——是 → 使用支持优先级继承的互斥锁

记住,没有银弹。每种机制都有它的适用边界。我见过太多人不管三七二十一,所有地方都用同一个锁——结果要么性能惨不忍睹,要么死锁频发。多花几分钟做选型,能省下几天的调试时间。