内存屏障与原子操作:内存序、原子操作、无锁数据结构基础
各位同学,今天我们来聊聊并发编程里最硬核的几个概念——内存屏障、原子操作,还有无锁数据结构。说实话,我刚入行那会儿,觉得这些东西离我很远。直到有一次,我在一个多核ARM平台上调试一个诡异的数据竞争问题,折腾了整整三天……嗯,从那以后,我再也不敢小看内存序了。
一、为什么需要原子操作?
先问大家一个问题:在多线程环境里,对一个全局变量做 count++,安全吗?
不安全。绝对不安全。
你想想看,count++ 在底层其实是三步操作:
- 从内存读 count 到寄存器
- 寄存器加1
- 写回内存
两个线程同时执行这三步,就可能出现「丢失更新」的情况。我在项目中就遇到过,一个统计请求次数的计数器,跑着跑着数值比实际少了将近一半。排查到最后,就是 count++ 惹的祸。
解决方案?用原子操作。
原子操作:不可分割的操作。要么全部执行完,要么一点都没执行。中间不会被其他线程打断。
C11 标准引入了 stdatomic.h,让我们可以方便地使用原子类型和操作。比如:
#include <stdatomic.h>
atomic_int count = 0;
// 原子自增
atomic_fetch_add(&count, 1);
// 原子比较并交换
int expected = 0;
int desired = 1;
atomic_compare_exchange_strong(&count, &expected, desired);
我个人习惯,只要是被多线程共享的变量,一律用原子类型。别嫌麻烦,这是防坑的第一道防线。
二、内存序:比你想象的复杂
原子操作只是第一步。真正让人头疼的,是内存序(memory order)。
为什么会这样?因为现代 CPU 和编译器会为了性能,对内存访问指令进行重排。单线程下没问题,多线程下就乱套了。
举个例子:
// 线程1
data = 42; // 普通写
flag = 1; // 原子写,默认 memory_order_seq_cst
// 线程2
while (flag != 1); // 原子读
assert(data == 42); // 可能失败!
我在一个嵌入式项目中就踩过这个坑。线程1先写数据,再设标志位。线程2看到标志位变了,去读数据,结果读到的还是旧值。为什么?因为 CPU 把 data = 42 和 flag = 1 的顺序给重排了。
C11 定义了六种内存序,我挑最常用的三种说说:
| 内存序 | 含义 | 性能 | 使用场景 |
|---|---|---|---|
memory_order_relaxed |
只保证原子性,不保证任何顺序 | 最快 | 计数器、统计量 |
memory_order_acquire |
之后的读操作不能重排到它前面 | 中等 | 读锁、读标志位 |
memory_order_release |
之前的写操作不能重排到它后面 | 中等 | 写锁、写标志位 |
memory_order_seq_cst |
全局顺序一致,最严格 | 最慢 | 默认值,通用场景 |
我的建议:刚开始用默认的 memory_order_seq_cst 就好。等性能瓶颈出现了,再考虑降级到 acquire/release 甚至 relaxed。别一开始就追求极致性能,正确性永远是第一位的。
三、内存屏障:手动控制顺序
内存屏障(memory barrier),也叫内存栅栏,是一条特殊的指令。它告诉 CPU 和编译器:到这里给我停一下,前面的内存操作必须完成,后面的才能开始。
在 C11 里,我们可以用 atomic_thread_fence 来插入屏障:
// 线程1
data = 42;
atomic_thread_fence(memory_order_release);
flag.store(1, memory_order_relaxed);
// 线程2
while (flag.load(memory_order_relaxed) != 1);
atomic_thread_fence(memory_order_acquire);
assert(data == 42); // 现在安全了
说白了,屏障就是一道「墙」。release 屏障保证它之前的所有写操作,对 acquire 屏障之后的读操作可见。
注意:内存屏障不是银弹。滥用屏障会严重拖慢性能。我曾经在一个高频交易系统中看到有人到处插屏障,结果延迟飙升了3倍。后来我们仔细分析,发现大部分屏障都是多余的。
四、无锁数据结构基础
有了原子操作和内存序,我们就可以构建无锁数据结构了。所谓无锁,不是说不用锁,而是不用互斥锁、信号量这些会导致线程阻塞的同步机制。
无锁数据结构的核心思想:利用原子操作(尤其是 CAS——比较并交换)来协调线程间的访问,避免线程挂起和上下文切换。
来看一个最简单的无锁栈:
#include <stdatomic.h>
typedef struct Node {
int data;
struct Node* next;
} Node;
typedef struct {
atomic_intptr_t head; // 原子指针
} LockFreeStack;
void push(LockFreeStack* stack, Node* node) {
Node* old_head;
do {
old_head = (Node*)atomic_load(&stack->head);
node->next = old_head;
} while (!atomic_compare_exchange_weak(
&stack->head,
(intptr_t*)&old_head,
(intptr_t)node
));
}
Node* pop(LockFreeStack* stack) {
Node* old_head;
do {
old_head = (Node*)atomic_load(&stack->head);
if (old_head == NULL) return NULL;
} while (!atomic_compare_exchange_weak(
&stack->head,
(intptr_t*)&old_head,
(intptr_t)old_head->next
));
return old_head;
}
这里的关键是 atomic_compare_exchange_weak(CAS)。它做三件事:
- 读取当前 head 指针
- 比较是否等于 old_head
- 如果相等,更新为 new_head;否则重试
整个操作是原子的,不会被其他线程打断。
ABA 问题:无锁编程里最经典的坑。线程1读取 head 为 A,然后被挂起。线程2把 A 弹出,又 push 了一个新节点 B,然后又把 B 弹出,重新 push 了 A(地址相同但内容不同)。线程1醒来后,CAS 发现 head 还是 A,就以为没变化,结果把 A 的 next 指向了错误的位置。
解决办法:使用带标签的原子指针(如 atomic_uintptr_t 配合版本号),或者用 hazard pointer 等技术。
五、知识体系总览
说了这么多,我画了一张图帮你理清思路:
从这张图可以看得很清楚:原子操作是基础,内存序控制可见性,内存屏障提供更强的顺序保证。三者结合,才能构建出正确的无锁数据结构。
六、避坑指南
最后,分享几个我亲身踩过的坑:
- 不要迷信无锁:无锁编程的复杂度远高于加锁。我见过太多人为了「无锁」而写出有 bug 的代码。如果你的场景锁竞争不激烈,老老实实用互斥锁反而更安全。
- 测试要跑在多核机器上:单核上很多并发 bug 根本复现不出来。我曾经在单核虚拟机里测试无锁队列,跑了三天都没问题,一上双核 ARM 板子,十分钟就挂了。
- 用工具辅助验证:比如 ThreadSanitizer、Relacy Race Detector,能帮你自动检测数据竞争。别全靠肉眼 review,人眼在并发问题上真的不靠谱。
- 优先用现成的库:如果项目允许,直接用
concurrentqueue、boost.lockfree这些经过大量验证的库。自己造轮子之前,先问问自己:我真的需要吗?
一句话总结:原子操作保证「不被打断」,内存序保证「不乱序」,内存屏障保证「看得见」。三者配合,才能写出正确的并发代码。