17、原子操作与内存序:原子类型使用、内存序模型、无锁编程基础、ABA问题

说到并发编程,很多C语言开发者第一反应就是加锁。互斥锁、读写锁、信号量……这些确实是好东西,但有时候它们太重了。我早年做嵌入式通信设备时,有个中断服务程序里需要更新一个共享计数器,加锁?不行,中断里不能sleep。用volatile?也不行,多核环境下volatile保证不了原子性。后来我找到了答案——原子操作。

这一章,我们就来聊聊C11标准引入的原子操作。它不是什么新概念,但在嵌入式领域,用好它能让你的代码既快又稳。

17.1 原子类型:从硬件到语言的桥梁

原子操作,说白了就是“不可分割的操作”。一个原子操作要么全部执行完,要么一点都没执行。不会出现执行到一半被别的线程打断的情况。

C11标准在里定义了一套原子类型。我习惯把它们看作是普通类型的“装甲版”。

普通类型 原子类型 说明
int atomic_int 32位有符号整数
unsigned int atomic_uint 32位无符号整数
long long atomic_llong 64位有符号整数
bool atomic_bool 布尔类型
uintptr_t atomic_uintptr_t 指针大小的无符号整数

使用起来也很直接:

#include <stdatomic.h>

atomic_int counter = ATOMIC_VAR_INIT(0);

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

这里有个细节——ATOMIC_VAR_INIT宏用于初始化。不过C17之后,这个宏被标记为可选了。我个人现在更推荐直接用atomic_init

atomic_int counter;
atomic_init(&counter, 0);
小提示: 在嵌入式平台上,原子类型的实现依赖于硬件指令。比如ARM的LDREX/STREX,x86的LOCK前缀。如果你的MCU不支持这些指令,编译器可能会回退到用锁模拟。嗯,这时候性能优势就没了。

17.2 内存序模型:控制可见性的五把钥匙

原子操作解决了“操作不可分割”的问题,但还有一个问题——内存可见性。一个线程修改了原子变量,另一个线程什么时候能看到?

这就要说到内存序(memory order)了。C11定义了六种内存序,但常用的就五种。我刚开始接触时也觉得头大,后来在项目中踩过几次坑,才真正理解它们的区别。

内存序 含义 开销 适用场景
memory_order_relaxed 只保证原子性,不保证顺序 最低 计数器、统计量
memory_order_consume 数据依赖顺序(很少用) 较低 指针传递(实际少用)
memory_order_acquire 防止后面的读操作重排到前面 中等 读锁、标志位检查
memory_order_release 防止前面的写操作重排到后面 中等 写锁、发布数据
memory_order_acq_rel acquire + release 较高 读-改-写操作
memory_order_seq_cst 全局顺序一致 最高 默认值,通用场景

为什么会有这么多选择?说白了,是为了性能。你想想看,如果每次原子操作都要全局同步,那多核CPU的缓存一致性协议得忙死。放宽约束,让编译器生成更高效的代码。

来看一个实际例子。我在做多生产者-单消费者队列时,就用到了release-acquire语义:

atomic_int flag = 0;
int data = 0;

// 线程A(生产者)
void producer(void) {
    data = 42;                    // 普通写
    atomic_store_explicit(&flag, 1, memory_order_release);  // 释放语义
}

// 线程B(消费者)
void consumer(void) {
    while (atomic_load_explicit(&flag, memory_order_acquire) != 1) {
        // 等待
    }
    // 此时保证能看到data = 42
    printf("%d\n", data);
}

这里的关键是:release保证前面的普通写操作不会被重排到原子写之后;acquire保证后面的普通读操作不会被重排到原子读之前。两者配合,就实现了“先写数据,再设标志”的语义。

警告: 不要滥用memory_order_seq_cst。虽然它最安全,但性能开销也最大。我在一个高频交易项目中,把seq_cst改成acquire-release后,吞吐量提升了30%。当然,前提是你得确保逻辑正确。

17.3 无锁编程基础:不用锁也能同步

无锁编程,听起来很玄乎,其实核心思想就一句话:用原子操作代替锁

为什么要无锁?因为锁会引发线程阻塞、上下文切换、优先级反转……在实时系统中,这些都是灾难。我做过一个无人机飞控,中断里要更新姿态数据,用互斥锁?不行,中断里不能sleep。用自旋锁?也不行,万一锁被低优先级任务持有,系统就死锁了。

无锁数据结构的基本工具是CAS(Compare-And-Swap)。C11里对应的是atomic_compare_exchange_strong

atomic_int value = ATOMIC_VAR_INIT(0);

bool try_update(int expected, int desired) {
    return atomic_compare_exchange_strong(&value, &expected, desired);
}

这个函数会检查value是否等于expected,如果是,就更新为desired,返回true;否则把expected更新为当前值,返回false。整个过程是原子的。

一个经典的无锁栈实现:

struct node {
    int data;
    struct node *next;
};

atomic_uintptr_t head = ATOMIC_VAR_INIT(0);

void push(int data) {
    struct node *new_node = malloc(sizeof(struct node));
    new_node->data = data;
    
    struct node *old_head;
    do {
        old_head = (struct node *)atomic_load(&head);
        new_node->next = old_head;
    } while (!atomic_compare_exchange_weak(&head, 
                (uintptr_t *)&old_head, (uintptr_t)new_node));
}

这里用了atomic_compare_exchange_weak而不是strong版本。为什么?因为weak版本在某些平台上性能更好,允许“伪失败”(spurious failure)。在循环里用weak,配合重试,效率更高。

核心要点: 无锁编程不是真的“无锁”,而是把锁的粒度降到了硬件指令级别。CAS指令本身就是一种“硬件锁”。

17.4 ABA问题:无锁编程的头号陷阱

说到无锁编程,就绕不开ABA问题。我曾经在一个项目中,用无锁链表管理内存块,跑了两天突然崩溃。查了三天,最后发现是ABA问题在作祟。

什么是ABA问题?看这个场景:

  1. 线程A读取栈顶指针为节点A(地址0x1000)
  2. 线程B弹出A,释放内存,再压入一个新节点B(地址0x2000)
  3. 线程B又弹出B,释放内存,再压入一个节点C(地址0x1000)——注意,地址复用了!
  4. 线程A执行CAS,发现栈顶还是0x1000,认为没变化,于是把新节点链接到A后面
  5. 但实际上,栈顶已经不是原来的A了,链表结构被破坏

说白了,CAS只比较了指针的值,没比较指针指向的内容。地址相同不代表内容相同。

解决ABA问题的常见方法:

  • 标记指针(Tagged Pointer):在指针的高位附加一个版本号。每次修改都递增版本号。这样即使地址复用,版本号不同,CAS也会失败。
  • 垃圾回收(GC):延迟释放内存,确保不会立即复用。Java的并发包就是这么做的。
  • 危险指针(Hazard Pointer):每个线程记录自己正在访问的节点,其他线程不能释放这些节点。

在C语言里,标记指针比较实用。比如在64位系统上,地址空间只用到了低48位,高16位可以用来存版本号:

// 假设指针在低48位,版本号在高16位
#define TAG_MASK  0xFFFF000000000000ULL
#define PTR_MASK  0x0000FFFFFFFFFFFFULL

uintptr_t pack_ptr(void *ptr, uint16_t tag) {
    return ((uintptr_t)ptr & PTR_MASK) | ((uintptr_t)tag << 48);
}

void *unpack_ptr(uintptr_t tagged) {
    return (void *)(tagged & PTR_MASK);
}

uint16_t unpack_tag(uintptr_t tagged) {
    return (uint16_t)(tagged >> 48);
}
注意: 标记指针在32位系统上不好使,地址空间不够。这种情况下,可以考虑用双字CAS(DCAS),但很多平台不支持。或者干脆用锁——有时候,简单的方案才是最好的方案。

17.5 知识体系总览

这一章的内容比较多,我画了一张图帮你理清思路:

原子操作与内存序 原子类型 • atomic_int / atomic_uint • atomic_bool • atomic_uintptr_t • atomic_init 初始化 内存序模型 • relaxed(最宽松) • acquire / release • acq_rel • seq_cst(最严格) 无锁编程基础 • CAS (Compare-And-Swap) • atomic_compare_exchange • 无锁栈 / 无锁队列 • 避免锁的阻塞问题 ABA问题 • 地址复用导致逻辑错误 • 标记指针(Tagged Ptr) • 危险指针(Hazard Ptr) • 延迟释放 / GC 核心:用硬件原子指令替代锁,注意内存序和ABA陷阱

这张图把本章的四个核心知识点串起来了。原子类型是基础,内存序控制可见性,无锁编程是应用,ABA问题是坑。你写代码时,按这个顺序思考,基本不会跑偏。


好了,关于原子操作和内存序,我们就聊到这里。这些东西初看有点绕,但用多了就会发现,它们其实是并发编程的“乐高积木”——基础、灵活、强大。下次你在嵌入式项目里遇到共享数据的问题,不妨先想想:能不能用原子操作解决?

一句话总结: 原子操作让你不用锁也能安全地操作共享数据,但内存序选错了会出bug,ABA问题没处理好会出大bug。谨慎使用,但别害怕使用。

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