需要预先学习:


1. 为什么要原子操作

回顾《数据共享问题与锁》中的计数器例子:两个线程各执行 100 万次 ++counter,结果不是 200 万。原因是 ++counter 在底层是"读 → 加 → 写"三步,两个线程的三步交错执行,更新互相覆盖。

用互斥锁可以解决,但代价不小:lock() 通常涉及系统调用(竞争时线程睡眠/唤醒)、缓存行失效、上下文切换。如果只是"把一个变量加 1"这种简单操作,锁是杀鸡用牛刀。

原子操作(atomic operation)就是答案:不可分割的操作——要么完整执行,要么完全不执行,中间不可能被其他线程插入。x = 1 这类单条机器指令天然是原子的;而"读-改-写"这类多步操作,需要硬件提供特殊指令(如 x86 的 lock 前缀指令)保证原子性。

std::atomic<T> 把这种能力封装成标准库类型。用它改写计数器:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter{0};   // 原子变量, 必须显式初始化

void add()
{
    for (int i = 0; i < 1000000; ++i)
    {
        ++counter;             // 原子自增: 读-改-写不可分割
    }
}

int main()
{
    std::thread t1(add);
    std::thread t2(add);
    t1.join();
    t2.join();
    std::cout << "counter = " << counter << std::endl;   // 稳定输出 2000000
    return 0;
}

不需要锁,结果稳定是 2000000。这就是原子操作的核心价值:对简单数据的并发访问,比锁更轻量、更快。

注:std::atomic 的构造函数不是原子的(C++20 起才保证 atomic 的初始化本身并发安全),所以原子变量通常用常量初始化(= {0} 或 {0}),并在任何线程使用之前完成初始化。


2. 原子变量的约束

std::atomic<T> 只支持"平凡可拷贝"(trivially copyable)的类型:int、double、指针、bool、枚举,以及只含平凡成员的结构体。std::string、std::vector 这种带资源管理的类型不能直接做成 atomic(标准库提供了 std::atomic<std::shared_ptr<T>> 特化等少数例外)。

std::atomic<int> a{0};                    // 正确
std::atomic<int*> ptr{nullptr};           // 正确: 原子指针
std::atomic<std::string> s;               // 错误! string 不是平凡可拷贝

对不能原子化的类型,要么用锁保护,要么用无锁数据结构(std::atomic 的 is_lock_free() 可以查询底层是否真的无锁)。


3. 基本操作


3.1 load 与 store

load() 原子地读取,store(v) 原子地写入。它们是原子变量最基本的操作,比 ++ 更常用:

#include <atomic>
#include <iostream>

int main()
{
    std::atomic<int> flag{0};

    flag.store(1);                     // 原子写入 1
    int value = flag.load();           // 原子读取
    std::cout << "value = " << value << std::endl;   // 1
    return 0;
}

flag = 1 等价于 store(1),int v = flag 等价于 load(),但显式写 load/store 更清晰,也更方便指定内存序(下文)。


3.2 exchange:原子交换

exchange(v) 原子地把变量设为 v 并返回旧值。典型用途:一次性标记(“抢到就返回旧值,别人抢不到”):

std::atomic<int> ticket{0};

int old = ticket.exchange(1);   // 把 ticket 设为 1, 返回旧值
if (old == 0)
{
    std::cout << "我抢到了第一个" << std::endl;
}

3.3 fetch_add / fetch_sub:原子算术

fetch_add(n) 原子地执行"加 n"并返回旧值;fetch_sub 同理。它们支持整数类型和指针(对指针按元素大小偏移):

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

int main()
{
    std::atomic<int> counter{0};

    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i)
    {
        threads.emplace_back([&counter] {
            for (int j = 0; j < 1000; ++j)
            {
                counter.fetch_add(1);        // 原子 +1
            }
        });
    }
    for (auto& t : threads)
    {
        t.join();
    }
    std::cout << "counter = " << counter.load() << std::endl;   // 10000
    return 0;
}

"返回旧值"这一点非常有用——它是实现无锁数据结构的基石。比如无锁地"给每个任务分配递增编号":

std::atomic<int> next_id{0};

int get_id()
{
    return next_id.fetch_add(1);   // 每个线程拿到的 id 都不重复
}

注:++counter 等价于 counter.fetch_add(1) + 1(返回的是新值)。fetch_add 系列还支持 fetch_and、fetch_or、fetch_xor 位运算。


3.4 compare_exchange:CAS

