原子操作:从概念到实战

各位同学,今天我们来聊聊并发编程里一个绕不开的话题——原子操作。说实话,我刚开始学多线程那会儿,总觉得原子操作就是个「加锁的轻量版」,后来踩了坑才明白,这玩意儿远比我想象的要深刻。

原子操作,说白了就是「不可分割的操作」。要么全部执行完,要么一点都没执行。不会出现执行到一半被其他线程打断的情况。你想想看,在多线程环境里,这个特性有多重要。

为什么需要原子操作?

先看一个经典问题。假设两个线程同时执行 counter++,你觉得结果会是什么?

// 两个线程同时执行
counter++;  // 这行代码不是原子的!

实际上,counter++ 在底层会被拆成三步:

  1. 从内存读取 counter 的值到寄存器
  2. 在寄存器里加 1
  3. 把新值写回内存

如果线程 A 刚读完值,还没来得及写回去,线程 B 也读了同一个值。那结果就是两个线程都加了 1,但最终 counter 只增加了 1,而不是 2。这就是典型的「数据竞争」。

核心要点:原子操作保证了一件事——在操作执行期间,其他线程既看不到中间状态,也无法干扰这个操作。

C11 原子类型

C11 标准引入了 stdatomic.h 头文件,提供了完整的原子操作支持。我个人习惯用 atomic_intatomic_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 互斥锁

很多初学者会问:有了原子操作,是不是就不用互斥锁了?我的回答是:看场景。

  • 原子操作适合:简单的计数器、标志位、单次读写
  • 互斥锁适合:复杂的临界区、需要保护多个变量的场景

举个例子,如果你要更新一个结构体的多个字段,原子操作就搞不定了。这时候还是得上锁。

知识体系总览

下面这张图总结了本章的核心内容,我建议你保存下来,写代码时对照着看:

原子操作知识体系 原子操作 核心概念 C11原子类型 内存序 不可分割 无中间状态 线程安全 atomic_int / atomic_long atomic_flag(无锁) atomic_bool / atomic_ptr relaxed(最快) release-acquire(常用) seq_cst(最安全) 性能与顺序保证的权衡

避坑指南

最后,分享几个我这些年积累的经验:

  • 能用默认就用默认:除非你明确知道自己在做什么,否则就用 memory_order_seq_cst。性能损失通常可以接受,但 bug 的代价你承受不起。
  • 不要过度优化:我曾经为了省那点性能,把内存序从 seq_cst 改成 relaxed,结果线上出了数据不一致。排查了两天才找到原因,得不偿失。
  • atomic_flag 是自旋锁的好材料:atomic_flag_test_and_setatomic_flag_clear 可以实现轻量级自旋锁,适合短临界区。
  • 注意平台差异:不同 CPU 架构对原子操作的支持不一样。x86 上 seq_cst 几乎没额外开销,但 ARM 上就不一样了。写跨平台代码时要留意。

一句话总结:原子操作是并发编程的基石,但用对内存序才是关键。记住 release-acquire 这对黄金搭档,能解决 90% 的同步问题。

好了,这一章的内容就到这里。原子操作看似简单,但里面的门道不少。建议你动手写几个 demo,亲自感受一下不同内存序带来的差异。实践出真知嘛。


公众号:蓝海资料掘金营,微信deep3321