
加了std::atomic结果还是错的这是学并发时最让人抓狂的一类问题。原因在于原子性只保证「这一步不可分割」不保证「两步之间的先后顺序」。编译器和 CPU 都可能在背后重排你的内存访问而memory_order就是用来告诉它们「这里不能乱动」的那把尺子。这篇把六种内存序各自保证什么讲清给出最常用的 release/acquire 配对模式并用实测数据说明一个很多人不知道的事实在 x86 上它的性能代价比你想象的小得多。1. 引子为什么「两个原子操作」还是会出错假设生产者要发布一块配置写成了下面这样// 反例不要这么写用了 relaxed没有任何跨线程同步#includeatomicintg_config0;// 非原子共享数据std::atomicboolg_ready{false};voidpublish(){g_config42;// ① 写数据g_ready.store(true,std::memory_order_relaxed);// ② 发布标志relaxed}intconsume(){while(!g_ready.load(std::memory_order_relaxed)){}// ③ 等标志returng_config;// ④ 可能读到 0}两个操作都是原子的g_ready也不可能撕裂但consume()依然可能返回0。因为relaxed只保证「g_ready的读写不可分割」完全不保证 ① 和 ② 的顺序编译器可以把g_ready.store(true)提到g_config 42前面编译器眼中这是两个互不相干的内存位置CPU 也可能在写缓冲里先提交标志再提交数据。一旦顺序颠倒消费者就会「看到标志已发布、但数据还是旧的」。正确的写法只需要把 ② 换成memory_order_release、③ 换成memory_order_acquire。这一篇就是讲清「为什么这样换就好了」。官方文档std::memory_order — cppreference六种枚举值与各自的正式保证全在这里· C Core Guidelines CP.8 / CP.432. 乱序从哪来编译器重排 CPU 重排内存序之所以存在是因为两个层级都在偷偷优化顺序① 编译器重排compile-time reordering—— 任何架构上都会发生 ┌──────────────────────────────────────────────────────────────┐ │ 源码顺序 编译器优化后-O2无内存序约束 │ │ g_config 42; g_ready.store(true); ← 被提到前面 │ │ g_ready.store(true); g_config 42; │ │ │ │ 依据两者访问不同内存位置编译器认为「没有依赖关系」 │ └──────────────────────────────────────────────────────────────┘ ② CPU 重排run-time reordering—— 弱内存序架构上常见 ┌──────────────────────────────────────────────────────────────┐ │ CPU 核心 store buffer │ │ ┌───────────┐ 写 g_config ──► [ 42 ] ──┐ │ │ │ 执行单元 │ │ 异步刷入缓存 │ │ └───────────┘ 写 g_ready ───► [ true ]─┼──► L1/L2/L3 │ │ │ │ │ 两个写在不同条目里刷新到缓存的先后顺序可以互换 │ │ → 其他核心可能先看到 g_readytrue再看 g_config 还是 0 │ └──────────────────────────────────────────────────────────────┘ memory_order 的作用给这些重排划定「不许越过」的边界 - 对编译器不许跨边界重排这是 memory_order 在任何平台上的确定收益 - 对 CPU在需要时插入屏障指令如 x86 的 mfence、ARM 的 dmb ish关键认识编译器重排是「零成本」的它不插任何指令效果全在编译器内部而 CPU 屏障是有代价的。所以memory_order的收益有两层第一层挡住编译器几乎总是值得的第二层换掉 CPU 屏障要看架构。3. 六种内存序一张表说清内存序保证什么典型用途备注relaxed只保证本操作的原子性没有任何跨线程同步纯计数器、统计量最强性能最容易用错consume依赖链上的可见性几乎无人使用不推荐实践上等同acquireacquire用于load之后的读写不能被提到本操作之前消费者等标志、取锁必须与release配对release用于store之前的读写不能被推到本操作之后生产者发布标志、放锁必须与acquire配对acq_rel同时具备 both读-改-写操作如fetch_add用自旋锁的exchange、引用计数只在 RMW 操作上合法seq_cst在 acquire/release 之上再加「所有 seq_cst 操作有唯一的全局顺序」默认值不确定就用它最强也最保守关于consume它原本是为「依赖链」设计的一种更弱的序但主流编译器一直把它实现成acquire因为正确实现依赖链的优化太复杂C 标准也建议不要使用它。所以实际可选的只有五个而consume在这篇里不再出现。三条找序的规则记不住表的时候用它们要「发布」就给 store 加release我写的东西别人得看全要「等待」就给 load 加acquire别人写完的东西我要看全读-改-写要么acq_rel、要么relaxedacquire/release单独用在 RMW 上通常是想表达 acquire 或 release 的语义容易混淆直接用acq_rel更清楚。4.relaxed只要原子性不要同步relaxed唯一不给的就是同步而这恰恰是很多场景真正需要的比如统计计数。四个线程各做十万次自增最后汇总结果必须是确定的 400000// memory_order_relaxed.cpp — 编译: g -stdc17 -Wall -O2 -pthread memory_order_relaxed.cpp -o morx#includeatomic#includecstdio#includethread#includevectorintmain(){constexprintkThreads4;constexprintkRounds100000;std::atomicintcounter{0};std::vectorstd::threadworkers;for(intt0;tkThreads;t){workers.emplace_back([counter]{for(intk0;kkRounds;k){counter.fetch_add(1,std::memory_order_relaxed);// 只要原子性不要同步}});}for(autot:workers)t.join();std::printf(relaxed 计数 %d期望 %d\n,counter.load(),kThreads*kRounds);std::atomicinta{0};std::atomicintb{0};std::vectorstd::threadmixed;for(intt0;tkThreads;t){mixed.emplace_back([a,b]{for(intk0;kkRounds;k){a.fetch_add(1,std::memory_order_acq_rel);// RMW 想两者兼得b.fetch_add(1,std::memory_order_release);// release 对 RMW 也合法}});}for(autot:mixed)t.join();std::printf(acq_rel 计数 %drelease 计数 %d期望都是 %d\n,a.load(),b.load(),kThreads*kRounds);}relaxed 计数 400000期望 400000 acq_rel 计数 400000release 计数 400000期望都是 400000这段告诉我们relaxed的「弱」体现在别处不体现在自己的计数上。counter.fetch_add(1, relaxed)依然是不可分割的不会丢更新它弱的只是「和别处的内存访问之间的顺序」。那relaxed什么时候会真的出错回到第 1 节那个反例用它发布标志。这是用错内存序 难复现的 bug的典型形态它在 x86 上可能跑一万次都「看起来正常」因为 x86 是强内存模型绝大部分重排在硬件层就被禁止了到了 ARM 或换个编译器版本就偶发失败而且你几乎不可能用调试器复现它。这类问题的代价远高于省下来的那几纳秒。5.acquire/release最常用的配对模式这是必须记住的一个组合release和acquire必须在同一个原子对象上配对使用才能建立 happens-before 关系。release / acquire 配对建立 happens-before 生产者线程 消费者线程 ────────────────────────────────────────────────────────────────────── payload 20260922; ← ① 普通写非原子 │ │ release 的语义本操作之前的所有读写 │ 都不能被重排到本操作之后 ▼ published.store(true, release); ──────► published.load(acquire) ▲ │ │ │ acquire 的语义本操作之后的 │ │ 所有读写都不能被重排到本操作之前 │ ▼ │ 读 payload ──► 保证看到 20260922 │ └── 两个线程之间建立了同步生产者的 ① 对消费者可见 请注意release/acquire 只保证这一对之间的顺序 它们不会让 payload 的读写变原子 —— payload 此时已不再被并发修改 所以是安全的。若生产者要反复改 payload就必须用锁或让 payload 自身原子化。下面这段把三个消费者放到同一个发布事件后面输出完全确定// memory_order_publish.cpp — 编译: g -stdc17 -Wall -O2 -pthread memory_order_publish.cpp -o mop#includeatomic#includecstdio#includethread#includevectorintmain(){constexprintkConsumers3;constexprintkPayload20260922;intpayload0;// 非原子靠 release/acquire 保护std::atomicboolpublished{false};std::atomicintseen{0};std::vectorintgot(kConsumers,0);std::vectorstd::threadconsumers;for(intc0;ckConsumers;c){consumers.emplace_back([,c]{while(!published.load(std::memory_order_acquire)){}// ③ 自旋直到看到发布got[c]payload;// ④ 发布之后读必然可见seen.fetch_add(1,std::memory_order_relaxed);});}std::threadproducer([]{payloadkPayload;// ① 先写数据published.store(true,std::memory_order_release);// ② 再发布标志});producer.join();for(autot:consumers)t.join();std::printf(每个消费者读到的 payload %d %d %d\n,got[0],got[1],got[2]);std::printf(消费者全部退出 %d\n,static_castint(seen.load()kConsumers));}每个消费者读到的 payload 20260922 20260922 20260922 消费者全部退出 1三个消费者都拿到了完整的数据。注意这里不是「运气好」release保证payload kPayload在标志发布之前完成acquire保证看到标志之后的所有读都能看到那批写。把acquire/release换成relaxed程序在 x86 上多半也能打印正确结果但这只是 x86 强内存模型给你的假象。最后一行消费者全部退出 1用的是relaxed累加seen它的正确性来自「每个线程只fetch_add一次、总和与顺序无关」而不是来自内存序。这正是relaxed的正当用法。6.seq_cst默认、最强以及在 x86 上的实测真相seq_cstsequentially consistent顺序一致是在acquire/release之上再加一条所有seq_cst操作存在一个所有线程都认同的全局顺序。它是所有原子操作的默认参数也是唯一能防止「独立的两对 release/acquire 之间出现跨对象重排」的序经典的 Dekker / store-buffer litmus test 就需要它。「最强所以最慢」这个说法流传很广但值得亲自量一次// memory_order_cost.cpp — 编译: g -stdc17 -Wall -O2 -pthread memory_order_cost.cpp -o moc#includeatomic#includechrono#includecstdio#includethread#includevectorintmain(){constexprintkThreads4;constexprintkRounds1000000;std::atomiclonglongrelaxed_counter{0};std::atomiclonglongseq_cst_counter{0};constautot0std::chrono::steady_clock::now();{std::vectorstd::threadworkers;for(intt0;tkThreads;t){workers.emplace_back([relaxed_counter]{for(intk0;kkRounds;k){relaxed_counter.fetch_add(1,std::memory_order_relaxed);}});}for(autot:workers)t.join();}constautot1std::chrono::steady_clock::now();{std::vectorstd::threadworkers;for(intt0;tkThreads;t){workers.emplace_back([seq_cst_counter]{for(intk0;kkRounds;k){seq_cst_counter.fetch_add(1,std::memory_order_seq_cst);}});}for(autot:workers)t.join();}constautot2std::chrono::steady_clock::now();std::printf(relaxed 计数 %lld耗时 %lld ms\n,relaxed_counter.load(),static_castlonglong(std::chrono::duration_caststd::chrono::milliseconds(t1-t0).count()));std::printf(seq_cst 计数 %lld耗时 %lld ms\n,seq_cst_counter.load(),static_castlonglong(std::chrono::duration_caststd::chrono::milliseconds(t2-t1).count()));}relaxed 计数 4000000耗时 ~~ ms seq_cst 计数 4000000耗时 ~~ ms实测结果是两者几乎一样甚至互有胜负同一台机器上多跑几次谁快谁慢会换位。原因很硬核x86-64上fetch_add无论什么内存序都编译成同一条lock xadd而lock前缀本身就带全屏障在 x86 上relaxed的 RMW 根本没有更省的指令可用。这条实测带来三个务实结论你的处境建议原因不确定该用哪个用默认的seq_cst正确性优先写错了是难复现的 UB省下的纳秒不值x86-64 上做 RMW 计数内存序几乎不影响耗时lock前缀已是全屏障换序省不到东西目标平台是 ARM / Power 等弱内存序load/store 上认真选acquire/release那里的屏障是真指令dmb ish等代价可见编译器的重排任何平台都值得挡挡编译器重排是零指令成本的所以对memory_order的正确态度是它是优化手段不是「正确的写法」。先用seq_cst把逻辑写对、跑通、覆盖到压力测试确认这个原子操作真的是热点profiler 说了算之后再一个变量一个变量地往下弱化每次弱化都重新验证。反过来做一上来全用relaxed得到的是「在 x86 上能过测试、换平台就崩」的代码。7. 完整示例发布 并发消费把两个模式合起来生产者一次性填好 1000 个数据再release发布四个消费者acquire等到发布后各自求和这一步是普通读靠内存序保护再用relaxed把结果汇总。// memory_order_final.cpp — 编译: g -stdc17 -Wall -O2 -pthread memory_order_final.cpp -o mof#includeatomic#includecstdio#includethread#includevectorintmain(){constexprintkConsumers4;constexprintkCount1000;std::vectorintdata(static_caststd::size_t(kCount),0);// 非原子的共享数据std::atomicboolready{false};std::atomiclonglonggrand_total{0};// 纯累加 → relaxed 足够std::atomicintfinished{0};std::vectorlonglongper_consumer(static_caststd::size_t(kConsumers),0);std::vectorstd::threadconsumers;for(intc0;ckConsumers;c){consumers.emplace_back([,c]{while(!ready.load(std::memory_order_acquire)){}// ③ 等发布acquirelonglongsum0;for(inti0;ikCount;i){sumdata[static_caststd::size_t(i)];// ④ 发布之后读安全}per_consumer[static_caststd::size_t(c)]sum;grand_total.fetch_add(sum,std::memory_order_relaxed);finished.fetch_add(1,std::memory_order_relaxed);});}std::threadproducer([]{for(inti0;ikCount;i){data[static_caststd::size_t(i)]i1;// ① 先填数据}ready.store(true,std::memory_order_release);// ② 再发布release});producer.join();for(autot:consumers)t.join();std::printf(每个消费者的求和 %lld %lld %lld %lld\n,per_consumer[0],per_consumer[1],per_consumer[2],per_consumer[3]);std::printf(汇总relaxed 累加 %lld\n,grand_total.load());std::printf(完成标记relaxed %d / %d\n,finished.load(),kConsumers);}每个消费者的求和 500500 500500 500500 500500 汇总relaxed 累加 2002000 完成标记relaxed 4 / 4四个消费者各自求和都是 12…1000 500500它们看到的是同一份完整数据而不是「填到一半的数组」。汇总 2002000 4 × 500500。完成标记 4/4 用的是relaxed同样因为「总和/计数与顺序无关」才安全。这里有一处值得单独强调per_consumer这个std::vector被多个线程写为什么不需要同步因为每个线程写的是不同的元素per_consumer[c]互不重叠。而且它绝不是std::vectorbool那个特化会把元素打包成 bit不同元素共享同一个机器字就真的会有数据竞争了。这算是「容器 并发」的一个隐藏坑。官方文档memory_order_acq_rel — cppreference · std::atomic_thread_fence — cppreference不需要绑定原子量的独立屏障锁的实现里会用到8. 延伸阅读std::memory_order — cppreference —— 六种枚举值的正式定义、release/acquire的 happens-before 语义以及那张经典的「四种序组合的合法性」表std::atomic — cppreference —— 每个成员函数的memory_order参数默认值能看到「默认就是seq_cst」std::atomic_thread_fence — cppreference —— 独立屏障的语义理解seq_cst内部插的是什么std::atomic::compare_exchange — cppreference —— 它有两个内存序参数成功序和失败序失败序不能是release/acq_rel很容易踩C Core Guidelines · 并发章节 CP.8 / CP.43 —— 「不要用 volatile 做同步」「优先用锁而不是无锁编程」这篇的务实结论与之一致本知识库内的相关篇目《std::atomic 入门原子操作、CAS 与它和 volatile 的真正区别》 —— 多线程里 counter 为什么丢更新因为读、改、写三步之间可能被插入。《内存序实战release/acquire 三种经典模式与 seq_cst 的代价》 —— 为什么原子操作默认用 seq_cst因为它最不容易写错但也是最贵的。顺带把无锁与高级并发这条线也铺一下。《无锁队列MPSC/MPMCVyukov 环形队列与 slot 未就绪问题》 —— 队列比栈难因为它有两个端点——头尾都要协调。顺带把无锁与高级并发这条线也铺一下。9. 一句话总结原子性只管「一步不可分割」memory_order管的是「两步之间的顺序」relaxed只给原子性、不给同步适合纯计数器releaseacquire配对给出 happens-before生产者「先写数据、再 release 发布标志」→ 消费者「acquire 读到标志、再看数据必然可见」acq_rel给读-改-写操作用seq_cst是默认值、最强、额外提供全局单一顺序。它只是优化手段用弱了就是难复现的 UB —— 而且实测表明在 x86-64 上relaxed与seq_cst的 RMW 耗时几乎一样lock前缀本身就是全屏障所以正确的策略是「先用默认seq_cst保证正确确认是热点之后再逐条弱化并重新验证」。