第13章 原子操作与内存模型
多线程编程里,最让人头疼的是什么?我猜很多人会说是「同步」。锁用多了性能下降,用少了数据乱掉。其实,有些场景根本不需要锁——原子操作就是那把钥匙。
这一章,我们来聊聊C11标准引入的原子操作。说白了,就是让某些操作在硬件层面保证「不可分割」。你想想看,如果一条指令就能完成读写,那还要锁干嘛?
13.1 原子操作的概念
什么是原子操作?我习惯这么理解:一个操作要么全部执行完,要么完全不执行。中间不会插入其他线程的操作。
举个简单的例子:
// 非原子操作
int counter = 0;
// 线程A: counter++
// 线程B: counter++
这段代码,两个线程同时执行counter++,结果可能是1,也可能是2。为什么?因为counter++在底层其实是三步:读、改、写。线程A读到0,还没来得及写回去,线程B也读到0,两个都写回1——数据就丢了。
原子操作的核心价值:在硬件层面保证「读-改-写」三步一气呵成,中间不会被任何其他线程打断。
我在项目中遇到过这样一个坑:一个统计系统,用全局计数器记录请求量。上线后发现数字总是偏小,排查了半天才发现是并发写入冲突。换成原子操作后,问题立刻解决。
13.2 C11原子类型(stdatomic.h)
C11标准在
13.2.1 基本原子类型
| 原子类型 | 对应普通类型 | 说明 |
|---|---|---|
| atomic_int | int | 32位有符号整数 |
| atomic_uint | unsigned int | 32位无符号整数 |
| atomic_long | long | 平台相关长度 |
| atomic_llong | long long | 64位整数 |
| atomic_bool | _Bool | 布尔类型 |
| atomic_flag | — | 无锁布尔标志 |
13.2.2 基本操作函数
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>
atomic_int counter = ATOMIC_VAR_INIT(0);
int worker(void* arg) {
for (int i = 0; i < 100000; i++) {
atomic_fetch_add(&counter, 1);
}
return 0;
}
int main() {
thrd_t t1, t2;
thrd_create(&t1, worker, NULL);
thrd_create(&t2, worker, NULL);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
printf("最终结果: %d\n", atomic_load(&counter));
return 0;
}
这段代码,两个线程各加10万次,结果一定是20万。换成普通int试试?我保证你每次跑的结果都不一样。
小技巧:atomic_fetch_add返回的是修改前的值,不是修改后的。如果你需要修改后的值,可以用atomic_add_fetch。
13.3 内存顺序(memory_order)
嗯,这里要重点说一下。内存顺序是原子操作里最容易让人懵圈的部分。我刚开始学的时候,看到memory_order_relaxed、memory_order_acquire这些名字,头都大了。
其实没那么复杂。说白了,内存顺序就是告诉编译器和CPU:这个原子操作周围的读写操作,能不能重排序?
13.3.1 六种内存顺序
| 内存顺序 | 含义 | 性能 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 最快 |
| memory_order_consume | 数据依赖顺序(很少用) | 较快 |
| memory_order_acquire | 之后的读写不能重排到之前 | 中等 |
| memory_order_release | 之前的读写不能重排到之后 | 中等 |
| memory_order_acq_rel | acquire + release | 较慢 |
| memory_order_seq_cst | 全局顺序一致 | 最慢 |
13.3.2 实际应用场景
我个人习惯:能用默认就用默认。默认是memory_order_seq_cst,虽然慢一点,但最安全。等性能瓶颈出现了,再考虑优化。
不过,有些场景确实需要手动指定。比如:
// 生产者-消费者模式
atomic_int data_ready = 0;
int shared_data = 0;
// 生产者线程
void producer() {
shared_data = 42; // 写数据
atomic_store_explicit(&data_ready, 1, memory_order_release);
}
// 消费者线程
void consumer() {
while (atomic_load_explicit(&data_ready, memory_order_acquire) == 0) {
// 等待
}
printf("数据: %d\n", shared_data); // 一定能读到42
}
这里用release-acquire配对,保证了shared_data的写入一定在data_ready置1之前完成。消费者读到data_ready为1时,shared_data一定已经是42了。
我曾经踩过的坑:用memory_order_relaxed做同步标志。结果消费者读到标志位变了,但数据还没写完。排查了整整两天才发现是内存顺序的问题。从那以后,我只要涉及同步,一律用acquire-release配对。
13.4 无锁编程入门
无锁编程,听起来很高大上对吧?其实核心思想很简单:用原子操作代替锁。但要注意,无锁不等于没有等待——它只是避免了线程被操作系统挂起。
13.4.1 无锁栈的实现
#include <stdatomic.h>
#include <stdlib.h>
typedef struct Node {
int value;
struct Node* next;
} Node;
typedef struct {
atomic_intptr_t head; // 用intptr_t存指针
} LockFreeStack;
void push(LockFreeStack* stack, int value) {
Node* new_node = malloc(sizeof(Node));
new_node->value = value;
Node* old_head;
do {
old_head = (Node*)atomic_load(&stack->head);
new_node->next = old_head;
} while (!atomic_compare_exchange_weak(
&stack->head,
(intptr_t*)&old_head,
(intptr_t)new_node
));
}
int pop(LockFreeStack* stack, int* result) {
Node* old_head;
do {
old_head = (Node*)atomic_load(&stack->head);
if (old_head == NULL) return 0; // 空栈
} while (!atomic_compare_exchange_weak(
&stack->head,
(intptr_t*)&old_head,
(intptr_t)old_head->next
));
*result = old_head->value;
free(old_head);
return 1;
}
这段代码的核心是CAS操作——Compare And Swap。它做的事情是:如果当前值等于期望值,就更新为新值,否则什么都不做。整个操作是原子的。
13.4.2 无锁编程的注意事项
- ABA问题:指针被重用导致CAS误判。我遇到过,解决方案是用双指针或标记位。
- 内存回收:无锁结构的内存释放很棘手。一个线程正在读的节点,另一个线程不能直接free掉。
- 性能不一定更好:高竞争下,CAS循环可能比互斥锁还慢。
我的建议:无锁编程是屠龙刀,不是新手村武器。如果你刚开始学多线程,先用好互斥锁和条件变量。等真正遇到性能瓶颈了,再考虑无锁方案。
13.5 本章知识体系
下面这张图,是我梳理的原子操作与内存模型的知识脉络:
这张图把本章的核心内容串起来了。原子类型是基础,内存顺序是灵魂,无锁编程是进阶应用。三者缺一不可。
13.6 避坑指南
最后,分享几个我踩过的坑:
- 不要对非原子变量做原子操作——编译器不会报错,但结果不可预测。
- atomic_flag是唯一保证无锁的类型——其他原子类型在某些平台上可能用锁实现。
- CAS循环要加退避策略——高竞争下,空转会浪费CPU。我一般加个pause指令或者yield。
- 测试要跑在多核机器上——单核下原子操作的问题很难复现。
一句话总结:原子操作是轻量级的同步工具,适合简单计数和标志位。复杂的数据结构,还是老老实实用锁吧。
公众号:蓝海资料掘金营,微信deep3321