内存屏障与原子操作:内存序、原子操作、无锁数据结构基础

各位同学,今天我们来聊聊并发编程里最硬核的几个概念——内存屏障、原子操作,还有无锁数据结构。说实话,我刚入行那会儿,觉得这些东西离我很远。直到有一次,我在一个多核ARM平台上调试一个诡异的数据竞争问题,折腾了整整三天……嗯,从那以后,我再也不敢小看内存序了。

一、为什么需要原子操作?

先问大家一个问题:在多线程环境里,对一个全局变量做 count++,安全吗?

不安全。绝对不安全。

你想想看,count++ 在底层其实是三步操作:

  1. 从内存读 count 到寄存器
  2. 寄存器加1
  3. 写回内存

两个线程同时执行这三步,就可能出现「丢失更新」的情况。我在项目中就遇到过,一个统计请求次数的计数器,跑着跑着数值比实际少了将近一半。排查到最后,就是 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 = 42flag = 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)。它做三件事:

  1. 读取当前 head 指针
  2. 比较是否等于 old_head
  3. 如果相等,更新为 new_head;否则重试

整个操作是原子的,不会被其他线程打断。

ABA 问题:无锁编程里最经典的坑。线程1读取 head 为 A,然后被挂起。线程2把 A 弹出,又 push 了一个新节点 B,然后又把 B 弹出,重新 push 了 A(地址相同但内容不同)。线程1醒来后,CAS 发现 head 还是 A,就以为没变化,结果把 A 的 next 指向了错误的位置。

解决办法:使用带标签的原子指针(如 atomic_uintptr_t 配合版本号),或者用 hazard pointer 等技术。

五、知识体系总览

说了这么多,我画了一张图帮你理清思路:

内存屏障与原子操作知识体系 并发同步基础 原子操作 atomic_load / atomic_store atomic_fetch_add / CAS 内存序 relaxed / acquire / release consume / acq_rel / seq_cst 内存屏障 atomic_thread_fence 编译器屏障 (asm volatile) 无锁数据结构(栈、队列、哈希表)

从这张图可以看得很清楚:原子操作是基础,内存序控制可见性,内存屏障提供更强的顺序保证。三者结合,才能构建出正确的无锁数据结构。

六、避坑指南

最后,分享几个我亲身踩过的坑:

  • 不要迷信无锁:无锁编程的复杂度远高于加锁。我见过太多人为了「无锁」而写出有 bug 的代码。如果你的场景锁竞争不激烈,老老实实用互斥锁反而更安全。
  • 测试要跑在多核机器上:单核上很多并发 bug 根本复现不出来。我曾经在单核虚拟机里测试无锁队列,跑了三天都没问题,一上双核 ARM 板子,十分钟就挂了。
  • 用工具辅助验证:比如 ThreadSanitizer、Relacy Race Detector,能帮你自动检测数据竞争。别全靠肉眼 review,人眼在并发问题上真的不靠谱。
  • 优先用现成的库:如果项目允许,直接用 concurrentqueueboost.lockfree 这些经过大量验证的库。自己造轮子之前,先问问自己:我真的需要吗?

一句话总结:原子操作保证「不被打断」,内存序保证「不乱序」,内存屏障保证「看得见」。三者配合,才能写出正确的并发代码。


公众号:蓝海资料掘金营,微信deep3321