国内某头部 quant 在 CFFEX 数据中心做股指期货 colo 部署的基础架构 lead,正在 review 一名 junior engineer 的一行 patch。这行改动把策略事件计数器上的 std::mutex 删了——这个计数器是 dashboard 每秒读一次的指标。Engineer 的理由:「计数只增不减,热点路径上也从不读它。」PR 描述末尾写着:「应该安全;x86_64 上整数本来就是 atomic 的。」Review 评论贴在那一行上:「不,对 C++ 标准而言它们不是——把它写成 std::atomic<long> + memory_order_relaxed,或者把锁留着。今天证明它『安全』的 benchmark,对编译器明天被允许怎么优化它一无所知。」补丁改成 std::atomic<long> 加显式 relaxed ordering。Dashboard 一切正常。第二周同款 review 又来了一次,这次是一个发布报价更新(一条线程写、策略线程读)的标志位——只是这一次正确的顺序是 release / acquire,不是 relaxed。本课要装进你身上的纪律恰恰就是这位 lead 在强制的那一条:每一处被多于一条线程访问、且没有互斥锁保护的共享内存位置,都需要 std::atomic<T> 加一个显式的 std::memory_order;要选对那个顺序,得懂 C++ 内存模型。L1 留给我们互斥锁 + 条件变量 + future——它们底下其实已经替我们建立了同步。L2 教那一层底下的层,L3 才能把原子拼成不带锁的无锁 SPSC 环形缓冲。
(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
数据竞争与 std::atomic<T>
C++ 标准对数据竞争的定义在操作上是严格的:两条线程并发访问同一内存位置、至少有一次是写、且这些访问没有被同步关系(互斥锁获取、原子操作或 fence)排序。带数据竞争的程序行为是未定义的。不是「值不确定」。是未定义。优化器被允许假设你的程序无数据竞争并按此假设重写你的代码;如果假设不成立,什么都可能发生——错值、死循环、安全漏洞、优化器「证明」某段你依赖的代码不可达。
std::atomic<T> 是这门语言为「不用互斥锁的跨线程共享内存位置」准备的工具。它适用于基本整型(int、long、bool、T*)和任意可平凡复制的用户类型,前提是不超过本平台 std::atomic<T>::is_lock_free() 的大小(x86_64 上通常 8 字节;带 cmpxchg16b 的 x86_64 是 16 字节)。超过这个大小,操作就由内部锁保护——表面 API 一致,但你已经丢掉了「lock-free」属性。可用操作:load(读)、store(写)、exchange(原子交换)、fetch_add / fetch_sub / fetch_and / fetch_or / fetch_xor(原子整型读改写)、加上两种 compare-exchange compare_exchange_weak 与 compare_exchange_strong(无锁 RMW 的通用基元)。
内存顺序菜单
每个原子操作都接受一个可选的 std::memory_order 参数。完整菜单共六个值;其中一个(memory_order_consume)实际上已被弃用,剩下五个在操作上有意义。把下面这张表当作本模块剩余部分的承重参考卡:
| 名称 | 可用于 | 操作含义 | x86_64 代价 |
|---|---|---|---|
memory_order_relaxed | load + store + RMW | 仅原子性;无跨线程顺序。用于「只在末尾读」的计数器。 | ~1 cycle |
memory_order_acquire | load + RMW-on-success | 后续 load / store 不可重排到此之前。配对 release。 | ~1 cycle(x86 是 TSO) |
memory_order_release | store + RMW-on-success | 之前 load / store 不可重排到此之后。配对 acquire。 | ~1 cycle |
memory_order_acq_rel | 仅 RMW | 二者兼备,用于读改写。 | ~1 cycle |
memory_order_seq_cst | 所有操作 | 全部线程间全局总序;安全基准。 | x86_64 store 上 ~20 cycles(MFENCE / locked 指令) |
用一行数学表达,release / acquire 行底下的 synchronizes-with 关系是:
两条实操规则把这张表凝练下来。其一,**memory_order_seq_cst 是安全默认:每条线程都看到所有 seq-cst 操作的同一个全序,唯一代价是 x86_64 store 上的 MFENCE。除非你测出来它确实重要,否则就用它。其二,release / acquire 是发布模式的实操主力**:生产者写数据,然后 release-store 一个标志;消费者 acquire-load 那个标志,然后读数据。release / acquire 的配对建立标准要求的 synchronizes-with 关系,且在 x86_64 上几乎免费,因为 x86 本来就是 TSO(Total Store Order)架构。
Synchronizes-with:数据发布模式
synchronizes-with 关系是让无锁编程成立的那条规则。定义:一次原子的 release-store,后面跟一次对同一原子、且观察到 release-store 写入值的 acquire-load,就在生产者线程之前的写、与消费者线程之后的读之间,建立了 happens-before 边。白话版:「生产者在 release-store 之前写过的所有东西,在消费者完成观察到 release-store 的 acquire-load 之后都对它可见。」
经典范例是数据发布模式。生产者填充一个 Quote 结构,然后以 release 语义置位一个 std::atomic<bool> ready 标志。消费者以 acquire 语义在标志上自旋,然后读取报价。
#include <atomic>
#include <thread>
struct Quote { double bid; double ask; std::int64_t ts_ns; };
Quote g_quote{}; // plain (non-atomic) — protected by ready_ below
std::atomic<bool> g_ready{false}; // the publication flag
void producer() {
g_quote = Quote{4.198, 4.202, /*ts*/ 1};
g_ready.store(true, std::memory_order_release); // publish: prior writes happen-before any acquire-load of g_ready==true
}
void consumer() {
while (!g_ready.load(std::memory_order_acquire)) { /* spin */ }
// we observed g_ready==true via acquire; producer's g_quote write happens-before this read.
Quote q = g_quote;
std::printf("bid=%.4f ask=%.4f ts=%lld\n", q.bid, q.ask, static_cast<long long>(q.ts_ns));
}
release-store 与 acquire-load 是这段程序里唯一的同步。没有互斥锁,没有条件变量。但消费者正确读到了 g_quote——因为标准保证:每当消费者的 acquire-load 观察到生产者的 release-store 所发布的值时,生产者对 g_quote 的普通 store happens-before 消费者对 g_quote 的普通 load。这是让 L3 的 SPSC 环形缓冲成立的那条承重规则。
特别提一下 x86_64:把两处的内存顺序都改成 std::memory_order_relaxed,代码在 x86_64 上看起来也会对——因为 x86-TSO 在硬件层就已经禁止了相关的重排。这种观察是危险的。C++ 标准模型是平台中立的;relaxed 版本是未定义行为,一个合规的 ARM 或 POWER 实现(两者的内存模型都比 x86-TSO 弱)有权把消费者的读重排到 acquire-load 之前,返回生产者写之前 g_quote 里残留的任意垃圾。可移植性与向前兼容性都要求你显式写 release / acquire。
伪共享
两个相邻摆放的 std::atomic<long> 计数器,默认会落在同一条 64 字节的 cache line 上。两条线程并发往这两个计数器写时,缓存一致性协议每次只要对面那个核也写,就得让本核 L1 上的这条 line 失效。结果:相比每个计数器各占一条 cache line 的同款负载,慢 5–10 倍。这就是伪共享——变量逻辑上独立,物理上却共用同一个硬件一致性单元。
修法是 alignas(std::hardware_destructive_interference_size)(C++17,多数现代标准库都已提供)——编译器侧的常量,含义是「两个相邻变量不再共用同一条 cache line 的尺寸」。x86_64 上是 64;某些 ARM 核上是 128(因为相邻行预取)。可移植回退是 alignas(64)。
#include <atomic>
#include <new>
#include <thread>
struct Bad {
std::atomic<long> a{0};
std::atomic<long> b{0};
};
#ifdef __cpp_lib_hardware_interference_size
constexpr std::size_t kCacheLine = std::hardware_destructive_interference_size;
#else
constexpr std::size_t kCacheLine = 64; // safe fallback
#endif
struct Good {
alignas(kCacheLine) std::atomic<long> a{0};
alignas(kCacheLine) std::atomic<long> b{0};
};
static constexpr long kIters = 100'000'000;
template <typename T>
void bench(T& s) {
std::thread t1([&]{ for (long i = 0; i < kIters; ++i) s.a.fetch_add(1, std::memory_order_relaxed); });
std::thread t2([&]{ for (long i = 0; i < kIters; ++i) s.b.fetch_add(1, std::memory_order_relaxed); });
t1.join();
t2.join();
}
这里 fetch_add 选 memory_order_relaxed 是正确的,因为这两个计数器都在两线程 join 之后才被读;不需要跨线程顺序,仅需原子性。在安静机器上把两条线程绑到同一 socket 的两个核上测:
# Build with optimization; pin to two cores on the same socket; observe cache misses.
g++ -std=c++17 -O3 -pthread -DUSE_BAD false_share.cpp -o bench_bad
g++ -std=c++17 -O3 -pthread -DUSE_GOOD false_share.cpp -o bench_good
# Pin to physical cores 2 and 3 (assumed same socket on a single-socket Xeon Gold / EPYC).
# Compare wall-clock time and L1d cache-miss rate.
taskset -c 2,3 perf stat -e cache-misses,cache-references,instructions,cycles ./bench_bad
taskset -c 2,3 perf stat -e cache-misses,cache-references,instructions,cycles ./bench_good
# Expected: bench_good is 5-10x faster, with a near-zero cache-miss delta vs single-threaded baseline.
诊断指标是 cache-miss 数。在 Bad 版本里,任何一核的每次 fetch_add 都让对面核上的那条 line 失效;perf stat 报告千万级的 cache miss。在 Good 版本里每个计数器都各占一条 line;cache-miss 数量塌回单线程 baseline。这是 3.4.3 L2 那个预告的操作化兑现——也是 L3 把 SPSC 环形缓冲的 head 与 tail 索引放到不同 cache line 上时复用的那项承重技术。
compare-exchange 循环
compare-and-exchange 操作是无锁读改写的通用基元。形式:「若原子当前值等于 expected,则写入 desired 并返回 true;否则把当前值覆盖回 expected 并返回 false。」放进循环里,就实现「原子地对值应用函数 f」:
// Atomically apply f to *x and return the new value. Universal lock-free RMW pattern.
template <typename T, typename F>
T atomic_update(std::atomic<T>& x, F f) {
T expected = x.load(std::memory_order_relaxed);
T desired;
do {
desired = f(expected);
} while (!x.compare_exchange_weak(
expected, desired,
std::memory_order_acq_rel, // success ordering: RMW publishes prior writes + observes prior writes
std::memory_order_relaxed)); // failure ordering: we only reread x, no synchronization needed
return desired;
}
// Example: maintain a running max of doubles across threads.
inline void atomic_max_update(std::atomic<double>& m, double sample) {
atomic_update(m, [&](double cur){ return cur > sample ? cur : sample; });
}
两个顺序参数都重要。成功侧用 memory_order_acq_rel:一次成功的 RMW 既发布写者之前的写(release 半),也观察对同一原子上前一名写者的 release-store(acquire 半)。失败侧用 memory_order_relaxed:失败时我们只是重读原子刷新 expected,不需要跨线程同步。
compare_exchange_weak 与 _strong 在 LL/SC 架构(ARM、POWER、RISC-V)上有差异。「weak」形式允许在比较其实成功的情况下假性失败——底下的 load-linked / store-conditional 配对可能因为上下文切换、cache 驱逐或竞争误判而中止。「strong」形式内部重试直到给出确定答案。循环里用 weak(循环本身就处理失败);只能跑一次时用 strong(少见,但出现在一些乐观锁模式里)。x86_64 上区别可忽略,因为 x86 的 lock cmpxchg 永不假性失败。上面的 atomic_update 模板用 _weak,因为 do-while 循环已经天然处理失败。
lock-free 真正给你什么
std::atomic<T>::is_always_lock_free 是一个 constexpr bool,报告在当前目标上对此类型的操作是否会编译为无锁的硬件指令。x86_64 上 std::atomic<long> 是 true(操作是 lock-prefixed 指令,~10–30 cycles);std::atomic<__int128> 取决于平台(要真无锁需要 cmpxchg16b,但并非所有芯片都声明支持)。更大的用户类型,std::atomic<T> 仍然能编译——但操作会偷偷拿一把内部锁,于是你拥有了和互斥锁等价的代价、却完全没有它的 API 工效。**只要你把 std::atomic 用到基本整型或指针以外的类型上,请始终在编译期检查 is_always_lock_free**。在声明附近放一句 static_assert(std::atomic<T>::is_always_lock_free) 是标准纪律。
下一课
L3 把本课搭起来的原语——原子类型、release / acquire 配对、cache line 对齐——拼成低延迟交易里最重要的一种无锁数据结构:单生产者单消费者环形缓冲。生产者 head 索引上的 release-store synchronizes-with 消费者 head 索引上的 acquire-load,正如本课的数据发布模式——只是从「每条数据流一次」变成「每条消息一次」。伪共享那一节的 cache line 对齐技术把两个索引放到独立 line 上,让生产者写和消费者写永远不会互相失效彼此的 line。L3 还加入了 CPU 亲和性与线程绑核——本课留作「假设一线程一核、不迁移」的那一面,由调度器侧把它补齐。当你能不假思索地说出 memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_seq_cst 之间的差别,就可以进 L3 了。
练习
Exercise
(a) 把数据发布范例搭起来,跑两条线程(先生产者、后消费者),确认消费者打印出预期的 bid / ask / ts。然后把两处内存顺序都改成 std::memory_order_relaxed,用 -O3 编译并在 x86_64 上跑——程序很可能看起来仍正确,因为 x86-TSO 在硬件层就提供了 acquire / release 语义。用两句话说明为什么这段代码按 C++ 标准依然是未定义行为(语言模型允许合规实现重排;对 ARM / POWER 的可移植性被打破)。(b) 把伪共享 benchmark 在 -DUSE_BAD 和 -DUSE_GOOD 两种配置下都构建,并把两者都绑到同一 socket 的两个核上跑:taskset -c 2,3 perf stat -e cache-misses,cache-references,instructions,cycles ./bench_bad 与 ./bench_good。记录两者的 wall-clock 时间与 cache-miss 数。好版本应当比坏版本快 5–10 倍、且 cache-miss 差近乎为零。用一句话说明 alignas(std::hardware_destructive_interference_size) 究竟做了什么让这一切发生。(c) 用 atomic_update 维护一个 4 线程产生的 1000 万 double 样本的运行最大值(每条线程用 std::mt19937 rng(thread_id); std::uniform_real_distribution<double> dist(0.0, 1.0); 生成 250 万个)。确认最终最大值在 1.0 的 1e-6 邻域内。把 compare_exchange_weak 换成 compare_exchange_strong 重跑;x86_64 上吞吐相近;ARM 上 weak 形式更快,因为 LL/SC 无需假性失败。(d) 读那张 std::memory_order 参考表,分别用一句话回答:为什么 memory_order_relaxed 对一个「指标线程仅在退出时读一次」的事件计数器是正确选择?为什么 memory_order_seq_cst 在交互式 code review 里是正确默认,即便它在 x86_64 上代价更高?
提示
(a):未定义行为说的是 C++ 抽象机允许什么,不是 x86 今天碰巧做了什么。同一份源码到 ARM 上编译时,消费者的读可能被重排到 acquire-load 之前。
提示
(b):伪共享发生在硬件一致性单元这一层,不在语言这一层。两个逻辑独立的变量若共用一条 cache line,每次任意一核写,协议都得把这条 line ping-pong 到对面。