比较并交换(CAS,Compare-And-Swap)是最重要的原子操作:如果当前值等于期望值,就替换为新值;否则不替换。它有两个版本:

  • compare_exchange_strong(expected, desired):强版本,比较失败时把 expected 更新为当前值
  • compare_exchange_weak(expected, desired):弱版本,可能"假失败"(值明明相等却返回 false,发生在无锁平台上);通常放在循环里重试,循环版效率更高
#include <atomic>
#include <iostream>

int main()
{
    std::atomic<int> value{5};

    int expected = 5;
    bool success = value.compare_exchange_strong(expected, 10);
    // 当前值 5 == expected 5, 替换成功, success = true, value = 10

    expected = 99;
    success = value.compare_exchange_strong(expected, 20);
    // 当前值 10 != 99, 替换失败, success = false, expected 被更新为 10

    std::cout << "value = " << value.load() << ", expected = " << expected << std::endl;
    return 0;
}
value = 10, expected = 10

CAS 的经典用途:无锁更新。比如"多线程并发地把 x 更新为 max(x, new_value)":

std::atomic<int> max_value{0};

void update_max(int new_value)
{
    int old = max_value.load();
    while (new_value > old && !max_value.compare_exchange_weak(old, new_value))
    {
        // 失败说明别的线程抢先更新了, old 已被更新为最新值, 重新比较
    }
}

原理:compare_exchange_weak 失败时把 old 自动刷新为当前值,循环重试直到"要么我的值不再更大,要么我成功写入"。"读-算-CAS 循环"是无锁编程的基本模式。

注意:CAS 循环在竞争激烈时可能反复失败重试(活锁),性能下降。高竞争场景仍然需要锁或更高级的无锁算法。


4. 内存序:为什么需要

原子操作保证"不可分割",但不自动保证"顺序"。现代 CPU 为了性能会乱序执行指令、写缓冲会延迟写回内存、多核缓存不一致——即使每个原子操作本身正确,多个原子操作的相对顺序在另一个线程看来可能颠倒。

考虑经典的"发布-订阅"模式:

std::atomic<bool> ready{false};
int data = 0;                        // 普通变量

// 线程 A: 生产
void producer()
{
    data = 42;                       // 1. 写普通变量
    ready.store(true);               // 2. 发布标志
}

// 线程 B: 消费
void consumer()
{
    while (!ready.load()) {}         // 3. 等待标志
    std::cout << data << std::endl;  // 4. 读普通变量
}

直觉上,B 看到 ready == true 时,data 一定已经是 42。但在默认内存序下不一定:编译器/CPU 可能把 data = 42 重排到 ready.store(true) 之后,或者 B 的 data 读取被提前。结果 B 可能读到 data == 0。

这就是内存序(memory order)要解决的问题:指定原子操作与其他内存访问之间的顺序约束。std::atomic 的每个操作都可以传一个内存序参数,C++ 提供六种:

内存序 含义
memory_order_relaxed 只保证原子性,不提供任何顺序保证
memory_order_consume 发布-消费,依赖链上的顺序(实践中几乎不用)
memory_order_acquire 读操作:其后的内存访问不能重排到它之前
memory_order_release 写操作:其前的内存访问不能重排到它之后
memory_order_acq_rel 读-改-写操作:acquire + release 的组合
memory_order_seq_cst 顺序一致(默认):所有线程看到同一个全局顺序

4.1 acquire / release:发布-订阅

release 写与 acquire 读配对使用:线程 A 的 store(..., release) 发布数据,线程 B 的 load(..., acquire) 订阅数据。规则是:

  • A 在 release 写之前的所有写(包括普通变量的写),对 B 在 acquire 读之后的读可见
  • 即"release 之前的内容不会跑到 release 之后",“acquire 之后的内容不会跑到 acquire 之前”

修正上面的例子:

std::atomic<bool> ready{false};
int data = 0;

void producer()
{
    data = 42;
    ready.store(true, std::memory_order_release);   // 发布: data 的写在标志之前可见
}

void consumer()
{
    while (!ready.load(std::memory_order_acquire)) {}   // 订阅
    std::cout << data << std::endl;   // 保证读到 42
}

release/acquire 是"单向屏障":它不阻止其他无关指令的重排,只约束"配对读写之间的依赖关系",所以比 seq_cst 快。


4.2 relaxed:只保证原子性

memory_order_relaxed 只保证操作本身不可分割,不保证任何顺序。适合"只需要计数正确、不关心顺序"的场景——比如统计次数、分配递增 id:

std::atomic<int> counter{0};

void worker()
{
    counter.fetch_add(1, std::memory_order_relaxed);   // 只关心总数, 顺序无所谓
}

4.3 seq_cst:顺序一致(默认)

