C++ 原子变量与内存序:从std::atomic到release/acquire

C++ 原子变量与内存序:从 std::atomicrelease/acquire

一、先理解原子变量解决了什么

1. 原子变量保证"操作不可分割"

多线程程序中,多个线程同时访问同一个共享变量,只要其中至少一个线程会写,就可能产生竞态条件。原子变量的价值就是让一次读、写、加减、交换、CAS 等操作不会执行到一半被其他线程插入,从结果上看,它要么完整发生,要么完全没发生

C++ 标准库用 std::atomic<T> 表示原子变量,比如 std::atomic<int> z;std::atomic<bool> x, y;。它不只是"给变量加锁"的替代品,更重要的是提供了一组能精确控制并发语义的接口,例如 store 写入、load 读取、exchange 交换、compare_exchange_weak/strong 比较并交换、fetch_add/fetch_sub/fetch_or/fetch_xor 等读改写操作

2. 原子变量不等于天然有序

下面是一个非常适合入门的例子:两个线程同时对同一个原子变量 x 写入,一个写正数,一个写负数,最后主线程读取 x 的值

cpp 复制代码
std::atomic<int> x{0};

void thread_func1()
{
    for (int i = 0; i < 100000; ++i)
        x.store(i, std::memory_order_relaxed);
}

void thread_func2()
{
    for (int i = 0; i < 100000; ++i)
        x.store(-i, std::memory_order_relaxed);
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    std::cout << "Final value of x = " << x.load(std::memory_order_relaxed) << std::endl;

    return 0;
}

这个例子说明两件事:第一,x 是原子变量,所以不会出现"写了一半的 int"这种撕裂读写问题;第二,memory_order_relaxed 不承诺两个线程谁的写入最后被观察到,所以最终值可能来自线程 1,也可能来自线程 2。也就是说,原子性解决的是"操作是否完整",内存序解决的是"操作之间是否有顺序和可见性关系"

3. 常用原子接口可以分成三类

原子变量的接口大致可以按使用场景理解:普通读写用 storeload,例如 x.store(true, order)x.load(order);交换和 CAS 用于实现无锁结构,例如 exchange 会替换旧值并返回旧值,compare_exchange_weak/strong 会在当前值等于期望值时写入新值;计数类操作用 fetch_addfetch_sub,位运算类操作用 fetch_orfetch_xor

需要注意的是,compare_exchange_weak 在某些平台上可能出现"明明值相等也失败"的弱失败,所以它通常放在循环里重试;compare_exchange_strong 语义更强,更适合不想处理弱失败的场景。is_lock_free() 则用于判断某个原子对象在当前平台上是否真的可以无锁实现

二、内存序到底在约束什么

1. 内存序约束的是"别人能按什么顺序看见操作"

CPU 和编译器为了提升性能,可能会对指令进行重排;多核 CPU 又有各自的缓存,某个核心先写入的数据,不一定会立刻以同样的顺序被另一个核心看见。C++11 的内存模型把这些底层细节抽象成一组 memory_order,程序员不用直接写内存屏障,只需要在原子操作上选择合适的内存序

从工程角度看,内存序要解决的问题不是"这一行代码在源码里写在前面",而是"其他线程是否必须按这个顺序观察到它"。如果只要求变量本身读写安全,可以用 relaxed;如果要先写数据再发布通知,通常用 release/acquire;如果希望所有相关原子操作都放进一个全局顺序,可以用 seq_cst

2. memory_order_relaxed:只保证原子性,不保证跨变量顺序

memory_order_relaxed 是最弱的内存序,它只保证对单个原子对象的操作是原子的,不保证不同原子变量之间的先后关系,也不建立线程之间的同步关系

看下面例子,先写 x,再写 y,读线程先等到 y == true,然后再去读 x。从源码顺序看,好像只要读线程看见了 y == true,就应该也能看见 x == true,但由于两次操作都是 relaxed,这个推理并不成立

cpp 复制代码
std::atomic<bool> x, y;
std::atomic<int> z;

void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);  // 1
    y.store(true, std::memory_order_relaxed);  // 2
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_relaxed));  // 3
    if (x.load(std::memory_order_relaxed))       // 4
        ++z;
}

