C++ 内存序六件套:从完全同步到爱咋咋地

我们写了个 store(memory_order_relaxed),CPU 心想"好的老板,我先把后面那行提前执行了,因为它效率高",然后我们的数据就烂了。

C++ 的这几种内存序用好了起飞,用错了也能起飞。

原子操作与内存序枚举

1. std::atomic 的常用操作

单纯的 load / store

c++ 复制代码
std::atomic<int> flag{ 0 };
int a = flag.load(); // 读
flag.store(1); // 写

load 用于原子地获取原子对象的值,store 则是原子地将值替换成非原子参数。它们的内存序默认为 memory_order_seq_cst。

读-改-写全家桶

  • exchange():原子地替换值并返回旧值。
c++ 复制代码
int main()
{
    std::atomic<int> flag(0);
    int old = flag.exchange(1); // 原子地设为1,返回旧值0
    std::cout << "旧值: " << old << ", 新值: " << flag.load() << std::endl;
    // 输出: 旧值: 0, 新值: 1
}
  • compare_exchange_weak() / compare_exchange_strong():只有当当前值等于期望值时才替换成新值,否则将当前值写入期望值,返回值表示是否替换成功。

weak 版本可能出现虚假失败,必须在循环中使用,但在某些平台上性能更好。strong 版本保证只有值真正不同时才失败,更直观。

c++ 复制代码
std::atomic<int> counter(0);

void increment()
{
    int expected = counter.load();
    while (!counter.compare_exchange_weak(expected, expected + 1))
    {
        // 失败时 expected 已被更新为当前值,再次尝试
    }
}

int main()
{
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i)
        threads.emplace_back(increment);
    for (auto& t : threads)
        t.join();
    std::cout << "最终计数: " << counter << std::endl; // 10
}
  • fetch_add() / fetch_sub():对整数或指针原子地执行加/减,返回旧值。
c++ 复制代码
std::atomic<int> total(0);

void add(int n)
{
    for (int i = 0; i < 1000; ++i)
        total.fetch_add(n);
}

int main()
{
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i)
        threads.emplace_back(add, 1);
    for (auto& t : threads)
        t.join();
    std::cout << "总和: " << total << std::endl; // 4000
}

2. 内存序六元组

C++ 一口气给了我们六个枚举值,我们按实际能力进行划分。

seq_cst:顺序一致性

memory_order_seq_cst 是所有操作的默认,也是唯一一个给所有 atomic 操作建立全局一致修改顺序的选项。

简单来说我们会觉得整个程序里所有原子变量的操作就像在同一个队列上排队发生,我们能清楚地讲出"A 的 store 在 B 的 load 之前"这种话,而且这个顺序对所有线程可见、一致。

代价嘛,就是在我们现代 CPU 上它经常要插入昂贵的内存栅栏,拿它写生产者消费者效果方面稳是稳,但浪费了一大半性能。

release / acquire / acq_rel:高效同步

  • store(memory_order_release):当前线程在此之前的所有写操作,之后对此原子变量做 获取读的线程,全部可见。
  • load(memory_order_acquire):当前线程在此之后的所有读/写,不会被重排到这个 load 之前。
  • memory_order_acq_rel 只用于"读-改-写"操作,兼具 acquire 和 release 的效果,是"读-改-写全局同步点"。

relaxed:只有原子性,没有顺序

memory_order_relaxed 是真正的没有感情的计数器。它只保证操作本身原子,至于操作在全局顺序里排哪、其他线程能不能看到其他变量的最新值,完全不管。

能干啥?绝对安全的场景就是无同步依赖的单独计数器:比如统计访问次数、引用计数的加加减减,只要最终一致,中间漏几个也没关系。

一旦我们试图用 relaxed 去做标记保护数据,你就会获得著名的"编译器/CPU 随便重排,数据未初始化"体验。

consume:坑

memory_order_consume 设计初衷是比 acquire 更轻量,只保证依赖关系有序。

但现实是几乎所有编译器都把它当作 acquire 实现,因为正确实现依赖追踪太难,标准委员会自己都快放弃了,至今处于不建议使用状态。

逐项解析六种内存序

1. memory_order_seq_cst

seq_cst 帮我们把所有事情都安排好了,很安全,但后来才发现我们付出了多少东西当保护费。