memory_order_seq_cst 是默认内存序,最强也最直观:所有线程对所有原子操作看到同一个全局顺序,就像所有操作按某个确定的先后顺序依次执行。程序行为最容易推理,但同步开销最大(通常需要在每个操作处加内存屏障)。

flag.store(true);              // 默认就是 seq_cst
bool v = flag.load();

何时用哪种?

场景 推荐内存序
计数器、统计、id 分配 relaxed
发布-订阅、传递数据所有权 release / acquire
需要多个原子变量的全局一致顺序 seq_cst(默认)
不确定、正确性优先 seq_cst(默认)

注意:内存序是 C++ 多线程里最难的角落。默认 seq_cst 不会错,只是可能慢;用 relaxed/acquire/release 优化之前,先确保你真的理解它们的保证。过早优化内存序是 bug 的温床。


5. 实现原理:硬件层面

std::atomic 的底层是什么?以 x86-64 为例,拆解几个关键机制:


5.1 1. 对齐的单次访问天然原子

如果变量对齐(地址是自身大小的倍数)且访问宽度不超过总线宽度(x86 上 8 字节以内),单次读或写就是原子的——内存总线的传输本身不可分割。所以 std::atomic<int> 的 load/store 在 x86 上就是一条普通 mov 指令,零额外开销。


5.2 2. 读-改-写需要 lock 前缀

++x(读-改-写)在 x86 上对应 lock inc [addr]。lock 前缀让指令在执行期间锁住缓存行/总线,其他核心无法同时访问该地址:

lock inc DWORD PTR [counter]    ; 原子自增
lock xchg DWORD PTR [flag], eax ; 原子交换
lock cmpxchg DWORD PTR [value], edx  ; CAS

lock 前缀的成本:它会阻止其他核心访问该缓存行,并强制缓存行写回、同步到其他核心(缓存一致性流量),所以原子读-改-写比普通读写慢几十倍。


5.3 3. 缓存一致性协议(MESI)

多核 CPU 的每个核心有自己的 L1/L2 缓存,通过缓存一致性协议(x86 用 MESI 变种)保证"所有核心看到同一份数据"。每条缓存行有状态:M(Modified,已修改)、E(Exclusive,独占)、S(Shared,共享)、I(Invalid,失效)。

当核心 A 修改了一个变量,协议会让其他核心中持有该缓存行的副本失效(Invalid),下次读取必须重新从内存/其他核心同步。lock 指令正是借助这套机制实现"跨核心的原子性"。

核心 A: 修改 x → 缓存行变 M → 广播"失效"消息
核心 B: 收到消息 → 自己的 x 副本变 I → 下次读 x 必须重新加载

5.4 4. ARM 等平台:LL/SC

ARM、RISC-V 没有 lock 前缀,改用 LL/SC(Load-Linked / Store-Conditional,链接加载/条件存储)指令对:ldxr 读取并标记地址,stxr 写入时检查该地址是否被其他核心改过,改过则写入失败。所以 ARM 上 CAS 天然可能"假失败"——这正是 compare_exchange_weak 存在的原因。


5.5 5. 内存屏障

acquire/release/seq_cst 在硬件上对应内存屏障(memory barrier/fence)指令:x86 的 mfence、lock 前缀附带的全屏障等。它们阻止 CPU 乱序执行越过屏障。x86 的硬件内存模型较强(写后读不重排),所以 acquire/release 在 x86 上几乎是免费的(只影响编译器优化);ARM/PowerPC 上则需要真实指令,开销更大。


5.6 6. 无锁(lock-free)

当 std::atomic<T> 底层只用原子指令实现(没有隐藏的锁),称该类型是 lock-free 的,可用 is_lock_free() / is_always_lock_free() 查询:

std::atomic<int> a{0};
std::cout << std::boolalpha << a.is_lock_free() << std::endl;   // true

只有 lock-free 的原子变量才有性能优势。atomic 允许内部用锁兜底实现(标准不禁止),此时它等价于"带锁的普通变量",性能没有意义。


6. 性能开销

原子操作到底比锁快多少?量级参考(现代 x86,单次操作):

操作 典型耗时
普通变量读写 约 0.5 ns(命中 L1 缓存)
原子 load/store(无竞争) 约 1 ns(对齐后就是普通 mov)
原子 fetch_add / CAS 约 10-20 ns(lock 指令 + 缓存同步)
互斥锁加解锁(无竞争) 约 20-50 ns(内部也是原子操作)
互斥锁加解锁(有竞争) 微秒到毫秒级(线程睡眠/唤醒、系统调用)

