绝大多数看似简单的修改语句(如counter++)在底层被拆为读-改-写三步,非原子;而atomic_fetch_add等专用原语才真正原子。

看操作是否被拆成读-改-写三步
绝大多数看似“简单”的修改语句,在底层都会被编译器或 CPU 拆解为多个独立步骤。比如 counter++,它实际对应:从内存加载值到寄存器 → 在寄存器中加 1 → 把结果写回内存。只要中间任意一步被线程切换打断,就可能丢失更新。
而真正原子的操作(如某些架构上的 int32_t 赋值)在硬件层面保证这三步不可分割 —— 其他线程要么看到旧值,要么看到新值,绝不会看到“一半更新”的中间态。
-
counter = 42在 32 位对齐地址上通常是原子的(但不绝对,取决于平台和对齐) -
counter++、counter += 1、counter--都是非原子的,无论变量类型多小 -
atomic_fetch_add(&counter, 1)是原子的,因为它是专门设计的同步原语
用 GCC 内置函数快速验证原子性边界
不要依赖直觉判断某条 C 语句是否线程安全。GCC 提供了一组带 __atomic_ 前缀的内置函数(替代已废弃的 __sync_ 系列),它们能明确告诉你哪些操作可跨平台保障原子性。
例如:
int counter = 0; __atomic_fetch_add(&counter, 1, __ATOMIC_SEQ_CST); // ✅ 强顺序原子加 counter++; // ❌ 非原子,即使 counter 是 int
注意第三个参数:__ATOMIC_RELAXED 不保证内存序,适合计数器;__ATOMIC_SEQ_CST 最强一致性,但开销略大。选错会影响可见性,不只是原子性。
- 必须传地址(
&counter),不能传值 - 类型要严格匹配,
__atomic_fetch_add对int64_t*和int32_t*是不同重载 - 在非 x86 架构(如 ARM)上,
__ATOMIC_SEQ_CST可能插入内存屏障指令,影响性能
别把“没出 bug”当成“线程安全”
很多人在单核、低并发、短循环下测试 counter++,发现结果偶尔正确,就误以为没问题。这是典型的风险误判 —— 竞态条件(race condition)是概率性问题,不是必然失败。
真实场景中,以下因素会显著放大非原子操作的暴露概率:
- CPU 核心数增加(调度更随机)
- 变量未对齐(例如
char buf[3]; int* p = (int*)&buf[1];) - 编译器开启
-O2后做寄存器缓存优化,让“读取旧值”更持久 - 使用 ASan 或 TSan 工具时,会主动注入调度点,让竞态更容易复现
一旦出现 TSan: data race on variable 'counter' 这类报告,说明代码已经踩中非原子操作的雷区,不能再靠运气绕过。
原子操作 ≠ 线程安全的万能解
原子操作只解决“单个变量的读写不被中断”,但它不解决更高层的逻辑一致性。比如银行转账:从 A 扣款、向 B 加款,这两个原子操作合起来仍不是原子事务。
常见误区:
- 认为用了
atomic_int就不用锁了 —— 错,多个原子变量之间的协调仍需额外同步 - 在循环里反复用
__atomic_load_n(&flag, __ATOMIC_ACQUIRE)等待状态,却不加pause或usleep,造成 CPU 空转 - 忽略内存序语义,比如用
__ATOMIC_RELAXED读一个被其他线程用__ATOMIC_SEQ_CST写的标志位,可能导致读到过期值
最易被忽略的一点:原子操作本身不提供互斥,也不阻止指令重排对周边代码的影响。你得自己决定哪些读写需要被“绑”在一起,而不是只盯着那个 ++ 符号。