它干了什么:

所有标记 seq_cst 的操作,在所有线程看来,都遵守同一个全局的、单一的总顺序。我们不用担心"线程 A 看到的顺序和线程 B 不一样",所有 seq_cst 操作就像排队,谁先谁后一目了然,而且对所有观察者一致。

c++ 复制代码
std::atomic<bool> x{false}, y{false};
std::atomic<int> z{0};

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()
{
    std::thread t1(write_x), t2(write_y), t3(read_x_then_y), t4(read_y_then_x);
    t1.join(); t2.join(); t3.join(); t4.join();
    assert(z.load() != 0); // 永远不会失败
}

这个例子是经典的商店-客户条件,用 seq_cst 时 z 永远不会为 0,因为 seq_cst 保证全局一致性:没有任何观察者会看到 x 和 y 都已被置位却漏掉另一个。若是换成更弱的内存序,这个断言就敢崩给我们看。

seq_cst 默认省心,但它在 x86 上其实也贵。x86 的 load 自带 acquire,store 自带 release,但加上 seq_cst 后每次都得多等一个锁总线,性能直接腰斩。

除非我们还在理清逻辑的阶段,否则写完原型就立刻换成 release/acquire 调优,懒惰是 bug 之源,但无脑用 seq_cst 是性能之坑。

2. acquire 和 release

它们不是锁,却比锁更灵活,专门用来在写线程与读线程之间传递"前面那一堆数据都就绪了"的信号。

  • store(memory_order_release):当前线程在此之前的所有写操作,之后对此原子变量做获取读的线程,全部可见。
  • load(memory_order_acquire):当前线程在此之后的所有读/写,不会被重排到这个 load 之前。

release 和 acquire 必须作用于同一个原子变量才能直接建立同步关系。如果换成不同变量,则没有这种效果。

c++ 复制代码
std::atomic<bool> ready{ false };
std::string data; // 普通共享变量

void producer()
{
    data = "非常非常重要的消息"; // 1
    ready.store(true, std::memory_order_release); // 2、release
}

void consumer()
{
    while (!ready.load(std::memory_order_acquire)) {} // 3、acquire!
    assert(data == "非常非常重要的消息"); // 4、保证看见 1
}

当 consumer 看到 ready 为 true 时,它同时也保证看到了 data 的完整写入。这就是 happens-before 链:1 → 2(同线程顺序),2 与 3 之间形成同步,3 → 4(同线程顺序)。没有这个序,我们大概率会在 data 里读到空字符串或半条乱码。

3. memory_order_acq_rel

这是给读-改-写操作(RMW)量身定做的,相当于在同一个操作里既做了 acquire 又做了 release。

典型场景是:用原子变量实现自旋锁、或节点引用计数调整时,需要同时建立前后因果关系。

c++ 复制代码
std::atomic<int> sync_point{ 0 };

void thread1()
{
    int old = sync_point.fetch_add(1, std::memory_order_acq_rel);
    // 获取之前的状态 + 释放本次修改给后面的观察者
}

更具体的一个自旋锁:

c++ 复制代码
std::atomic<bool> lock_flag{false};

void lock()
{
    while (lock_flag.exchange(true, std::memory_order_acq_rel))
    {
        // 自旋等待
    }
    // 获取锁:acquire 部分确保临界区不会泄漏到加锁前
    // 释放的语义则由之后显式 store(release) 负责,其实用 acquire 就够了,acq_rel 在这里稍重,但无伤大雅
}

不过说实话,自旋锁的标准写法通常用 acquire 获取,release 释放,而 RMW 里用 acq_rel 主要是某些复杂无锁结构必须一步完成 acquire 和 release,防止中间被插队。

acq_rel 用起来要斟酌:它比单纯的 release 或 acquire 都重,如果我们只是要读或只是写,别上头乱套。

4. memory_order_consume

consume 的设计初衷是轻量级 acquire:只保证依赖于这个 load 结果的数据依赖关系有序,而不必像 acquire 那样建立一个沉重的栅栏。比如:

c++ 复制代码
std::atomic<std::string*> ptr;
std::string data;

void producer()
{
    data = "message";
    ptr.store(&data, std::memory_order_release);
}

