第2章:原子操作与内存屏障
各位同学,今天我们来聊聊多核编程里最绕不开的两个话题——原子操作和内存屏障。说实话,这两个东西我早年做芯片验证时也吃过不少苦头。你想想看,明明代码逻辑看起来没问题,跑起来就是各种诡异的数据错乱。后来才明白,问题出在硬件层面,不是软件能解决的。
2.1 CAS操作原理
CAS,全称是Compare-And-Swap,比较并交换。它的核心思想很简单:先比较,再交换。如果当前值等于预期值,就更新成新值;否则什么都不做。整个过程是原子的,不会被中断。
CAS的伪代码实现:
bool CAS(int* addr, int expected, int new_val) {
if (*addr == expected) {
*addr = new_val;
return true;
}
return false;
}
我在项目中遇到过一个问题:一个计数器被多个线程同时更新,用CAS实现自增时,如果并发太高,CAS会反复失败重试。说白了就是「忙等」,浪费CPU周期。后来我改用Fetch-And-Add指令,性能直接翻倍。
个人经验:CAS适合低冲突场景。如果冲突率高,建议用硬件支持的原子加法指令,比如x86的LOCK XADD。
2.2 Load-Linked / Store-Conditional
LL/SC是另一种原子操作机制,常见于ARM、PowerPC等架构。它不像CAS那样直接比较内存值,而是分两步走:
- Load-Linked (LL):读取内存地址,并标记该地址被「监控」
- Store-Conditional (SC):尝试写入,如果该地址在LL之后没有被其他核修改过,写入成功;否则失败
为什么会有LL/SC?因为CAS在某些架构上实现起来代价太高。我记得有一次调试ARM平台上的锁实现,发现CAS指令竟然需要模拟,性能惨不忍睹。换成LL/SC后,延迟直接降了一个数量级。
注意:LL/SC有个坑——它可能因为中断、上下文切换等原因「虚假失败」。也就是说,即使没人修改,SC也可能失败。所以通常需要配合循环重试。
2.3 内存屏障类型与作用
内存屏障,也叫内存栅栏。它的作用是保证内存访问的顺序。你想想看,现代CPU为了性能,会乱序执行指令。单核下没问题,多核下就乱套了。
常见的屏障类型:
| 类型 | 作用 | 典型场景 |
|---|---|---|
| 读屏障 (Load Barrier) | 保证屏障前的读操作先完成 | 读取共享标志位 |
| 写屏障 (Store Barrier) | 保证屏障前的写操作先完成 | 写入数据后更新标志 |
| 全屏障 (Full Barrier) | 保证读写操作都按顺序完成 | 锁操作、同步点 |
我曾经在调试一个高性能消息队列时,发现消费者读到的数据总是「半新半旧」。排查了半天,原来是生产者写完数据后没有加写屏障,消费者读到的标志位已经更新了,但数据还没写进去。嗯,这就是典型的「写后读」乱序问题。
避坑指南:我曾经在x86上写了一个无锁队列,测试一切正常。移植到ARM上就崩了。原因就是x86的内存模型比较强,而ARM是弱内存模型,需要显式加屏障。所以跨平台开发时,千万别偷懒。
2.4 编译器屏障
除了CPU乱序,编译器也会乱序。编译器为了优化,可能会重新排列你的代码。比如:
// 原始代码
data = 42;
flag = 1;
// 编译器可能优化成
flag = 1;
data = 42;
这种优化在单线程下没问题,多线程下就出事了。编译器屏障就是告诉编译器:别动我这里的顺序。
在C/C++中,可以用asm volatile("" ::: "memory")来插入编译器屏障。Linux内核里有个barrier()宏,就是干这个的。
我的习惯:在写多核代码时,我通常会同时加CPU屏障和编译器屏障。虽然看起来冗余,但能避免很多隐晦的bug。你想想看,一个bug在线上跑了一个月才复现一次,那调试成本得多高?
知识体系图
下面这张图展示了本章的核心逻辑关系:
最后提醒一句:原子操作和内存屏障是双刃剑。用好了,性能飞起;用不好,bug藏得比谁都深。我见过太多人为了炫技,在不需要的地方乱加屏障,结果性能反而更差。记住:能不用就不用,用就用到点子上。
公众号:蓝海资料掘金营,微信deep3321