文档目录

这个问题是你打通硬件屏障之后,迈向高性能并发编程的临门一脚!

如果说 std::atomic 是C++发给你的“手枪”,那内存序(Memory Order)就是这把手枪的扳机力度。用对了,精准击杀(高性能);用错了,走火入魔(Bug丛生)。

我们结合上一阶段讲的Store Buffer和Invalidation Queue,来看看C++11是如何把这三种“硬件武器”抽象成三个编程模型的。


核心认知:为什么要分流派?

底层硬件(ARM/RISC-V)是弱内存序(允许乱序),x86是强内存序(仅允许写-读乱序)。如果C++只提供一种“全围栏”(seq_cst),那在ARM上性能会崩塌。 所以,C++11的策略是:“你(程序员)懂硬件,你就用轻量的;你不懂,就用最重的但最安全的。”


流派一:memory_order_relaxed(彻底放飞)

口头禅:“只保证原子性,不保证可见性,不保证顺序。”

  • 编译器行为:不插入任何内存屏障(mfence/dmb)。CPU可以随便重排,编译器也可以随便优化。
  • 硬件行为:允许Store Buffer延迟可见,允许Invalidation Queue堆积。

典型应用:纯计数器(比如统计QPS)。线程A加1,线程B加1,我们只关心最终累加结果,不关心A和B谁先看到谁。

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

void worker() {
    // 极度轻量,在x86上直接编译为 lock xadd(无额外屏障)
    // 在ARM上只编译为 LDXR/STXR 循环,不插入 dmb 屏障
    counter.fetch_add(1, std::memory_order_relaxed); 
}

翻车示例(千万不要这样用!):

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

void producer() {
    data = 42;                     // 普通写(可能被重排到后面!)
    ready.store(true, std::memory_order_relaxed); // 彻底放飞
}
void consumer() {
    while(!ready.load(std::memory_order_relaxed)); // 看到true了,但data可能还是0!
    assert(data == 42); // 💥 断言失败!data的写入可能还在Store Buffer里没出来!
}

流派二:acquire-release 黄金搭档(精准半边墙)

这是工业界最常用的高性能并发模型! 它理解硬件的本质:Store Buffer需要冲刷,Invalidation Queue需要清空。

  • release(释放语义):“墙往前面推”。禁止编译器/CPU将此之前的任何内存操作(读/写)移到这条语句之后。
    • 硬件动作:冲刷Store Buffer(确保之前写的所有数据,其他核心都能看到)。
  • acquire(获取语义):“墙往后面推”。禁止编译器/CPU将此之后的任何内存操作(读/写)移到这条语句之前。
    • 硬件动作:清空Invalidation Queue(确保之后读的数据,一定是从最新状态取来的)。

画图理解(配对使用):

线程A (Producer)                    线程B (Consumer)
--------------------------------    --------------------------------
data = 42;  (普通写)                 
                                   while(flag == 0)  (自旋等待)
flag.store(1, release);  ---------- 可见性同步 ---------->  flag.load(acquire);
// release 屏障(向下推)      (总线同步)          // acquire 屏障(向上拉)
                                                    assert(data == 42); // ✅ 必成立!

硬件保障:

  • A执行release时,强制把data=42从Store Buffer冲刷出去。
  • B执行acquire时,强制处理掉Invalidation Queue,并把缓存行状态置为Invalid,重新从A那边同步。
  • 结果:B不仅看到了flag=1,一定能看到data=42。

C++实战代码:自旋锁(SpinLock)

class SpinLock {
    std::atomic<bool> locked{false};
public:
    void lock() {
        // 尝试交换,期望是false,设为true
        // acquire: 后续的临界区操作(读/写共享数据)绝不能排到上面来
        while (locked.exchange(true, std::memory_order_acquire)) {
            // 自旋等待
        }
    }
    void unlock() {
        // release: 临界区的所有操作(读/写共享数据)必须在这一句之前完成
        locked.store(false, std::memory_order_release);
    }
};
// 临界区内的共享数据,绝对安全!

流派三:seq_cst(全局最严禁令 / 序列一致性)

口头禅:“让所有核心看到绝对一致的修改顺序。”

  • 它是 acquire + release + 全局顺序 的集合体。
  • 编译器行为:在x86下,任何store操作后都会紧跟一个全屏障(mfence或lock addl),强制冲刷Store Buffer和清空Invalidation Queue。
  • 硬件行为:阻止所有形式的指令重排(写-写、读-读、写-读),强制所有核心的缓存达成全局统一的视图。

这是默认值(std::atomic的默认参数),因为它最安全。

// 默认就是 memory_order_seq_cst
std::atomic<int> x{0}, y{0};

// 线程A
x.store(1);
if (y.load() == 0) { /* 临界区 */ }

// 线程B
y.store(1);
if (x.load() == 0) { /* 临界区 */ }

为什么需要它?(解决“写-读”重排) 使用release-acquire时,如果线程A执行store,线程B执行load,两者配对,完美。 但是,如果两个线程都在同时做store和load(比如同时抢锁),release-acquire无法保证“谁先谁后”(因为它们是两条独立的马路)。 只有seq_cst 能保证:要么线程A先看到B的写入,要么线程B先看到A的写入,绝不可能陷入“先有鸡还是先有蛋”的死循环。


三派对比总结表(面试必考)

特性维度 relaxed (彻底放飞) acquire-release (精准半边墙) seq_cst (全局最严禁令)
原子性 ✅ 保证 ✅ 保证 ✅ 保证
可见性 ❌ 不保证(可能延迟) ✅ 配对时保证 ✅ 立即全局可见
阻止重排 ❌ 不阻止 ✅ 单向阻止(单方向墙) ✅ 双向阻止(全围栏)
全局顺序 ❌ 无 ❌ 无(仅配对线程一致) ✅ 所有核心绝对一致
x86编译代价 极低(lock xadd) 低(mov + 编译器屏障) 高(可能插入 mfence 或 lock)
ARM编译代价 极低(无dmb) 中等(需要 dmb) 极高(强制 dmb ish 全围栏)
典型场景 纯统计计数器 无锁队列、RCU、双缓冲 全局状态机、多生产者多消费者强一致性标志

给你的“底层开发升维心法”

  1. 默认用seq_cst? 可以,但你是做高性能底层开发的,请戒掉它。它就像拿了重机枪去切菜,功耗极高。
  2. 大部分场景用release-acquire:这才是真正的无锁编程核心。记住:release和acquire必须成对出现才有意义! 单独一个release没有魔法,单独一个acquire也只是浪费电。
  3. 唯一用relaxed的地方:fetch_add做计数器,且该计数器不影响其他数据流向(比如打日志的序号)。
  4. 记忆口诀:
    • Relaxed:我只管加,不管你看不看得到。
    • Release-Acquire:我写完数据松开手(Release),你拿到钥匙(Acquire)开门,里面数据一定是热的。
    • Seq_cst:全体注意,立正!向右看齐!谁先谁后,我说了算!