void consumer()
{
    std::string* p = ptr.load(std::memory_order_consume);
    if (p)
    {
        // 因为有依赖,*p 能看到完整的 "message"
        assert(*p == "message");
    }
}

理论上很美,利用 CPU 内部的数据依赖顺序,省去一道内存屏障。

但问题来了:编译器要精确追踪依赖,太难了。什么 p->length()、把指针放进结构体再转出来、函数调用间接引用......编译器几乎没法在不产生大量保守同步的情况下证明没有依赖。于是,主流的编译器直接摆烂,它们把 consume 实现为完整的 acquire。

这就导致一个荒诞局面:我们写 consume 本来想省性能,结果编译器默默给我们加了全套 acquire 的栅栏,还多了语义不一致的风险。标准委员会也基本放弃了,ISO C++11 以后的趋势是弃用 consume,当前明确定位为不鼓励使用

5. memory_order_relaxed

relaxed 做唯一承诺:对该变量的操作本身是原子的,仅此而已。没有 happens-before,没有全局顺序,连编译器重排都放任自由。

合法用法:不需要任何同步副作用的纯计数器。

c++ 复制代码
std::atomic<long long> hit_count{0};

void increment()
{
    hit_count.fetch_add(1, std::memory_order_relaxed);
}

就算两个线程同时加,我们得到的一定是正确加法结果(原子性),但此时其他变量可能乱七八糟,我们根本无法推断除了这个计数器本身之外还有什么值。但这在统计请求数、错误计数等场景完全够用。

作死示范

c++ 复制代码
std::atomic<bool> valid{ false };
int value = 0;

void threadA()
{
    value = 21;
    valid.store(true, std::memory_order_relaxed);  // 1、没有 release
}

void threadB()
{
    if (valid.load(std::memory_order_relaxed)) // 2、没有 acquire
    {   
        // 可能 value 还是 0,因为顺序未定义
        std::cout << value << std::endl;
    }
}

这段代码在一些 CPU 和编译器眼里,完全可以把 valid.store 提前到 value = 21 之前,把混乱展示得淋漓尽致。

6. 总结一下

  • 想要安全感,用 seq_cst。
  • 想要性能又要有正确同步,release/acquire 是主力。
  • 只做纯原子运算且不涉及任何数据依赖,relaxed 随便用。
  • acq_rel 留给 RMW 的复合需求。
  • consume 请直接烧纸上香,忘掉它。

内存序的典型应用模式

1. 无锁单生产者单消费者队列

单生产者单消费者意味着我们不需要处理多写者的复杂竞争,只需要防止生产者和消费者踩同一个地方。

一个简单的环形缓冲区,用两个 atomic<size_t> 下标 + relaxed + release/acquire:

c++ 复制代码
template <typename T, size_t N>
class SPSCQueue
{
    T buffer[N];
    std::atomic<size_t> write_idx{ 0 };
    std::atomic<size_t> read_idx{ 0 };
public:
    bool try_push(const T& item)
    {
        size_t w = write_idx.load(std::memory_order_relaxed);
        size_t next = (w + 1) % N;
        if (next == read_idx.load(std::memory_order_acquire)) // 获取消费者最新进度
            return false; // 满
        buffer[w] = item; // 1
        write_idx.store(next, std::memory_order_release); // 2 发布元素
        return true;
    }

    bool try_pop(T& item)
    {
        size_t r = read_idx.load(std::memory_order_relaxed);
        if (r == write_idx.load(std::memory_order_acquire)) // 获取生产者最新进度
            return false; // 空
        item = buffer[r]; // 3
        read_idx.store((r + 1) % N, std::memory_order_release); // 4 释放槽位
        return true;
    }
};

这里的点在于:

  • 生产者用 relaxed 读自己的 write_idx 没关系,因为只有自己在改它。但读 read_idx 必须用 acquire,这样能看到消费者刚释放的槽位确实是空的。
  • 消费者对称:读 write_idx 用 acquire 可以看到生产者刚写入的元素,读自己的 read_idx 用 relaxed。

如果这里用 seq_cst 会强制额外同步,把这条轻量队列的性能优势吃掉一大半。

2. 双层检查锁定的实现

C++ 史上最臭名昭著的反模式之一,直到 atomic 和正确的内存序入场才得以洗净冤屈。

