5、原子操作与内存序:std::atomic基础、原子类型与操作、内存序模型(relaxed/acquire/release/seq_cst)、无锁编程入门

多线程编程里,最让人头疼的是什么?

我个人觉得,不是死锁,也不是线程创建销毁的开销。而是——数据竞争。你明明加了个锁,程序还是偶尔崩溃;你明明觉得逻辑没问题,跑着跑着就出诡异的结果。

嗯,这时候你需要的,就是原子操作。

5.1 为什么需要原子操作?

先看个经典例子:

int counter = 0;

// 线程A
counter++;

// 线程B
counter++;

两个线程同时执行 counter++,你觉得结果一定是2吗?

不一定。因为 counter++ 在底层是三条指令:读、加、写。线程A读到0,还没来得及写回去,线程B也读到0,然后各自加1写回——结果变成了1。

这就是数据竞争。锁能解决,但锁太重了。对于这种简单的加减操作,我们有更好的选择——原子操作

核心概念:原子操作是不可分割的操作。要么全部执行完,要么完全不执行。中间不会被其他线程打断。

5.2 std::atomic 基础

C++11 引入了 std::atomic,让我们可以轻松定义原子变量。

#include <atomic>

std::atomic<int> counter(0);

// 线程A
counter.fetch_add(1);

// 线程B
counter.fetch_add(1);

这样,counter 最终一定是2。为什么?因为 fetch_add 是原子的,底层会使用 CPU 提供的 LOCK 前缀指令或 CAS(Compare-And-Swap)指令。

我在项目中遇到过一个问题:用 std::atomic<bool> 做标志位,控制线程的启停。一开始用普通 boolvolatile,结果在高并发下偶尔会漏掉状态变化。换成 std::atomic<bool> 后,问题彻底解决。

小提示std::atomic 是模板类,支持所有基础类型(int、long、bool、指针等)。C++20 还支持了 std::atomic<std::shared_ptr>

5.3 原子类型与操作

常用的原子操作有哪些?我整理了一张表:

操作 说明 示例
load() 原子读取 int val = counter.load();
store() 原子写入 counter.store(10);
exchange() 原子交换 int old = counter.exchange(5);
compare_exchange_weak() CAS(弱版本) int expected = 0; counter.compare_exchange_weak(expected, 1);
compare_exchange_strong() CAS(强版本) int expected = 0; counter.compare_exchange_strong(expected, 1);
fetch_add() 原子加 counter.fetch_add(1);
fetch_sub() 原子减 counter.fetch_sub(1);

这里要特别说一下 compare_exchange_weakcompare_exchange_strong 的区别。

弱版本在某些平台上可能会伪失败(spurious failure),即明明值相等,却返回 false。强版本保证不会伪失败。但弱版本在某些架构上性能更好。

我个人的习惯是:在循环中使用弱版本,因为循环会重试;在非循环场景用强版本,避免意外失败。

// 弱版本 + 循环
int expected = 0;
while (!counter.compare_exchange_weak(expected, 1)) {
    expected = 0;  // 重置 expected
}

// 强版本
int expected = 0;
if (counter.compare_exchange_strong(expected, 1)) {
    // 成功
}

5.4 内存序模型

原子操作还有一个重要的参数——内存序(memory order)。

说白了,内存序控制的是:这个原子操作对其他线程的可见性

C++ 定义了六种内存序,但常用的就四种:

  • memory_order_relaxed:最宽松,只保证原子性,不保证顺序
  • memory_order_acquire:读取操作,保证后续读写不会重排到它前面
  • memory_order_release:写入操作,保证前面的读写不会重排到它后面
  • memory_order_seq_cst:最严格,全局顺序一致(默认值)

你想想看,为什么需要这些?因为 CPU 和编译器会为了性能对指令进行重排。单线程下没问题,多线程下就可能出乱子。

5.4.1 Relaxed 模型

最轻量,但也是最危险的。只保证原子性,不保证任何顺序。

std::atomic<int> x(0);
std::atomic<int> y(0);

// 线程A
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);

// 线程B
int b = y.load(std::memory_order_relaxed);
int a = x.load(std::memory_order_relaxed);
// 可能出现 b==1 且 a==0 的情况

为什么会这样?因为 relaxed 不阻止重排。线程A可能先写 y 再写 x,线程B先读 y 再读 x,结果就是看到 y 更新了但 x 没更新。

警告:relaxed 只适合做计数器这种不依赖顺序的场景。我曾经在项目里用它做标志位,结果线程退出逻辑出了问题——因为标志位虽然原子了,但其他数据的可见性没保证。

