6、原子操作(Atomic):std::atomic的基本类型与操作、内存顺序(memory_order)、无锁编程简介

原子操作,说白了就是「不可分割的操作」。

我刚开始学多线程时,总觉得加锁就能解决一切。后来线上一个高并发服务频繁卡顿,用 perf 一分析,锁竞争占了 30% 的 CPU 时间。嗯,那时候我才意识到——有些场景,根本不需要锁,原子操作就够了。

6.1 std::atomic 的基本类型

C++11 引入了 std::atomic,它是个模板类。你可以用它包装任何 trivially copyable 的类型。不过我个人最常用的,还是下面这些特化版本:

类型别名 底层类型 说明
std::atomic_bool bool 原子布尔,常用于标志位
std::atomic_int int 32位原子整数,最常用
std::atomic_uint unsigned int 无符号版本
std::atomic_long long 平台相关,通常是64位
std::atomic_llong long long 保证64位
std::atomic_size_t size_t 索引、计数器专用
我的习惯:能用 std::atomic_int 就别用 std::atomic<int>,少打几个尖括号,代码也清爽。

6.2 基本操作

原子变量的操作其实不多,但每个都有讲究。我列一下最核心的:

  • load:读取值。相当于「看一眼」。
  • store:写入值。相当于「放进去」。
  • exchange:写入并返回旧值。原子化的「先拿后放」。
  • compare_exchange_weak / strong:CAS 操作。这是无锁编程的基石。
  • fetch_add / fetch_sub:原子加减。比用锁快一个数量级。

看个例子:

#include <atomic>
#include <iostream>

std::atomic_int counter{0};

void increment() {
    for (int i = 0; i < 1000; ++i) {
        counter.fetch_add(1);  // 原子加1
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    std::cout << counter.load() << std::endl;  // 一定是2000
    return 0;
}

关键区别:fetch_add 返回旧值,operator++ 返回新值。我踩过这个坑——想用旧值做判断,结果拿到的却是新值,排查了半天。

6.3 内存顺序(memory_order)

这是原子操作里最让人头疼的部分。说实话,我刚开始也觉得这东西玄乎。但后来在项目中调试过一个诡异的 bug——两个线程之间看到的数据不一致,就是因为内存顺序没选对。

内存顺序有六种,但常用的就三种:

枚举值 含义 性能 使用场景
memory_order_relaxed 只保证原子性,不保证顺序 最快 计数器、统计量
memory_order_acquire 之后的读写不能重排到之前 中等 读取锁状态
memory_order_release 之前的读写不能重排到之后 中等 写入锁状态
memory_order_acq_rel acquire + release 较慢 read-modify-write 操作
memory_order_seq_cst 全局顺序一致 最慢 默认值,通用场景

我曾经踩过的坑:memory_order_relaxed 实现了一个自旋锁,结果两个线程同时「看到」锁是空闲的,双双进入临界区。后来改成 acquire/release 对才解决。记住——relaxed 只保证原子性,不保证可见性。

为什么会有这些顺序?因为 CPU 和编译器会重排指令。你写的是:

data = 42;
flag.store(true, memory_order_release);

但 CPU 可能先执行 flag.store,再执行 data = 42。另一个线程看到 flag 为 true 时,data 可能还是旧值。这就是内存顺序要解决的问题。

6.4 无锁编程简介

无锁编程,不是真的「不用锁」,而是用原子操作代替锁。它的核心思想是:如果操作失败,就重试,而不是阻塞。

最简单的例子——无锁栈:

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

public:
    void push(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
        )) {
            // 失败就重试,new_node->next 会被更新为最新的 head
        }
    }
};

这里的关键是 compare_exchange_weak。它做三件事:

  1. 比较 head 是否等于 new_node->next
  2. 如果相等,把 head 设为 new_node
  3. 如果不相等,把 new_node->next 更新为 head 的最新值

然后循环重试。这就是无锁的核心模式——CAS + 重试。

我的建议:无锁编程虽然性能好,但调试难度极高。我见过一个团队花了两周才修好一个 ABA 问题。如果你不是写底层库,优先用锁。等性能瓶颈真的来了,再考虑无锁。

6.5 知识体系图

下面这张图总结了本章的核心脉络:

原子操作 基本类型 atomic_int atomic_bool 核心操作 load / store CAS 内存顺序 relaxed acquire/release 无锁编程 CAS + 重试 ABA问题 原子操作 = 无锁 + 无阻塞 + 高性能

6.6 避坑指南

最后,分享几个我亲身踩过的坑:

  • 不要用 volatile 代替 atomic。 volatile 只防止编译器优化,不保证原子性,也不保证内存可见性。我见过有人用 volatile int 做计数器,结果多线程下数据全乱套了。
  • compare_exchange_weak 可能假失败。 某些 CPU 上,即使值相等,CAS 也可能失败。所以一定要放在循环里重试。strong 版本不会假失败,但性能稍差。
  • 无锁数据结构要处理 ABA 问题。 简单说就是:线程 A 读到指针 P,然后被挂起;线程 B 释放了 P 又分配了 P,地址相同但内容不同;线程 A 醒来后 CAS 成功,但数据已经变了。解决方案是加版本号或使用 hazard pointer。

一句话总结:原子操作是并发编程的「轻武器」——灵活、高效,但用不好容易伤到自己。先从 memory_order_seq_cst 开始,性能不够再优化内存顺序。


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