int main()
{
    int cnt=0;
    for (int i=0; i < 10000; i++) {
        x=false;
        y=false;
        z=0;
        std::thread b(read_y_then_x);
        std::thread a(write_x_then_y);
        b.join();
        a.join();
        int v = z.load(std::memory_order_relaxed);
        if (v != 1) cnt++;
    }
    std::cout<<cnt<<std::endl;
    return 0;
}

这个例子中,x.storey.store 都是原子的,但没有同步关系把 "1 一定先于 2 被另一个线程观察到" 这个语义建立起来。因此从 C++ 标准的角度看,读线程可能已经看到了 y == true,却仍然没有看到 x == true,于是 z 不一定等于 1。注意这类结果在 x86 等强内存序平台上不一定容易复现,但标准语义允许它发生,写可移植并发代码时不能依赖"这台机器刚好没出问题"

3. memory_order_release:发布之前的写入

memory_order_release 常用于写线程的"发布点"。它的核心限制是:当前线程中 release 操作之前的读写,不能被重排到 release 操作之后;如果另一个线程用 acquire 读取到了这个 release 写入的值,那么 release 之前的写入对 acquire 之后的代码可见

下面例子就是在 relaxed 例子上做了关键修复:写线程仍然先用 relaxed 写 x,但再用 release 写 y;读线程用 acquire 等待 y 变成 true,随后再用 relaxed 读取 x

cpp 复制代码
std::atomic<bool> x, y;
std::atomic<int> z;

void write_x_then_y()
{
    x.store(true, std::memory_order_relaxed);  // 1
    y.store(true, std::memory_order_release);  // 2
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_acquire));  // 3
    if (x.load(std::memory_order_relaxed))       // 4
        ++z;
}

int main()
{
    x=false;
    y=false;
    z=0;
    std::thread a(write_x_then_y);
    std::thread b(read_y_then_x);
    a.join();
    b.join();
    std::cout << z.load(std::memory_order_relaxed) << std::endl;
    return 0;
}

这里 y 扮演的是"发布完成"的标志位。写线程在 y.store(true, release) 之前做了 x.store(true, relaxed);读线程一旦通过 y.load(acquire) 读到这个 true,就和写线程的 release 建立同步关系。于是 1 发生在 4 之前,读线程再读 x 时就应该能看到 x == true,所以这个例子里的 z 会稳定变成 1

4. memory_order_acquire:获取发布出来的结果

memory_order_acquire 常用于读线程的"获取点"。它的核心限制是:当前线程中 acquire 操作之后的读写,不能被重排到 acquire 操作之前。换句话说,先确认发布标志,再读取发布的数据,这个顺序不能反过来

release 和 acquire 必须落在同一个原子变量上才能配对。上面的例子中,release 是 y.store(true, release),acquire 是 y.load(acquire),二者都作用在 y 上,所以 y 成了两个线程之间的同步桥梁。x 本身虽然仍然用 relaxed 访问,但它被放在 release/acquire 建立的 happens-before 关系里,因此读线程可以安全读取到发布前写好的 x

一个常见写法可以这样记:普通数据或其他原子数据先准备好,然后 release 写入 flag;另一个线程 acquire 读取 flag 成功后,再读取真正的数据。flag 本身不一定是业务数据,它更像是一面"数据已经准备好"的同步旗子

5. memory_order_seq_cst:所有操作进入一个全局顺序

memory_order_seq_cst 是默认内存序,也是最容易理解但通常成本最高的内存序。它对读操作相当于 acquire,对写操作相当于 release,对读改写操作相当于 acquire-release,并且所有 seq_cst 原子操作会被放进同一个全局顺序中,所有线程观察到的顺序保持一致

下面有两个写线程分别写 xy,两个读线程分别等待 xy,再去检查另一个变量

cpp 复制代码
void write_x() { x.store(true, std::memory_order_seq_cst); }
void write_y() { y.store(true, std::memory_order_seq_cst); }

void read_x_then_y()
{
    while (!x.load(std::memory_order_seq_cst));
    if (y.load(std::memory_order_seq_cst))
        ++z;
}

void read_y_then_x()
{
    while (!y.load(std::memory_order_seq_cst));
    if (x.load(std::memory_order_seq_cst))
        ++z;
}

