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
ldaxr/stlr),否则多核之间看不到对方的修改。
4.2 互斥锁与信号量:让出 CPU 的同步
自旋锁是「死等」,互斥锁和信号量则是「等的时候去睡一觉」。任务拿不到锁,就主动挂起,让 CPU 去跑别的任务。等锁释放了,再被唤醒。
| 特性 | 自旋锁 | 互斥锁 | 信号量 |
|---|---|---|---|
| 等待方式 | 忙等待(轮询) | 阻塞睡眠 | 阻塞睡眠 |
| 持有时间 | 极短 | 可长可短 | 可长可短 |
| 能否在中断中用 | 可以 | 不可以 | 不可以(部分RTOS支持) |
| 资源计数 | 只有0/1 | 只有0/1 | 可计数(N) |
| 优先级反转 | 无 | 有(需协议处理) | 有 |
互斥锁和信号量最大的区别在于:互斥锁有「所有权」概念——谁拿了谁释放,而且支持优先级继承,能解决优先级反转问题。信号量更像一个计数器,可以用来管理多个资源。
我曾经在一个项目中,用信号量来管理 DMA 通道池。系统有 4 个 DMA 通道,信号量初始值设为 4。每个任务申请通道时 take(),用完 give()。简单、直观、不出错。
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 指令。每种屏障的语义和开销都不一样。
volatile 和 __sync_synchronize() 之后才解决。
4.6 知识体系总览
下面这张图把本章的同步机制按「等待方式」和「适用场景」做了分类。你可以把它当作选型参考。
4.7 选型决策清单
最后,我整理了一个快速决策清单。遇到同步问题时,按这个顺序问自己,就能找到合适的机制。
- 临界区代码有多长?——极短(几十条指令)→ 自旋锁;较长 → 互斥锁/信号量
- 能否在中断上下文中执行?——能 → 自旋锁或原子操作;不能 → 互斥锁/信号量
- 是读多写少吗?——是 → 读写锁;否 → 普通互斥锁
- 只是简单的计数或标志位?——是 → 原子操作 + 内存屏障
- 需要管理多个同类资源?——是 → 信号量(计数型)
- 担心优先级反转?——是 → 使用支持优先级继承的互斥锁
记住,没有银弹。每种机制都有它的适用边界。我见过太多人不管三七二十一,所有地方都用同一个锁——结果要么性能惨不忍睹,要么死锁频发。多花几分钟做选型,能省下几天的调试时间。