结论:

  • 无竞争的锁并不比原子操作慢太多(锁内部也是原子操作)
  • 有竞争的锁代价高昂——线程睡眠、上下文切换、唤醒,原子操作则只是忙等重试(由硬件保证),不会让线程睡眠
  • 原子操作的优势:无系统调用、无线程状态切换,适合高频、短小的临界区

何时用原子,何时用锁?

场景 选择
单个变量(计数、标志、id) 原子操作
短临界区、高频访问 原子操作(CAS 循环)
多个变量的一致性修改 锁(原子操作无法原子地改多个变量)
长临界区(IO、复杂计算) 锁(原子操作的忙等会浪费 CPU)
复杂数据结构(容器、树) 锁(或专门的无锁数据结构)

注:原子操作不睡眠,竞争激烈时多个线程反复 CAS 重试会空转烧 CPU。临界区一旦变长,锁(让出 CPU)反而更合适。


7. 性能陷阱:伪共享

伪共享(false sharing)与原子操作/缓存密切相关:缓存以缓存行(通常 64 字节)为单位管理。如果两个线程频繁写不同的变量,但两个变量恰好落在同一条缓存行里,缓存一致性协议会让两个核心互相失效对方的缓存行——每次写都要重新同步整条缓存行,性能急剧下降:

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>

// 两个原子变量紧挨着, 大概率落在同一条缓存行里
struct Shared
{
    std::atomic<int> a{0};
    std::atomic<int> b{0};
};

int main()
{
    Shared shared;

    auto worker = [&shared](std::atomic<int>& x) {
        for (int i = 0; i < 10000000; ++i)
        {
            x.fetch_add(1, std::memory_order_relaxed);
        }
    };

    auto start = std::chrono::steady_clock::now();
    std::thread t1(worker, std::ref(shared.a));
    std::thread t2(worker, std::ref(shared.b));
    t1.join();
    t2.join();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::steady_clock::now() - start).count();
    std::cout << "伪共享耗时: " << ms << " ms" << std::endl;
    return 0;
}

修复方法:把热点变量隔开,保证各自独占缓存行。常见手法是给变量填充 alignas(64)(对齐到缓存行大小)或补齐到 64 字节:

struct alignas(64) Padded
{
    std::atomic<int> a{0};   // a 独占一条缓存行
};

struct Padded2
{
    std::atomic<int> a{0};
    char padding[60];        // 手动填充到 64 字节
    std::atomic<int> b{0};
};

伪共享的判定:两个线程访问的地址差小于缓存行大小(64 字节),且都是写热点。它在多线程性能优化中极其隐蔽——perf 等工具可以统计 cache-miss 帮助定位。


8. atomic_flag 与自旋锁

std::atomic_flag 是最简单的原子类型:只有 clear()(置 false)和 test_and_set()(置 true 并返回旧值)两个操作,保证 lock-free。用它可以实现一个自旋锁(spinlock):

#include <atomic>

class SpinLock
{
public:
    void lock()
    {
        // 不断尝试"抢到"标志; 抢到前忙等
        while (flag_.test_and_set(std::memory_order_acquire))
        {
            // 抢不到: 让出 CPU, 避免空转烧核
        }
    }

    void unlock()
    {
        flag_.clear(std::memory_order_release);
    }

private:
    std::atomic_flag flag_ = ATOMIC_FLAG_INIT;   // 必须用宏初始化
};

用法和 std::mutex 一致(配合 lock_guard):

SpinLock spin;
{
    std::lock_guard<SpinLock> guard(spin);
    // 临界区
}

自旋锁的特点:不睡眠,一直忙等。适合"临界区极短、竞争不激烈"的场景(等待时间 < 线程睡眠唤醒的开销);临界区长或竞争激烈时,自旋会浪费大量 CPU,此时应改用 std::mutex。

注意:C++20 起 std::atomic_flag 初始化可以用 std::atomic_flag flag{false};,ATOMIC_FLAG_INIT 宏是 C++11/14/17 的写法。


9. 原子操作与锁的选型总结

维度 原子操作 互斥锁
适用数据 单个简单变量 任意数据、多个变量
竞争时的行为 忙等重试(不睡眠) 睡眠等待(有竞争时)
无竞争开销 极低(1-20 ns) 较低(20-50 ns)
有竞争开销 空转 CPU 系统调用 + 上下文切换
顺序保证 通过内存序显式控制 lock/unlock 隐含完整同步
正确性难度 高(内存序、ABA 等) 低(但可能死锁)
典型场景 计数器、标志、id、无锁队列 复杂数据结构的保护

原子操作是"最后一层"同步原语:标准库的锁、条件变量、future 内部全部建立在它之上。理解原子操作,才算真正理解多线程程序在硬件上是怎么跑起来的——这也是本系列最后一篇文章。


系列目录: