1 背景
std::atomic可以保证单个对象上的读、写、读改写都是原子的,不会被撕裂,不会丢更新。但在实际的多线程代码里,我们经常看到一种写法:一个线程先准备好一批普通数据,然后翻一个原子标志位;另一个线程轮询标志位,看到翻转后就去读那批数据。
int data = 0;
std::atomic<bool> ready{false};
void Producer() {data = 42;ready.store(true);
}
void Consumer() {while (!ready.load()) {Use(data);}
}
在这段代码里,ready是原子变量,并发读写它没问题。但data不是原子变量一一它只是一个普通的int,Consumer 在看到ready == true 之后去读data,凭什么能保证读到的是 42 而不是 0?
这个问题的答案不在std::atomic本身,而在 C++内存模型。内存模型定义了一套规则,告诉我们在什么条件下,一个线程的写入对另一个线程可见。atomic的原子性只是这套规则的一部分;另一部分线程间的可见性和顺序约束一一靠的是happens-before这样的同步关系。
在内存模型的语境下,"可见性"和"顺序性"经常被混在一起说,但它们其实是两个不同层面的问题。
-
可见性(Visibility)问的是:一个线程写入的值,另一个线程能不能看到。这是最基本的问题。如果写入对另一个线程不可见,那什么顺序都没意义。
-
顺序性(Ordering)问的是:多个写入之间的先后关系,是否能被其他线程正确感知****。一个线程先写了
**a = 1**,再写了**b = 2**,另一个线程能不能保证在看到**b == 2**的时候,一定也能看到**a ==1**?
抛开概念来讲,在具体场景里,可见性往往涉及到两个层面,即CPU 缓存 / Store Buffer 未同步,编译器将变量优化至寄存器,而顺序性往往涉及到指令重排
我们从最简单的情况说起。在同一个线程内部,代码的执行顺序是确定的。C++标准用一个术语来描述这种顺序关系:sequenced-before(先序于)
void Producer() {data = 42; //(1)ready.store(true); //(2)
}
在 Producer这个线程里,语句 (1) sequenced-before 语句 (2),这意味着在这个线程的视角下,data = 42 一定在ready:store(true)之前完成。这是 C++ 语言对单线程执行顺序的基本承诺。但要注意一个关键限定:**sequenced-before**只描述当前线程内部的顺序,它不对其他线程做任何承诺。
Producer 线程自己知道**data = 42**先发生,但 Consumer 线程并不知道,****Consumer 看到**ready true**之后去读**data**,如果没有额外的同步机制,它完全可能读到 0,因为**data=42**这个写入可能还没有对Consumer所在的CPU核心可见
现代CPU有写缓冲区(Store Buffer),核心执行写入时,数据先进入私有的写缓冲区,还没刷到公共缓存。另一个核心这时候去读,读到的是缓存里的旧值。而且编译器和CPU也可能对指令做重排,只要在单线程视角下结果不变,编译器有权把data= 42挪到ready.store(true)后面去执行。
-
CPU 执行
data1 = val:发起写内存请求,写入 Store Buffer,CPU 不用等数据刷回 L1 缓存; -
CPU 发现
flag.store和 data1 无依赖,为填满流水线,提前把 flag 的写入提交; -
另一个线程读到
flag=true,但 data1 还停留在 CPU Store Buffer 没落到缓存,读到旧值 0;
CPU 执行流水线 → Store Buffer(写队列,每个核心私有) → L1d(一级数据缓存) → L2 → L3 → 内存 DRAM Store Buffer 在L1 的更上游,不在 L1/L2/L3 缓存分级里。
2 内存序到底在约束什么
走到这里,"为什么光有 atomic 的原子性还不够"已经被拆成了两个独立的根因:
- 指令重排——编译器重排 + CPU 乱序执行(包括 x86 上唯一允许的 Store-Load 重排),会让一个线程内部的写入顺序,在另一个线程眼里被打乱;
- 缓存一致性延迟——Store Buffer 未刷出、MESI 协议的传播滞后,会让一个线程的写入迟迟不对其他核心可见。
内存序(memory_order)就是用来按需约束这两个根因。约束得越紧,正确性越有保障,但硬件代价越大;约束得越松,性能越高,但能保证的东西越少。C++ 提供了一组从松到紧的内存序:
| 内存序 | 对 "指令重排" 的约束 | 对 "缓存一致性延迟" 的约束 | 跨线程同步额外保证 | 硬件代价 | 典型使用场景 |
|---|---|---|---|---|---|
| memory_order_relaxed | 无任何重排约束,仅保证原子操作不可拆分 | 不主动同步缓存,各核缓存独立 | 无线程同步关系,无 happens-before | 最低 | 独立原子计数、仅自增无跨线程依赖 |
| memory_order_acquire | Load 操作禁止向后重排;普通读写可自由重排 | Load 时同步其他核 release 写入的缓存数据 | 仅与 release 写形成局部synchronizes-with |
中 | CAS 读取侧、无锁队列出队、加载共享资源 |
| memory_order_release | Store 操作禁止向前重排;普通读写可自由重排 | Store 完成后冲刷当前核 Store Buffer,对外可见 | 仅与 acquire 读形成局部synchronizes-with |
中 | CAS 写入侧、无锁队列入队、发布共享数据 |
| memory_order_acq_rel | 读写都约束:Load 不能后移、Store 不能前移 | 兼具 acquire 读同步 + release 写冲刷缓存能力 | 读写复合操作可和 acquire/release 配对同步 | 中 | compare_exchange、fetch_add 等同时读写原子 API |
| memory_order_seq_cst | 全局全约束,禁止 Store-Load 重排,所有原子操作全局有序 | 全局统一缓存同步,所有核缓存强一致 | 全局单一执行顺序,任意线程间可见性有序 | 最高 | 简单多线程标志位、逻辑需要全局时序的场景 |
| memory_order_consume | 仅对数据依赖的变量阻止重排,弱于 acquire | 仅同步存在指针依赖的缓存数据 | 弱化版 synchronizes-with,依赖数据指针 | 偏低 | C++17 后基本弃用,不推荐业务代码使用 |
3 指令重排
3.1 什么是指令重排
现代 CPU 不是顺序串行执行指令,编译器也不会死板按照你 C++ 书写顺序生成机器码。重排分为两层:
-
编译器重排(编译期):编译器(GCC/Clang/MSVC)为了 CPU 流水线效率、减少缓存停顿,在无内存屏障、无原子变量时,可以自由调换无依赖指令顺序。
-
CPU 硬件重排(运行期):CPU 拿到汇编指令后,CPU 内部乱序执行(流水线、多发射缓存)。
两者目的完全一致:最大化利用 CPU 硬件资源,减少等待空转,提升吞吐。
为什么会这样?
CPU 执行指令有耗时差异,如:
-
运算指令(
add、移位):1~2 个时钟周期,极快; -
访存指令(
load读内存、store写内存):要访问 L3 缓存、DDR 内存,动辄几十上百周期,非常慢。
假设有代码顺序:
A: 从内存读 x(慢,要等内存)
B: 计算 y = x + 1
C: 计算 z = 1+2(纯运算,不需要x)
-
严格顺序执行:CPU 读完 A 漫长等待,才能跑 B、C,大量时钟周期空等。
-
重排后执行顺序:A、C 并行执行,C 不用等内存;等 A 内存返回,再执行 B。
CPU 没有空闲等待,整体耗时大幅缩短。
CPU 硬件自动乱序执行,编译器也会提前帮忙调整顺序配合 CPU。
3.2 编译器指令重排(GCC O2/O3)
编译器的指令重排其实是可以控制的,只有当C/C++ 开启优化(-O2/-O3)后,编译器才会自由调换指令顺序,但遵循一个规则:
单线程下逻辑结果不能变,只要单线程最终运算结果和源码顺序完全一致,随便调换指令。
只要单线程结果不变,跨无依赖指令随便换顺序。 但是多线程场景下,****编译器只管自己线程,看不到其他线程读写,会乱序导致跨线程逻辑错乱。
我们还是用例子进行说明,我们在这里输入下述代码,然后选择编译优化选项,观察不同编译优化选项的asm
int func();int data1=0;
int data2=0;void func1(){data1=func();data2=42;
}


上述结果里,第一张图没有开启任务编译优化,默认为O0,在第二张图里,我们开启了O2优化选项,可以明显看到当开启O2后,汇编指令的顺序和源码顺序不一致了
call func // 调用外部函数(必须最先执行,调用是屏障)
mov data2, 42 // 源码第二行,汇编提前到data1赋值之前
mov data1, eax // 源码第一行赋值data1,反而后置
3.3 CPU乱序重排
即使编译器没有重排,CPU 本身也会乱序执行指令。这是现代 CPU 为了提高利用率而采用的重要优化策略,和编译无关。
比如,当一条指令需要等待内存数据(cache miss)时,CPU 不会空等,而是先执行后面不依赖的指令。在单线程视角下,这是完全透明的。但在多线程视角下,另一个线程可能观察到指令的执行顺序与代码不一致。
x86/x64 架构的幸运在于,它默认提供强顺序保证(TSO,Total Store Order)。但 ARM 采用的是弱顺序保证(Weak Ordering),重排和乱序执行是常态。这就解释了为什么同一份代码在 x86 上正常,在 ARM 上偶发崩溃。
在x86 的强内存模型下,只允许唯一一种重排:Store 后接 Load 可以乱序(Store-Load 重排),其余所有访存都禁止重排:
-
Store→Store:不乱序
-
Load→Load:不乱序
-
Load→Store:不乱序
-
Store→Load:CPU 可重排
CPU 内部有 Store Buffer(存储缓冲区):
CPU 写内存不会直接落到缓存,先写入 Store Buffer 排队;读内存必须立刻去缓存 / 内存拿数据。
当 CPU 执行:
-
store a:数据进 Store Buffer,没刷入缓存; -
紧接着
load b:需要立刻读 b,CPU 不等 Store Buffer,先执行load。
最终硬件上:读 b 先完成,写 a 延后,硬件层面颠倒了源码顺序。
这是运行时动态行为,每次 CPU 流水线、缓存竞争、多核总线调度都不一样,因此 100 万次测试随机出现重排,不是编译一次性定死。
#include <atomic>
#include <iostream>
#include <thread>int a = 0;
int b = 0;
int x = 0;
int y = 0;void thread1() {a = 1;x = b;
}void thread2() {b = 1;y = a;
}int main() {int reorder_count = 0;const int attempts = 1000000;std::cout << "测试 x86 的 Store-Load 重排序 ..." << std::endl;for (int i = 0; i < attempts; ++i) {a = 0;b = 0;x = 0;y = 0;std::thread t1(thread1);std::thread t2(thread2);t1.join();t2.join();if (x == 0 && y == 0) {reorder_count++;if (reorder_count <= 10) { // 只打印10次std::cout << "第 " << i << " 次: 检测到重排! x=" << x << ", y=" << y << std::endl;}}}std::cout << "在 " << attempts << " 次测试中,检测到 " << reorder_count<< " 次重排 (" << (reorder_count * 100.0 / attempts) << "%)"<< std::endl;return 0;
}


#include <atomic>
#include <iostream>
#include <thread>std::atomic<int> a{0};
std::atomic<int> b{0};
int x = 0, y = 0;void thread1() {a.store(1, std::memory_order_seq_cst);x = b.load(std::memory_order_seq_cst);
}void thread2() {b.store(1, std::memory_order_seq_cst);y = a.load(std::memory_order_seq_cst);
}int main() {int reorder_count = 0;const int attempts = 1000000;for (int i = 0; i < attempts; ++i) {a = 0; b = 0; x = 0; y = 0;std::thread t1(thread1);std::thread t2(thread2);t1.join(); t2.join();if (x == 0 && y == 0) {reorder_count++;}}std::cout << "重排次数: " << reorder_count << std::endl;return 0;
}

4 多核缓存一致性协议
多核CPU 每个核心都有自己的 L1/L2 缓存。当线程 A 在 CPUO 上写入数据,线程B 在 CPU1上读取数据时,数据需要通过缓存一致性协议(MESI°)传播。

这个传播不是瞬时的。CPU 写入后,其他核心的缓存行需要逐步失效并重新加载。在这个窗口期内,其他线程可能读到旧值。
这就是为什么简单store+load 也可能出问题: store 只是把数据写入了当前核心的缓存,它还需要时间才能"流动"到其他核心,load的读取的也可能只是本地缓存中的旧副本。
5 memory_order_release\memory_order_acquire
5.1 安全发布
我们回到开始的背景问题,sequenced-before管的是单线程内的事情,所以要让一个线程的写入对另一个线程可见,我们需要跨线程的同步关系。
C++内存模型定义了一种跨线程的同步关系,叫做 synchronizes-with(同步于)。当一个线程的某个操作和另一个线程的某个操作之间建立了 synchronizes-with 关系,就等于在两个线程之间拉了一根同步的线,把它们的执行顺序接了起来。
最常见的建立synchronizes-with的方式是release/acquire配对:
int data = 0;
std::atomic<bool> ready{false};
void Producer() {data = 42; // (1)ready.store(true, std::memory_order_release); // (2)
}
void Consumer() {while (!ready.load(std::memory_order_acquire)) { // (3)}Use(data); //(4)
}
这段代码里做了什么:
-
Producer 用release 语义写入
ready。release的意思是:在这次store之前的所有写入(包括普通变量的写入),都不允许被重排到这次 store 之后。它像一道单向栅栏,把前面的写入全部"拦"住。除此之外,在硬件层面,CPU 在执行这次 store 的时候,会确保前面所有的写入已经从写缓冲区store buffer刷出去了(或者至少保证了在对外可见性上的顺序)。 -
Consumer 用 acquire 语义读取
ready。acquire 的意思是:在这次 load 之后的所有读取,都不允许被重排到这次 load 之前。它也是一道单向栅栏,保证后面的读取一定发生在 load 之后。同理在硬件侧,****CPU也不会在ready.load完成之前就提前预读data
本质上,release/acquire 在配对处对指令重排做了****单向约束,并在硬件侧刷出 Store Buffer、促成缓存同步——也就是说,它只约束"这一对发布"所涉及的重排和可见性,不管别的。这样当 Consumer 的 acquire load 读到了 Producer 的 release store 写入的值(true),两者之间就建立了 synchronizes-with 关系。
data = 42 先序于ready.store(release),ready.store(release)同步于ready.load(acquire), ready.load(acquire)先序于Use(data)。整条链串起来,data=42就 happens-before Use(data)。Consumer 读data 的时候,一定能看到 42。
这就是"安全发布"的底层原理。data本身只是个普通变量,但它搭了ready这个原子变量的release/acquire 同步链的"顺风车",可见性就有了保障。
5.2 mutex的release/acquire语义
很多人觉得 mutex"只是一把锁",没有把它和内存模型联系起来。其实mutex是C++内存模型中最可靠的happens-before建立者之一。它在加锁和解锁的边界上自动提供了完整的内存屏障,不需要你手动指定memory_order。这也是为什么大多数并发代码用 mutex就够了一一它把同步关系的建立完全封装起来了,你不用操心可见性的问题。我们看下述代码
#include <mutex>std::mutex mtx;
int data = 0;void Producer() {std::lock_guard<std::mutex> lock(mtx);data = 42;
}void Consumer() {std::lock_guard<std::mutex> lock(mtx);Use(data);
}
只要Producer先执行(先拿到锁、先释放锁),那Consumer后面拿到同一把锁的时候,Producer在持锁期间做的所有写入对Consumer都可见。
原理很直接:mutex的unlock自带release 语义,后续的lock 自带 acquire 语义。
Producer的lock_guard 析构时执行 unlock(release),Consumer 的lock_guard 构造时执行 lock(acquire)。如果 Consumer 的lock 发生在 Producer 的 unlock 之后(即 Consumer 拿到了Producer 释放的那把锁),两者之间就建立了 synchronizes-with 关系。再加上各自线程内部的 sequenced-before,整条happens-before链就串起来了。
5.3 发布指针
release/acquire 最常见的用法之一是发布一个指针一一生产者在堆上构造好一个对象,然后把指向它的指针原子地发布出去,消费者通过这个指针来访问对象。
这个模式带来了一个release/acquire 本身管不了的问题:对象的生命周期。
如果用裸指针来发布,消费者拿到指针之后开始读对象,但生产者或者某个清理线程可能在消费者还在读的时候就把对象delete了。这就变成了悬空指针访问——属于use-after-free,是比数据竞争更直接的灾难。
C++20 引入了std::atomic<std::shared_ptr<T>>,把指针的原子发布和生命周期管理结合在了一起。
#include <atomic>
#include <memory>
#include <string>struct Config {std::string endpoint;int timeout_ms = 0;
};std::atomic<std::shared_ptr<const Config>> current_config;void PublishConfig() {auto config = std::make_shared<Config>();config->endpoint = "10.0.0.1";config->timeout_ms = 3000;current_config.store(config, std::memory_order_release);
}std::shared_ptr<const Config> LoadConfig() {return current_config.load(std::memory_order_acquire);
}
生产者先在堆上构造好完整的Config 对象,所有字段都赋好值,然后用 release store 把shared_ptr发布出去。消费者用 acquire load 取得一个shared_ptr 的拷贝。
这段代码里有几个值得注意的细节。
-
首先,
Config用的是const修饰——shared_ptr<const Config>,意味着发布出去的配置对象是只读的,消费者不能修改它。如果需要更新配置,生产者重新make_shared一个新对象再发布,旧对象在所有消费者释放shared_ptr之后自动销毁。这种"读时复制"的模式天然适release/acquire。 -
其次,
shared_ptr的引用计数管理了对象的生命周期。即使生产者发布了新版本的配置,旧版本的配置对象不会被立即释放,只要还有消费者持有指向它的shared_ptr,对象就不会被销毁。这解决了裸指针方案里最棘手的生命周期问题。
5.4 release/acquire的硬件代价与适用边界
-
在 x86-64 上,x86 的
TSO模型天然保证了Store-Store不会重排(release store 所需),Load-Load和Load-Store不会重排(acquire load 所需)。编译器唯一需要做的是阻止自己的重排优化——具体说就是在release store前面、acquire load 后面插入一个编译器屏障,禁止编译器把指令挪过去,但不需要插入任何额外的 CPU 指令。所以在 x86 上,release/acquire 和relaxed生成的汇编往往一模一样,性能差距几乎为零。 -
在 ARM64 上,情况不同。release store 会被编译成
stlr(Store-Release)指令,acquire load 会被编译成ldar(Load-Acquire)指令。这两条指令比普通的str/ldr多了硬件级别的屏障语义,执行开销更大,但比seq_cst所需的全屏障(dmb ish)要便宜。
所以release/acquire 的性能定位是:比seq_cst轻量(尤其在弱内存模型架构上),比relaxed重一些(但在×86上几乎无差别),正好覆盖了"需要跨线程同步但不需要全局顺序"这个最常见的工程需求。
release/acquire 最适合的场景是"定向发布":一方写好数据,打标记;另一方收标记,读数据。它的核心优势是提供了精确到一对 store/load 的同步关系,不像seq_cst 那样给所有原子操作排全局队列。使用release/acquire 时需要紧盯三件事。
-
第一,release store 和acquire load 必须操作同一个原子变量。
-
第二,acquire load 必须读到 release store 写入的那个值。
-
第三,被发布的数据的写入必须在 release store 之前完成,数据的读取必须在 acquire load 之后发生。
三个条件全部满足,同步链才成立。
release/acquire 解决的是局部的同步问题。当系统变得复杂,当出现多个生产者竞争、数据需要分版本管理、对象的生命周期跨越多个线程时,你往往需要把 release/acquire 和 CAS、引用计数、条件变量甚至 mutex 组合使用。
release/acquire 和 seq_cst 都能完成安全发布,但它们的同步强度不同。
-
seq_cst 给所有 seq_cst 原子操作排了一条全局顺序,任何线程看到的这些操作的先后关系都是一致的。release/acquire 没有这条全局顺序——它只在配对的 store 和 load 之间建立局部的同步关系。
-
在单生产者-单消费者的发布场景下,两者的效果一样。你用seq_cst来 store 和 load,和用release/acquire 来 store 和 load,消费者都能安全地读到数据。区别在于seq_cst额外保证了"所有seq_cst操作排在同一条全局队列里",而release/acquire 不保证这一点。
所以工程上的判断标准是:如果你的场景只是单向的数据发布一一生产者准备数据、打标记、消费者收标记、读数据--release/acquire 足够了,不需要 seq_cst 的全局顺序开销。如果你的正确性依赖于"所有线程对多个原子变量的操作顺序达成共识",那你需要seq_cst
5.5 case
5.5.1 Publish-Subscribe 无锁发布模型(一次发布,高频查询)
int RegisterProtocol(ProtocolType type, const Protocol& protocol) {const size_t index = type;if (index >= MAX_PROTOCOL_SIZE) {LOG(ERROR) << "ProtocolType=" << type << " is out of range";return -1;}if (!protocol.support_client() && !protocol.support_server()) {LOG(ERROR) << "ProtocolType=" << type<< " neither supports client nor server";return -1;}ProtocolEntry* const protocol_map = get_protocol_map();BAIDU_SCOPED_LOCK(s_protocol_map_mutex);if (protocol_map[index].valid.load(butil::memory_order_relaxed)) {LOG(ERROR) << "ProtocolType=" << type << " was registered";return -1;}protocol_map[index].protocol = protocol;protocol_map[index].valid.store(true, butil::memory_order_release);return 0;
}// Called frequently, must be fast.
const Protocol* FindProtocol(ProtocolType type) {const size_t index = type;if (index >= MAX_PROTOCOL_SIZE) {LOG(ERROR) << "ProtocolType=" << type << " is out of range";return NULL;}ProtocolEntry* const protocol_map = get_protocol_map();if (protocol_map[index].valid.load(butil::memory_order_acquire)) {return &protocol_map[index].protocol;}return NULL;
}
- 互斥锁 s_protocol_map_mutex 职责(只管注册写流程)
-
阻止多线程同时注册同一个协议,避免重复注册覆盖;
-
保证
protocol这个复杂结构体完整拷贝写入(结构体不能原子操作); -
临界区内所有内存操作对其他加锁线程可见。
锁只保护写操作的并发安全,不负责服务高频无锁查询。
- atomic
valid 职责(面向所有读路径)
-
原子读写,消除 data race
-
业务线程不加锁读取
valid,是合法原子操作,标准定义行为,不会触发 UB; -
提供 release-acquire 内存屏障,阻止重排:写端 store (release):强制
protocol结构体拷贝先完成,再标记 valid=true;读端 load (acquire):读到 true 后,一定能看到完整初始化的 protocol; -
极低开销,适配高并发热路径
原子 load 仅一条 CPU 指令,无锁、无上下文切换,百万 QPS 无压力。
6 memory_order_seq_cst
在日常编写多线程C++代码时,我们或多或少都使用过原子操作。当我们在代码中写下counter.fetch_add或者flag.load()时,大多数情况下我们并不会传入第二个参数。这并不是因为标准库的接口设计比较简易,而是因为它为我们自动填补了一个最强也最安全的默认值:std::memory_order_seq_cst
#include <atomic>std::atomic<int> counter{0};void Increase() {// 默认传入了 std::memory_order_seq_cstcounter.fetch_add(1);
}int Read() {// 默认传入了 std::memory_order_seq_cstreturn counter.load();
}
这种设计体现了C++标准委员会在安全与性能之间的权衡。多线程并发中的内存序问题非常隐蔽,稍有不慎就会引发难以重现的数据竞争和指令重排Bug。通过将最强一致性级别的seq_cst设为默认值,标准库实际上是为所有开发者提供了一道天然的防护栏。只要你坚持不写任何内存序参数,并且所有的跨线程共享变量都由std::atomic封装,那么你的多线程代码就会像串行程序一样易于推理,而不需要去跟各种复杂的编译器和硬件重排特性死磕。
6.1 顺序一致性保证
顺序一致性(Sequential Consistency,简称seq_cst)这一术语定义可以追溯到计算机体系结构领域的奠基人 Leslie Lamport 在 1979 年发表的经典论文。在这篇论文中,Lamport 对顺序一致性给出了精确的形式化定义。简单来说,一个并发系统如果满足以下两个约束,就可以被认为是顺序一致的:
-
单处理器内程序:每个独立处理器或线程锁执行的全部内存操作顺序,必须与该处理器内程序代码所写下的指令执行顺序严格一致。
-
全局交错顺序(Interleaving Order Constraint):所有的访问内存操作都好似在一个全局的、唯一的中央存取设备上排队执行。每个操作对所有处理器而言,在时间上都是在同一个瞬间发生且完全一致的。
和 relaxed 正好相对,seq_cst 把下述两个东西全部管死,并在其上叠加了一条全局顺序。
-
指令重排:全部约束,连 Store-Load 也不放过。
seq_cst在操作的两侧都立起完整屏障:既禁止编译器把前后的指令挪过这次操作,也禁止 CPU 的乱序重排,包括 x86 上唯一允许的**Store-Load**重排。这是它和 release/acquire 的关键分界——release/acquire 挡不住Store-Load,seq_cst能。 -
缓存一致性延迟:全局排队可见。
**seq_cst**强制所有**seq_cst**操作排进同一条全局队列,每次 store 都按这个全局顺序对其他核心可见——等于在 release 的"刷出 Store Buffer"之上,又加了一层所有核心对先后关系达成共识。不仅这次写入会刷出,而且所有核心看到的刷出顺序都一致。
因此**seq_cst**保证了全局单一顺序(total order)。任何线程看到的 seq_cst 操作先后关系都完全一致,不存在"线程 A 觉得先 x 后 y、线程 B 觉得先 y 后 x的分歧。
7 memory_order_relaxed
7.1 memory_order_relaxed的原子保证
relaxed的意思是(松散的、放宽的),但是这并不意味着它连原子性都不保证,memory_order_relaxed 仍然是标准的原子操作。
#include <atomic>std::atomic<int> request_count{0};void OnRequest() {request_count.fetch_add(1, std::memory_order_relaxed);
}
即使加上了 relaxed 标签,fetch_add 依然是一个不可分割的读-改-写操作。如果有 100 个线程同时调用 OnRequest(),每个线程调 10000 次,最后 request_count 的值一定是一分不差的 1000000。它绝不会出现像普通 int 那样因为并发自增而丢失更新的情况。
不仅是读-改-写,load 和 store 也一样。一个 relaxed 的 store 仍然是一个完整的写入,不会出现高位写了一半、低位写了另一半的撕裂状态。
既然原子性没有打折扣,那它到底"放宽"了什么?它放宽的,正是前面两个根因上的全部约束
-
指令重排约束——完全不约束。 relaxed 不插入任何编译器屏障,也不生成任何 CPU 内存屏障指令。围绕这次 relaxed 操作,编译器和 CPU 可以自由地把前后的普通内存访问、甚至其他原子操作挪到它前面或后面。
-
缓存一致性延迟约束——完全不约束。relaxed 只保证这次读写本身在单个变量上原子(不撕裂、不丢更新),但它既不强制把 Store Buffer 刷出,也不强制触发缓存行同步。这次写入何时对其他核心可见,完全没有承诺——可能立刻可见,也可能在 Store Buffer 里赖一会儿,relaxed 不负责催它。
换句话说,relaxed 只兑现了 std::atomic 的原子性那一半承诺,把可见性和顺序性这两半彻底放弃了。它对应的是预告表里最左边那一档:最快,但什么同步都不给。
7.2 case
7.2.1 旁路计数器
既然relaxed保证了自身的原子性,又抛弃了昂贵的同步包袱,那它最契合的场景就是那些“只关心自己这个值,不牵扯别人”的旁路指标统计。
比如Web服务器里的请求计数、缓存的命中率统计、或者网络库里的丢包记录:
#include <atomic>struct ServerMetrics {std::atomic<long long> total_requests{0};std::atomic<long long> cache_hits{0};
};ServerMetrics g_metrics;void HandleRequest(bool hit_cache) {g_metrics.total_requests.fetch_add(1, std::memory_order_relaxed);if (hit_cache) {g_metrics.cache_hits.fetch_add(1, std::memory_order_relaxed);}// 处理具体的业务逻辑...
}
这段代码里, total_requests和cache_hits只是旁路数据。别的线程(比如监控线程)可能隔一秒钟过来load一次这些值,把它打印到日志或者发给时序数据库。
监控线程根本不在乎“当我看到请求数变成10000的时候,某个业务变量是不是已经更新了”。计数器的值并不作为某个逻辑分支的控制条件,也没有另一个线程在等这个计数器到达某个值然后去读一块内存。它不承担任何“通知”或者“数据发布”的职责,它只是一个孤立的数值。
在这种场景下,使用默认的纯粹是浪费性能。把它们降级为relaxed,既保证了统计的绝对seq_cst准确(不会丢更新),又给了编译器和CPU最大的优化自由
7.2.2 全局单调ID分配
#include <atomic>
#include <cstdint>std::atomic<std::uint64_t> next_id{1};std::uint64_t AllocateRequestId() {return next_id.fetch_add(1, std::memory_order_relaxed);
}
在微服务网关或者分布式追踪系统(Tracing)里,我们经常需要给每个进来的请求打上一个唯一的数字ID。这种场景的核心诉求只有一个:每次调用AllocateRequestId()近返回的值绝对不能重复。
fetch_add是原子的读-改-写操作,在硬件层面排了队,这已经保证了唯一性。至于这个 ID 分配出去之后,它和系统里的其他变量到底是个什么时序关系,分配器根本不关心。业务线程拿到这个ID去写日志、
去构造请求对象,那是业务线程自己内部的逻辑。
8 CAS和compare_exange
前面三档内存序(relaxed / acquire-release / seq_cst)解决的是可见性和顺序性问题——一个线程的写入何时、以什么顺序对另一个线程可见。但并发编程里还有另一类问题,和内存序属于不同维度:检查再写入,这种读-改-写复合操作的原子性。内存序管不了它,得靠 CAS。
观察以下业务场景:为一个账户编写余额扣款函数。假设账户的余额变量被声明为atomic
#include <atomic>std::atomic<int> balance{100};bool Withdraw(int amount) {if (balance.load() >= amount) {balance.store(balance.load() - amount);return true;}return false;
}
设想这样一个并发场景:账户当前的真实余额是 100,此时有 A 和 B 两个独立线程分别试图扣除 80。线程A率先执行了balance.load(),读到了 100,判断100>=80成立,准备执行扣款。但在执行store 之前, 操作系统的调度器将 CPU 切换给了线程 B。线程 B 同样执行了Load 操作,也读到了100,并做出了同样的判断准备扣款。随后,两个线程各自执行了store(100-80)。它们双双把余额改写成 20,并返回扣款成功。最终结果是:账户实际上扣除了160,但系统余额却显示为 20。
这个并发问题的根源并不在于load 和store 自身的原子性。问题在于:“检查状态"和“写入新状态”是两个独立的动作,它们之间存在一个时间差。在这个时间差内,其他线程可以介入并修改状态,导致之前检查的先决条件失效。
如果将最初的load读取也计算在内,这段扣款逻辑在底层其实被拆分成了三个独立的原子动作:读出余额、条件判断、基于旧值计算并写入新余额。业务逻辑要求这三个步骤作为一个不可分割的整体执行,但
基础的Load/store 无法提供这种保护。为了解决这类普遍的工程需求,我们需要一种原语,能够将“读取当前值、比较预期旧值、若匹配则写入新值”这三个动作在硬件层面压缩成一条不可分割的指令。
这就是并发编程中核心的 CAS(Compare-And-Swap,比较并交换)机制。在 C++标准库中,它对应着std::atomic类的两个重要接口:compare_exchange_weak 和compare_exchange_strong。
8.1 CAS
为了清晰地理解CAS的行为,我们可以用一段伪代码来描述它在底层的语义:
CAS(atomic_var, expected, desired):原子地执行以下逻辑:如果 atomic_var 当前的真实值 == expected:atomic_var = desired返回 true (表示写入成功)否则:expected = atomic_var 当前的真实值返回 false (表示预期不符,写入放弃)
这段包含内存访问和条件分支的逻辑,作为一个不可分割的硬件级事务一次性发生。操作系统的调度器和其他核心,既不能在“比对成功”和“写入新值”的间隙插入写操作,也不能在“比对失败”和“回读当前真实值”的过程中进行干预。对外部视角而言,这些操作要么全部完成,要么完全不发生。

