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);
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保证后面的普通读操作不会被重排到原子读之前。两者配合,就实现了“先写数据,再设标志”的语义。
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,配合重试,效率更高。
17.4 ABA问题:无锁编程的头号陷阱
说到无锁编程,就绕不开ABA问题。我曾经在一个项目中,用无锁链表管理内存块,跑了两天突然崩溃。查了三天,最后发现是ABA问题在作祟。
什么是ABA问题?看这个场景:
- 线程A读取栈顶指针为节点A(地址0x1000)
- 线程B弹出A,释放内存,再压入一个新节点B(地址0x2000)
- 线程B又弹出B,释放内存,再压入一个节点C(地址0x1000)——注意,地址复用了!
- 线程A执行CAS,发现栈顶还是0x1000,认为没变化,于是把新节点链接到A后面
- 但实际上,栈顶已经不是原来的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);
}
17.5 知识体系总览
这一章的内容比较多,我画了一张图帮你理清思路:
这张图把本章的四个核心知识点串起来了。原子类型是基础,内存序控制可见性,无锁编程是应用,ABA问题是坑。你写代码时,按这个顺序思考,基本不会跑偏。
好了,关于原子操作和内存序,我们就聊到这里。这些东西初看有点绕,但用多了就会发现,它们其实是并发编程的“乐高积木”——基础、灵活、强大。下次你在嵌入式项目里遇到共享数据的问题,不妨先想想:能不能用原子操作解决?