int main() {
    for (int i = 0; i < 20; i++) {
        x = false;
        y = false;
        z = 0;
        std::thread a(write_x);
        std::thread b(write_y);
        std::thread c(read_x_then_y);
        std::thread d(read_y_then_x);
        a.join();
        b.join();
        c.join();
        d.join();
        // assert(z.load() != 0); // 5
        std::cout << z.load() << std::endl;
    }
    return 0;
}

如果两个读线程都已经分别看到了自己等待的变量为 true,那么在 seq_cst 的全局顺序下,不可能两个线程随后读取另一个变量时都读到 false。换成代码里的结论就是 z 可以是 1 或 2,但不应该是 0。注释里的 assert(z.load() != 0) 正是这个例子的核心判断,只是源文件中暂时把它注释掉了

6. memory_order_consumememory_order_acq_rel

memory_order_consume 类似 acquire,但它只约束依赖于该原子读结果的数据依赖链。比如 s = a + b 中,如果原子读发生在 a 上,那么 s 依赖 a,但 b 不依赖 a。这个模型很难写对,不同编译器实现也比较保守,所以实际工程中通常不建议使用 consume,直接用 acquire 更清晰

memory_order_acq_rel 常用于读改写操作,例如 CAS、fetch_addexchange。这些操作既读取旧值,又写入新值,所以有时需要同时具备 acquire 和 release 语义:读取别人的发布结果,同时把自己的修改发布出去

三、用例子总结如何选择内存序

1. 只做统计计数,用 relaxed

如果一个原子变量只是用来做计数、打点、统计次数,并且其他线程不依赖这个计数去判断某段数据是否已经准备好,那么 relaxed 通常就够了。例如 counter.fetch_add(1, std::memory_order_relaxed) 可以保证计数本身不会丢失更新,但不额外约束其他内存读写的顺序

2. 先准备数据再通知,用 release/acquire

如果一个线程负责准备数据,另一个线程等待数据准备好再读取,就应该优先考虑 release/acquire。acquire_release.cc 中的 xy 就是典型结构:x 是被发布的数据,y 是发布标志。写线程先写 x,再 release 写 y;读线程 acquire 读到 y 后,再读 x,这比全部使用 seq_cst 更轻量,也比全部使用 relaxed 更安全

3. 不确定怎么选,先用默认 seq_cst

seq_cst 的好处是语义直观:所有使用 seq_cst 的原子操作在全局上有一个一致顺序,推理难度最低。它的缺点是可能更慢,尤其是在高并发、跨核心频繁同步的场景下。对于初学者或正确性优先的代码,可以先使用默认内存序;当性能压测证明它成为瓶颈时,再把局部同步关系改成 release/acquire 或 relaxed

4. 最重要的判断标准

选择内存序时不要只问"这个变量是不是原子的",而要问三个问题:是否只关心这个变量自身的原子读写;是否需要让一个线程在看到标志位后,也看到另一个线程之前写好的数据;是否需要所有线程对多个原子变量的观察顺序完全一致。对应答案分别倾向于 relaxed、release/acquire、seq_cst

相关推荐
sanqima1 小时前
mscomm32.ocx串口插件的注册方法
c++·串口通信·ocx插件
进击的荆棘1 小时前
递归、搜索与回溯——综合(下)
c++·算法·leetcode·深度优先·dfs
OBiO20131 小时前
靶向骨的腺相关病毒(AAV)血清型及启动子选择
笔记
白云偷星子2 小时前
云原生笔记8
笔记·云原生
代码中介商3 小时前
C++ STL 容器完全指南(二):vector 深入与 stringstream 实战
开发语言·c++
澈2078 小时前
C++并查集:高效解决连通性问题
java·c++·算法
郝学胜-神的一滴9 小时前
Qt 入门 01-01:从零基础到商业级客户端实战
开发语言·c++·qt·程序人生·软件构建
测试员周周10 小时前
【Appium 系列】第06节-页面对象实现 — LoginPage 实战
开发语言·前端·人工智能·python·功能测试·appium·测试用例
宏笋10 小时前
C++ thread的detach()方法详解
c++