49、原子操作:C11原子操作与volatile的区别
这个问题,我几乎每次培训都会被问到。
很多人觉得 volatile 和原子操作差不多,都能用在多线程里。嗯,这个理解其实有点危险。我见过不止一个项目,因为搞混了这两个东西,线上出了诡异的bug,查了好几天才定位到。
今天咱们就把这事彻底说清楚。
volatile 到底干了什么?
volatile 这个关键字,说白了就是告诉编译器:别优化我。
编译器有时候很聪明,它会觉得某个变量在循环里没被改过,就直接从寄存器里读,不去内存取了。这在单线程里没问题,但一旦有中断或者另一个线程改了那个变量,编译器还傻乎乎地用寄存器里的旧值,就出事了。
volatile 就是强制编译器每次读写都去内存里拿,别耍小聪明。
volatile 的核心能力:
- 防止编译器优化掉对变量的读写
- 保证每次读取都从内存获取最新值
- 保证每次写入都立即写回内存
但是,请注意——volatile 不保证原子性。
什么意思?你写一个 volatile int,在32位系统上可能是一条指令完成的。但如果你写一个 volatile long long,在32位系统上就需要两条指令。这时候线程切换进来,你就只改了半个变量,数据就坏了。
C11 原子操作又是什么?
C11 标准引入了 stdatomic.h,提供了真正的原子操作。它保证两件事:
- 原子性:操作要么全部完成,要么完全不执行,不会看到中间状态
- 内存序:可以控制不同线程间看到操作的顺序
我个人习惯,只要涉及多线程共享变量,优先用原子操作,而不是 volatile。
我的经验: 在嵌入式裸机开发中,如果只是中断和主循环共享一个标志位,用 volatile 就够了。但一旦涉及多核或者RTOS多线程,我建议直接用原子操作。
一张图看懂区别
下面这张图,是我自己总结的。你看完应该就明白了。
代码对比:一看就懂
咱们直接看代码。假设两个线程都在对一个计数器做自增操作。
用 volatile 的错误写法
// 错误示例:volatile 不保证原子性
volatile int counter = 0;
// 线程A
counter++; // 实际是:读-加-写 三步
// 线程B
counter++; // 可能在线程A写回之前就读了旧值
你想想看,counter++ 在底层是三条指令:读内存、寄存器加1、写回内存。如果线程A刚读完,线程B也读了,两个都加1,然后都写回——结果只加了1次,不是2次。
我曾经踩过的坑: 在一个多核ARM芯片上,用 volatile 修饰了一个共享计数器,结果跑着跑着计数就对不上了。查了两天才发现,两个核同时执行自增,volatile 根本拦不住这种竞争。
用 C11 原子操作的正确写法
// 正确写法:使用原子操作
#include <stdatomic.h>
atomic_int counter = 0;
// 线程A
atomic_fetch_add(&counter, 1);
// 线程B
atomic_fetch_add(&counter, 1);
atomic_fetch_add 保证整个读-改-写操作是原子的。要么全部完成,要么没做。不会出现中间状态。
内存序:volatile 做不到的事
C11 原子操作还有一个大杀器——内存序。
你可以控制原子操作的内存可见性顺序。比如:
// 宽松内存序:只保证原子性,不保证顺序
atomic_store(&flag, 1, memory_order_relaxed);
// 释放-获取语义:保证之前的写入对其他线程可见
atomic_store(&flag, 1, memory_order_release);
// 另一个线程
int val = atomic_load(&flag, memory_order_acquire);
volatile 完全做不到这个。它只能保证编译器不优化,但CPU还是可能乱序执行。在多核系统上,你用一个 volatile 标志位去同步,另一个线程可能看到乱序的结果。
我的建议: 如果你不确定用什么内存序,就用默认的 memory_order_seq_cst。它最安全,虽然性能稍微差一点,但不会出错。等你有经验了再优化。
什么时候用 volatile?什么时候用原子操作?
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 中断服务函数和主循环共享标志位 | volatile | 单核,中断不会被打断,volatile 足够 |
| 内存映射寄存器 | volatile | 硬件寄存器值可能被外设改变,需要禁止优化 |
| 多线程计数器 | 原子操作 | 需要保证自增操作的原子性 |
| 多线程状态标志 | 原子操作 | 需要内存序控制,保证可见性 |
| 实现自旋锁 | 原子操作 | 需要 test-and-set 等原子指令 |
| 单核裸机简单共享 | volatile | 没有多线程竞争,volatile 够用 |
避坑指南
我总结几个常见的坑,你遇到了可以少走弯路:
- 别用 volatile 实现锁——它保证不了原子性,锁会坏掉
- 别以为 volatile 能跨线程同步——它只防编译器,不防CPU乱序
- 原子操作不是万能的——复杂的数据结构还是需要锁
- 注意平台差异——有些单片机不支持原子操作指令,C11原子操作会退化成锁
我曾经犯过的错: 在一个Cortex-M0的芯片上用了原子操作,结果发现它没有LDREX/STREX指令,编译器默默给我加了个全局锁。性能直接掉了一半。后来我改成关中断的方式,反而更快。
嗯,总结一下:volatile 是给编译器看的,原子操作 是给CPU和内存系统看的。两者不是一个层面的东西。多线程编程,优先用原子操作;单核中断共享,volatile 就够了。别混着用,也别互相替代。
公众号:蓝海资料掘金营,微信deep3321