8.2 compare_exchange接口设计
查看C++标准库的手册,compare_exchange_strong和compare_exchange_weak的函数签名如下
bool compare_exchange_weak(T& expected, T desired, ...);
bool compare_exchange_strong(T& expected, T desired, ...);
一个值得注意的细节是,expected参数是通过左值引用(T&)传递的,因此不能传常量、临时变量(编译报错)。我们先看一个失败的示例:
#include <atomic>
#include <iostream>std::atomic<int> value{10};int main() {int expected = 8;// 试图把 8 改成 20bool ok = value.compare_exchange_strong(expected, 20);std::cout << ok << "\n"; // 打印 0 (false)std::cout << expected << "\n"; // 打印 10std::cout << value.load() << "\n"; // 打印 10 (原子的目标值未被改动)
}
在调用 CAS 前,预期值expected 为 8,4 而原子变量的当前真实值为 10。 CAS 在底层比对发现 8 不等于 10,中断写入并返回 false。同时,作为引用传入的局部变量expected 被修改为了原子变量当前的真实值 10。
初次接触这种行为,部分开发者可能会觉得不适应:作为一个条件比对的变量,为何在内部被修改了?其实,这是一个注重工程实用性的设计。
在并发编程中,CAS 通常不是只调用一次的接口。它最经典的使用模式是嵌套在一个重试循环中:读取当前值、计算新值、尝试CAS 写入;如果失败,则基于最新值重新计算并再次尝试。
如果CAS 在比对失败时只返回false,调用方就必须额外发起一次load调用来获取最新值以开启下一轮循环。这在性能敏感的无锁结构中会带来不必要的开销。既然CAS在硬件执行比对时已经获取了当前的真实值,将这个新值通过expected 引用直接返回给调用方,就能省掉一次内存访问。
我们通过以下无锁更新最大值示例进行说明
#include <atomic>std::atomic<int> global_max{0};void UpdateMax(int candidate) {int current = global_max.load(std::memory_order_relaxed);// 只要候选值大于当前记录的最大值,就尝试更新while (candidate > current) {if (global_max.compare_exchange_weak(current,candidate,std::memory_order_relaxed,std::memory_order_relaxed)) {// CAS 更新成功return;}}
}
我们可以拆解多线程竞争时的执行路径:
假设线程进入UpdateMax(50),先Load 出当前的峰值为 30。判断条件50>30 成立,进入循环,发起 CAS 调用试图将 global_max 从 30 更新为 50。
-
情况一,CAS 成功:在发起调用期间,没有其他线程修改变量,真实值仍为 30。硬件原子改写为 50,CAS 返回 true,线程直接退出。
-
情况二,CAS 失败但候选值仍有效:假设在调用 CAS 前,另一线程已将
global_max修改为 40。CAS比对发现当前值并非预期的 30,判定写入失败,返回 false,同时将current 刷新为 40。线程回到**while(candidate >current)**评估条件**50> 40**依然成立。于是进行下一轮尝试,此时 CAS的预期值变成了 40。若中途无人干预,CAS 将成功写入50。 -
情况三,CAS 失败且候选值失效:如果另一线程直接将 global_max 更新到了 60。我们的 CAS 失败,current 被刷新为 60。回到candidate >current 时,发现50>60 不成立。循环结束,函数返回。虽然没有成功写入,但在业务逻辑上这是正确的,既然已有更大的值满足“全局最大值”语义,50作为候选值理应被淘汰。
在这个例子中, 使用 memory_order_relaxed 是合理的。因为 global_max 仅记录数字峰值,不承 担为其他共享数据做同步指示的职责。如果global_max 还需同步其他相关内存状态,则必须使用release/acquire 组合。
此外,C++的 CAS接口接受两个独立的内存序参数:
-
前一个代表 CAS 成功执行写入时的要求
-
后一个代表 CAS 失败只做读取时的要求。失败时只会读取原子变量、不会写入,因此有强制约束,标准规定,失败时的内存序不能强于成功时的内存序。即使写入失败,底层仍完成了一次原子读取操作,这个读取到的值在跨线程可见性上的要求,由第二个内存序参数决定。例如,success=acquire,failure 不能填 seq_cst;success=relaxed,failure 只能 relaxed。
8.3 weak与strong的区别与取舍
为什么C++会提供compare_exchange_weak 和compare_exchange_strong 两个接口? 这是基于底层硬件物理限制做出的设计折中。
compare_exchange_strong 的语义明确:仅当目标原子变量的真实值不等于expected 时,才会返回 false。
相比之下,compare_exchange_weak 多了一种被称为“伪失败”(Spurious Failure)的状态-一即使传入的预期值与真实值完全一致,CAS 也可能返回 false,并将expected原样写回,中止此次本该成功的
写入。
8.3.1 虚假失败
虚假失败仅存在于 compare_exchange_weak,compare_exchange_strong 无此现象: 原子变量的值 和 expected 完全相等,但 CAS 依然返回 false,交换没有执行。 从业务逻辑上明明满足交换条件,却无缘无故失败,这就叫虚假失败。这种情况是由底层硬件引起的,如下所述
CAS 底层依赖 CPU 的 LOCK 指令 / LL/SC 指令集:
-
x86:
lock cmpxchg,硬件保证无虚假失败,所以 weak 在 x86 几乎碰不到虚假失败; -
ARM/RISC-V:使用 Load-Link / Store-Conditional(LL/SC) 实现原子 CAS,这是虚假失败的源头:
-
LL addr:读取原子变量,同时 CPU 对该内存地址建立硬件监控锁(标记正在监听这段内存);
-
SC addr, val:尝试写入时直接失败,只有满足两个条件才会写入并返回成功
-
条件 1:内存当前值 == LL 读出的
expected; -
条件 2:从 LL 执行到 SC 执行期间,没有任何 CPU 核 / 中断修改过该监控地址。
在x86平台上,CAS底层指令LOCK确保了无虚假失败,但是在ARM平台上,只要监控状态被破坏,无论内存值是否没变,SC 直接失败,而这种监控被破坏的场景是不可避免的,如中断 / 线程切换清空 LL 监控:
-
线程 A:
LL读取内存值 = 10,expected=10,CPU 开启地址监控; -
LL之后、SC之前,内核触发硬件中断 / 时间片轮转切换线程:
- CPU 上下文切换时,硬件会自动清除当前核所有 LL 监控标记;
- 切回线程 A,执行
SC:
-
虽然内存值依然是 10(没有其他线程修改),但监控状态已失效;
-
SC 直接返回失败,CAS 返回
false,同时把最新的内存值重新写入expected。
此时:原子变量值 == expected,但 CAS 依然失败,这就是虚假失败。
这种由环境引发的失败在硬件层面上难以避免,如果在编译器内部封装循环兜底来掩盖,可能会影响指令流水的性能。因此比compare_exchange_weak将这一硬件现实暴露给调用方:既然无锁逻辑通常包裹在重试循环中,那么允许偶发的伪失败,可以省去一层内部封装的开销。
相比之下,compare_exchange_strong则是另一种设计,它在内部做了封装:如果检测到虚假失败,库层面自动循环重试直到成功;对外屏蔽虚假失败,用户感知不到,代价是多一层循环逻辑,少量性能损耗。
8.3.2 使用取舍
在工程实践中,常常遵循以下原则:
-
若 CAS 在while循环中重试,优先使用 weak****。循环自然能够消化偶发的伪失败,并且在部分平台上能带来轻量化的性能优势。
-
若CAS逻辑只尝试一次不重试,请使用strong。例如竞争初始化某个标志位,只有第一个线程能成功写入,其余线程走另一业务分支。在这种一次性判定下,strong 的确定性能减少心智负担。
不要因为名字中的“weak”而认为其在线程安全性或原子性上有任何折扣。weak在应对并发冲突时的保障强度与strong 相同,它只是允许偶发的硬件级伪失败,这纯粹是为了性能做出的工程权衡。
8.4 case
int64_t CircuitBreaker::EmaErrorRecorder::UpdateLatency(int64_t latency) {int64_t ema_latency = _ema_latency.load(butil::memory_order_relaxed);do {int64_t next_ema_latency = 0;if (0 == ema_latency) {next_ema_latency = latency;} else {next_ema_latency = ema_latency * _smooth + latency * (1 - _smooth);}if (_ema_latency.compare_exchange_weak(ema_latency, next_ema_latency)) {return next_ema_latency;}} while(true);
}
这是brpc熔断组件里指数移动平均(EMA,Exponential Moving Average) 的无锁更新逻辑,作用: 持续平滑统计接口调用耗时 latency,弱化毛刺延迟,给熔断策略提供稳定的平均延迟指标; 全程只用原子变量 + compare_exchange_weak 自旋,无锁并发安全,多线程同时调用不会数据竞争。
这段代码其实就做了一件事情:更新_ema_latency,我们逐步分析一下,在单线程视角下,其语义和下述代码一致
int64_t ema_latency = _ema_latency.load(butil::memory_order_relaxed);
// 错误示范
auto new_ema = ema_latency * smooth + latency*(1-smooth);
_ema_latency.store(new_ema, relaxed);
但是在多线程视角下,这里就存在致命缺陷:读、计算、写三步非原子。假设线程 A load=100,线程 B 同时 load=100;两者各自算出 new 值,先后 store,后写入的会直接覆盖前者的计算结果,丢失一条延迟采样,EMA 统计失真。 EMA 的计算强依赖读取时的旧值,必须 “读 - 算 - 写” 整体原子化,只有 CAS 能实现。因此需要考虑的就是使用compare_exchange_strong还是compare_exchange_weak的问题。
我们先抛开使用compare_exchange_strong 还是compare_exchange_weak 的视角,从业务逻辑上看,这段代码的需求是统计EMA延迟,所以要求每一次上报的 latency 必须合并到 EMA 里,绝对不能丢失采样,因此考虑到写入失败的问题(本地快照过期,必须重新 load、重新计算 EMA,再重试 CAS),必须有一个循环重试机制来处理并发真实冲突
do {// 失败后必须刷新快照,重新计算EMAload最新ema_latency计算next_ema_latency
} while (!cas(ema_latency, next_ema_latency));
所以从这个角度看,使用compare_exchange_weak 天然就比compare_exchange_strong 合适
所有失败原因(并发真实冲突 / 硬件虚假失败)收敛到同一套循环逻辑:
-
CAS 返回 false;
-
自动刷新本地
ema_latency为内存最新值; -
回到循环开头重新计算 EMA;
这样无论快照过期 / 虚假失败都会回到循环开头,用最新的ema_latency重新计算 EMA,再次尝试 CAS,直到更新成功。
9 总结
-
原子性不等于顺序性:std::atomic保证原子性,但顺序性需要memory_order来控制。
-
重排发生在三个层面:编译器重排、CPU乱序执行、缓存同步延迟。
-
release/acquire 是最常用的配对:生产者release发布信号,消费者acquire获取信号,建立跨线程的happens-before 关系。
-
我们的生产环境是x86,底层硬件机制以及保证了大部分的乱序,即便是store-load这种不保证的乱序,使用默认内存序也可扼杀所有意外情况
-
不确定的情况下直接使用默认约束最强的memory_order_seq_cst即可,其余内存序可以作为性能提升考虑
| 内存序 | 保证原子性 | 保证顺序 / 可见性 | 常用场景 |
|---|---|---|---|
| memory_order_relaxed | ✅ | ❌ | 单纯计数器,无需其他变量的依赖 |
| memory_order_acquire | ✅ | 读取后,后续读不能前提 | 消费者获取信号 |
| memory_order_release | ✅ | 写入前,前面写不能后移 | 生产者发出信号 |
| memory_order_acq_rel | ✅ | 同时满足 acquire + release | 读 - 改 - 写复合操作 |
| memory_order_seq_cst | ✅ | 全局顺序一致 | 默认行为,最安全但最慢 |
-
如果你的原子变量是用来传递"某些别的数据已经准备好了"这种信号,就必须用release/acquire
-
如果仅仅是独立计数(如请求次数、当前连接数),relaxed就够了。
-
如果你不确定,且这不是性能热点,直接用默认的seq_cst
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 单线程访问的变量 | 普通变量 | 无需任何同步开销 |
| 多线程只读数据 | const + 非原子 | 读不需要同步 |
| 简单计数器 | std::atomic + relaxed | 只需要原子性 |
| 生产者 - 消费者标志位 | atomic + release/acquire | 需要保证见到前面的数据 |
| 多个原子变量的复杂协调 | atomic + seq_cst | 全局顺序保证 |
| 实在不确定 | std::mutex | 锁是最保险的选择 |