5.4.2 Acquire-Release 模型

这是最常用的配对模型。release 用于写入,acquire 用于读取。

std::atomic<int> flag(0);
int data = 0;

// 线程A(生产者)
data = 42;
flag.store(1, std::memory_order_release);

// 线程B(消费者)
while (flag.load(std::memory_order_acquire) == 0) {
    // 等待
}
// 此时 data 一定是 42

release 保证:写入 flag 之前的所有写操作,对其他线程可见

acquire 保证:读取 flag 之后的所有读操作,能看到 release 之前写入的值

说白了,这就是一个「同步点」。我在做消息队列时经常用这个模式——生产者准备好数据后 release 一个标志,消费者 acquire 到这个标志后,就能安全地读取数据。

5.4.3 Seq-Cst 模型

这是默认的内存序,也是最严格的。所有线程看到的操作顺序完全一致。

std::atomic<int> x(0);
std::atomic<int> y(0);

// 线程A
x.store(1, std::memory_order_seq_cst);

// 线程B
y.store(1, std::memory_order_seq_cst);

// 线程C
int a = x.load(std::memory_order_seq_cst);
int b = y.load(std::memory_order_seq_cst);

// 线程D
int c = y.load(std::memory_order_seq_cst);
int d = x.load(std::memory_order_seq_cst);

在 seq_cst 下,所有线程看到的操作顺序是全局一致的。不会出现线程C看到 x=1、y=0,而线程D看到 y=1、x=0 的情况。

代价呢?性能开销最大。因为 CPU 需要做更多的同步工作。

我个人的建议是:默认用 seq_cst,等性能瓶颈出现时,再考虑优化成 acquire-release 或 relaxed。不要一开始就追求极致性能,正确性永远是第一位的。

5.5 无锁编程入门

无锁编程(Lock-Free Programming),说白了就是不用互斥锁,只用原子操作来实现线程安全的数据结构。

为什么要无锁?

  • 避免死锁
  • 避免线程阻塞和唤醒的开销
  • 在某些场景下性能更高

但无锁编程非常难。我刚开始学的时候,写一个无锁队列,调试了整整一周才跑对。

来看一个最简单的无锁栈:

template<typename T>
class LockFreeStack {
private:
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

public:
    void push(const T& value) {
        Node* new_node = new Node{value, nullptr};
        new_node->next = head.load(std::memory_order_relaxed);
        while (!head.compare_exchange_weak(
            new_node->next,
            new_node,
            std::memory_order_release,
            std::memory_order_relaxed)) {
            // 循环直到成功
        }
    }

    bool pop(T& value) {
        Node* old_head = head.load(std::memory_order_relaxed);
        while (old_head != nullptr &&
               !head.compare_exchange_weak(
                   old_head,
                   old_head->next,
                   std::memory_order_acquire,
                   std::memory_order_relaxed)) {
            // 循环直到成功
        }
        if (old_head == nullptr) return false;
        value = old_head->data;
        delete old_head;
        return true;
    }
};

这个实现有个问题——内存泄漏。如果线程A在 pop 时删除了节点,但线程B还在用这个节点,就会出问题。这就是无锁编程中的ABA 问题

避坑指南:我曾经在无锁队列里踩过 ABA 的坑。解决方案是用带标记的指针(tagged pointer)或 hazard pointer。C++20 的 std::atomic_refstd::atomic<std::shared_ptr> 也能帮上忙。

5.6 知识体系总览

说了这么多,来张图总结一下:

原子操作与内存序知识体系 std::atomic 原子类型 int, long, bool, 指针 C++20: shared_ptr 原子操作 load / store / exchange fetch_add / fetch_sub compare_exchange_weak/strong 内存序模型 relaxed(最宽松) acquire / release seq_cst(最严格) 无锁编程入门 无锁栈 / 无锁队列 ABA问题 / Hazard Pointer CAS循环 + 内存序

5.7 总结

原子操作和内存序,是多线程编程的基石。我做了这么多年并发编程,最大的体会就是:先保证正确,再追求性能

几个要点再强调一下:

  • 能用 std::atomic 就别用 volatile,后者不保证原子性
  • 默认用 seq_cst,性能瓶颈时再优化
  • acquire-release 是最常用的同步模型,理解它就能解决大部分问题
  • 无锁编程很强大,但也很危险。没有充分测试前,别上生产环境

嗯,原子操作就讲到这里。记住一句话:原子操作不是银弹,但它是你理解并发编程的必经之路


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