需求:懒汉单例,首次构造后无锁访问。

早期作死写法

c++ 复制代码
if (!instance) // 非原子读取,数据竞争!
{
    lock_guard<mutex> lock(mtx);
    if (!instance) instance = new Foo; // 普通指针,可能指令重排
}

这等于把未定义 拌上数据竞争 再浇一勺指令重排,豪赤😋。

现在使用原子 + acquire/release

c++ 复制代码
class Foo
{
public:
    void doSomething() { /* 实际业务逻辑 */ }

private:
    Foo() = default;

    // 禁止拷贝和移动,保证单例唯一性
    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;

    // 设为友元,使其能访问私有构造函数
    friend Foo* getFoo();
};

std::atomic<Foo*> instance{ nullptr };
std::mutex mtx;

Foo* getFoo()
{
    Foo* tmp = instance.load(std::memory_order_acquire); // 1
    if (!tmp)
    {
        std::lock_guard<std::mutex> lock(mtx);
        tmp = instance.load(std::memory_order_relaxed); // 2 持锁后再看一次
        if (!tmp)
        {
            tmp = new Foo();
            instance.store(tmp, std::memory_order_release); // 3
        }
    }
    return tmp;
}

这个方案里:

  • 1 用 acquire:一旦看见非空指针,保证能看到对象的完整构造。
  • 2 用 relaxed:在锁内第二次读取,没有竞争,不需要同步。
  • 3 用 release:对象构造完毕,发布给后续所有 acquire 读操作。

3. 发布一系列对象(发布-订阅)

这个模式本质就是 release / acquire 的扩大版本:我们生产的不止一个变量,而是一整包数据,然后一把发布 出去;消费者用自己的订阅动作一手接住全部数据。

一个典型例子:多线程日志后端,主线程把一帧完整日志数据填好,然后原子地发布指针,日志消费者看到指针就安全消费。

c++ 复制代码
struct LogBlock
{
    char data[256];
    size_t len;
};

std::atomic<LogBlock*> latest_log{ nullptr };

void producer(const char* msg)
{
    auto* block = new LogBlock();
    strncpy(block->data, msg, sizeof(block->data));
    block->len = strlen(msg);
    // 所有数据准备好,一次性发布
    latest_log.store(block, std::memory_order_release);
}

void consumer()
{
    while (true)
    {
        LogBlock* block = latest_log.load(std::memory_order_acquire);
        if (block)
        {
            // 拿到指针后,由于 acquire,看到的是完整的 block 内容
            write_to_disk(block->data, block->len);
            // 为了简化没回收内存
        }
    }
}

消息里所有普通成员(data、len)的写入,保证了在 store(release) 之前发生;消费者的 load(acquire) 则保证在这些成员的使用之前发生。这就形成了一个非常干净的同步链,不需要任何锁,发布者立刻回去干活,订阅者按自己的步调消费。

这类场景要是不小心写成了 relaxed 就会喜提:数据不定时缺失、偶尔半条消息。

结尾

所以最后就一句话:std::atomic 就一使用说明书,内存序也不是玄学,是我们欠 CPU 的一份礼貌。忽略它,我们的程序会跑,但跑着跑着容易摔进坑里。

相关推荐
haibindev3 小时前
别让AI再从零写一堆优美的屎山了
c++·ai编程·claude·流媒体·codex·代码复用
Zhang~Ling3 小时前
C++ 模板初阶:从函数模板到类模板
c++
蜕变的土豆3 小时前
Visual Studio编译时,报错windows sdk 不匹配,找不到windows sdk
c++
雪度娃娃3 小时前
转向现代C++——优先选用限定作用域的枚举型别,而非不限作用域的枚举型别
java·jvm·c++
咩咦3 小时前
C++学习笔记17:析构函数
c++·学习笔记·类和对象·构造函数·析构函数·动态内存
历程里程碑3 小时前
54 深入解析poll多路复用技术
java·linux·服务器·开发语言·前端·数据结构·c++
无限进步_4 小时前
【C++】可变参数模板与emplace系列
java·c++·算法
计算机安禾4 小时前
【c++面向对象编程】第28篇:new/delete vs malloc/free:C++中正确动态内存管理
开发语言·c++·算法