原子操作:从概念到实战
各位同学,今天我们来聊聊并发编程里一个绕不开的话题——原子操作。说实话,我刚开始学多线程那会儿,总觉得原子操作就是个「加锁的轻量版」,后来踩了坑才明白,这玩意儿远比我想象的要深刻。
原子操作,说白了就是「不可分割的操作」。要么全部执行完,要么一点都没执行。不会出现执行到一半被其他线程打断的情况。你想想看,在多线程环境里,这个特性有多重要。
为什么需要原子操作?
先看一个经典问题。假设两个线程同时执行 counter++,你觉得结果会是什么?
// 两个线程同时执行
counter++; // 这行代码不是原子的!
实际上,counter++ 在底层会被拆成三步:
- 从内存读取 counter 的值到寄存器
- 在寄存器里加 1
- 把新值写回内存
如果线程 A 刚读完值,还没来得及写回去,线程 B 也读了同一个值。那结果就是两个线程都加了 1,但最终 counter 只增加了 1,而不是 2。这就是典型的「数据竞争」。
核心要点:原子操作保证了一件事——在操作执行期间,其他线程既看不到中间状态,也无法干扰这个操作。
C11 原子类型
C11 标准引入了 stdatomic.h 头文件,提供了完整的原子操作支持。我个人习惯用 atomic_int 或 atomic_flag,前者用于计数器,后者用于自旋锁。
#include <stdatomic.h>
atomic_int counter = ATOMIC_VAR_INIT(0);
void increment() {
atomic_fetch_add(&counter, 1); // 原子加 1
}
int main() {
// 创建多个线程...
// 每个线程调用 increment()
printf("counter = %d\n", atomic_load(&counter));
return 0;
}
常用的原子类型有:
| 类型 | 说明 |
|---|---|
| atomic_int | 原子整型 |
| atomic_long | 原子长整型 |
| atomic_flag | 原子布尔标志(无锁) |
| atomic_bool | 原子布尔值 |
| atomic_uintptr_t | 原子指针 |
小技巧:如果你只需要一个简单的标志位,用 atomic_flag。它是唯一保证无锁的原子类型,在嵌入式系统里特别有用。
原子操作的内存序
嗯,这里要重点讲一下。内存序(memory order)是原子操作里最容易让人迷糊的地方。我在项目中遇到过好几次因为内存序选错导致的诡异 bug,排查起来特别痛苦。
C11 定义了六种内存序:
| 内存序 | 含义 | 性能 |
|---|---|---|
| memory_order_relaxed | 只保证原子性,不保证顺序 | 最快 |
| memory_order_consume | 数据依赖顺序(很少用) | 较快 |
| memory_order_acquire | 后续读写不能重排到前面 | 中等 |
| memory_order_release | 前面的读写不能重排到后面 | 中等 |
| memory_order_acq_rel | acquire + release | 较慢 |
| memory_order_seq_cst | 全局顺序一致 | 最慢(默认) |
说白了,内存序就是在「性能」和「顺序保证」之间做权衡。默认的 memory_order_seq_cst 最安全,但性能也最差。
relaxed 模式:最快但最危险
atomic_int flag = ATOMIC_VAR_INIT(0);
int data = 0;
// 线程 A
data = 42;
atomic_store(&flag, 1, memory_order_relaxed);
// 线程 B
while (atomic_load(&flag, memory_order_relaxed) == 0);
printf("data = %d\n", data); // 可能打印 0!
为什么会这样?因为 relaxed 模式只保证原子性,不保证顺序。线程 B 可能先看到 flag 变成 1,但 data 还是 0。这就是我在项目中踩过的坑——用 relaxed 做同步,结果数据对不上。
release-acquire 模式:最常用的组合
atomic_int flag = ATOMIC_VAR_INIT(0);
int data = 0;
// 线程 A
data = 42;
atomic_store(&flag, 1, memory_order_release); // 释放
// 线程 B
while (atomic_load(&flag, memory_order_acquire) == 0); // 获取
printf("data = %d\n", data); // 保证打印 42
release 保证:之前的所有写操作,在 release 之前对其他线程可见。acquire 保证:之后的所有读操作,在 acquire 之后执行。两者配合,就形成了「happens-before」关系。
警告:千万不要在 release 和 acquire 之间混用不同的内存序。我曾经见过有人用 release 写,用 relaxed 读,结果数据竞争依然存在。这种 bug 极难复现,排查起来让人抓狂。
原子操作 vs 互斥锁
很多初学者会问:有了原子操作,是不是就不用互斥锁了?我的回答是:看场景。
- 原子操作适合:简单的计数器、标志位、单次读写
- 互斥锁适合:复杂的临界区、需要保护多个变量的场景
举个例子,如果你要更新一个结构体的多个字段,原子操作就搞不定了。这时候还是得上锁。
知识体系总览
下面这张图总结了本章的核心内容,我建议你保存下来,写代码时对照着看:
避坑指南
最后,分享几个我这些年积累的经验:
- 能用默认就用默认:除非你明确知道自己在做什么,否则就用
memory_order_seq_cst。性能损失通常可以接受,但 bug 的代价你承受不起。 - 不要过度优化:我曾经为了省那点性能,把内存序从 seq_cst 改成 relaxed,结果线上出了数据不一致。排查了两天才找到原因,得不偿失。
- atomic_flag 是自旋锁的好材料:用
atomic_flag_test_and_set和atomic_flag_clear可以实现轻量级自旋锁,适合短临界区。 - 注意平台差异:不同 CPU 架构对原子操作的支持不一样。x86 上 seq_cst 几乎没额外开销,但 ARM 上就不一样了。写跨平台代码时要留意。
一句话总结:原子操作是并发编程的基石,但用对内存序才是关键。记住 release-acquire 这对黄金搭档,能解决 90% 的同步问题。
好了,这一章的内容就到这里。原子操作看似简单,但里面的门道不少。建议你动手写几个 demo,亲自感受一下不同内存序带来的差异。实践出真知嘛。