博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站如果你稍微研究过 C 的std::atomic多半会在某个时刻经历这样一种心理活动既然默认的memory_order_seq_cst会在底层插入昂贵的内存屏障而我这个原子变量明明只是用来做个简单的计数那我能不能把它换成开销最小的memory_order_relaxed在很多性能优化的复盘里这种诱惑非常真实。在一个几十万行代码的后端项目里常常会有热心的工程师提这样一个 PR全局搜索代码里所有的fetch_add(1)然后统一给它们加上std::memory_order_relaxed标签。PR 的描述往往写得理直气壮“在本地压测过了整体吞吐量提升了 15%且功能没有任何异常建议合并”。如果在 Code Review 时你放过了这个 PR代码合并上线头几个月可能真的风平浪静。但功能没出问题往往只是幸存者偏差。一方面可能因为这个计数器真的只承担了单纯的统计职责误打误撞用对了另一方面更常见的原因是你们的线上服务器跑的是 x86 架构。x86 本身有着非常强壮的硬件内存模型TSOTotal Store Order硬件在底层拦住了大部分的指令乱序。代码逻辑其实已经破洞百出了但 x86 硬件像一个尽职的保姆偷偷帮你把问题掩盖了起来。直到有一天公司决定降本增效把一部分计算节点迁移到 ARM 架构的服务器上比如 AWS 的 Graviton 或者苹果的 M 系列芯片。ARM 是典型的弱内存模型硬件不再提供免费的顺序保证。这个时候那些被强行拆掉的同步屏障就会变成定时炸弹原来在 x86 上跑得好好的代码会在 ARM 上以极低的概率出现各种匪夷所思的段错误、数据撕裂和死锁。把所有的 atomic 操作无脑降级为relaxed就像是把所有路口的红绿灯都拆了——如果本来就是条平时没人走的断头路那确实没问题如果是车水马龙的十字路口不出车祸只是因为大家碰巧没撞上而不是因为你的交通设计有多精妙。这一章我们就把memory_order_relaxed掰开揉碎看看它到底保留了什么放宽了什么在工程里到底应该怎么判断一个场景能不能用relaxed。relaxed 这个名字容易误导看到relaxed松散的、放宽的很多人的第一反应是这是不是意味着它连原子性都不保证了会不会退化成普通变量的读写答案是绝对不会。memory_order_relaxed仍然是标准的原子操作。#includeatomicstd::atomicintrequest_count{0};voidOnRequest(){request_count.fetch_add(1,std::memory_order_relaxed);}即使加上了relaxed标签fetch_add依然是一个不可分割的读-改-写操作。如果有 100 个线程同时调用OnRequest()每个线程调 10000 次最后request_count的值一定是一分不差的 1000000。它绝不会出现像普通int那样因为并发自增而丢失更新的情况。不仅是读-改-写load和store也一样。一个relaxed的store仍然是一个完整的写入不会出现高位写了一半、低位写了另一半的撕裂状态。既然原子性没有打折扣那它到底“放宽”了什么它放宽的是跨线程的顺序约束。它明确声明这次原子操作不和本线程内的其他内存访问包括普通变量和其他原子变量发生绑定。编译器和 CPU 不需要为了这次原子操作去拦截前后的指令不需要插入那些用来保证跨线程可见性的内存屏障。打个比方seq_cst就像是快递员不仅要把这件包裹送到原子性还要保证签收之前发出的所有通知单都已经贴在门上了跨线程顺序。而relaxed则是快递员只管把这件包裹扔进快递柜原子性至于你之前发出的通知单别人看不看得到他一概不管。保留底线单对象的修改顺序虽然relaxed放弃了跨变量的顺序保证但它仍然坚守着最后一道底线每个原子变量自己的修改顺序Modification Order。你可以这样理解在 C 的内存模型里每一个原子对象都自带一本私有的“修改日记”。所有线程对这个原子对象的每一次修改都必须严丝合缝地记在这本日记上形成一条单向的时间线。假设有几个线程不断地对一个原子变量counter进行累加它的修改日记可能是这样的0 - 1 - 2 - 3 - 4 - 5relaxed保证了两件事第一这本日记的顺序是唯一的。虽然不同的线程可能因为缓存延迟的关系看到日记的进度不一样比如线程 A 已经看到了 4而线程 B 还在看 2但他们看到的顺序一定是顺着日记方向的。第二同一个线程里的观察绝对不能倒退。如果线程 A 的一次relaxed load读到了 3那么它下一次再对同一个变量做load时读到的值要么还是 3要么是比 3 更靠后的 4 或 5绝对不可能倒退回 1 或 2。这就好比一个人看日记你可以翻得慢但绝对不能往回翻。为什么relaxed放弃了那么多却依然能死守这条底线这要归功于 CPU 硬件底层的缓存一致性协议比如最经典的 MESI 协议。在 MESI 协议里物理内存的最小同步单位是缓存行Cache Line。当一个 CPU 核心想要修改某个原子变量时它必须先在总线上广播要求其他所有核心把自己本地的那个缓存行标记为“失效Invalid”然后当前核心才能把状态切换为“独占/已修改Modified”并写入新值。这个硬件机制意味着什么意味着对于同一个内存地址在任何一个极微小的物理切面上全宇宙只有一个 CPU 核心拥有写入权。即使没有任何上层的软件内存屏障介入MESI 协议也会保证这一个地址上的数据更新是物理上排队一个接一个发生的。这层硬件底座就是单变量修改顺序Modification Order绝对不会发生历史倒退的物理根基。这个单变量的修改顺序保证是relaxed能够用来做计数器和 ID 分配的根基。如果连这个都保证不了并发逻辑就彻底崩溃了。典型正确场景一旁路计数器既然relaxed保证了自身的原子性又抛弃了昂贵的同步包袱那它最契合的场景就是那些“只关心自己这个值不牵扯别人”的旁路指标统计。比如 Web 服务器里的请求计数、缓存的命中率统计、或者网络库里的丢包记录#includeatomicstructServerMetrics{std::atomiclonglongtotal_requests{0};std::atomiclonglongcache_hits{0};};ServerMetrics g_metrics;voidHandleRequest(boolhit_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);}// 处理具体的业务逻辑...}这段代码里的relaxed用得非常完美。为什么因为total_requests和cache_hits只是旁路数据。别的线程比如监控线程可能隔一秒钟过来load一次这些值把它打印到日志或者发给时序数据库。监控线程根本不在乎“当我看到请求数变成 10000 的时候某个业务变量是不是已经更新了”。计数器的值并不作为某个逻辑分支的控制条件也没有另一个线程在等这个计数器到达某个值然后去读一块内存。它不承担任何“通知”或者“数据发布”的职责它只是一个孤立的数值。在这种场景下使用默认的seq_cst纯粹是浪费性能。把它们降级为relaxed既保证了统计的绝对准确不会丢更新又给了编译器和 CPU 最大的优化自由。典型正确场景二全局单调 ID 分配另一个极其契合relaxed的工程场景是分配全局唯一的单调递增 ID。#includeatomic#includecstdintstd::atomicstd::uint64_tnext_id{1};std::uint64_tAllocateRequestId(){returnnext_id.fetch_add(1,std::memory_order_relaxed);}在微服务网关或者分布式追踪系统Tracing里我们经常需要给每个进来的请求打上一个唯一的数字 ID。这种场景的核心诉求只有一个每次调用AllocateRequestId()返回的值绝对不能重复。fetch_add是原子的读-改-写操作在硬件层面排了队这已经保证了唯一性。至于这个 ID 分配出去之后它和系统里的其他变量到底是个什么时序关系分配器根本不关心。业务线程拿到这个 ID 去写日志、去构造请求对象那是业务线程自己内部的逻辑。这种时候用relaxed就是在明确地向阅读代码的人传达一个设计意图“嘿我只是要个唯一的序号别在我身上找什么复杂的并发同步逻辑。”典型正确场景三自旋锁的 Test-and-Test-and-Set 优化前面的两个场景都是直接对数值做操作接下来我们看一个进阶一点的场景自旋锁底层的性能优化。在第 04 章讲std::atomic_flag时我们写过一个最简单的自旋锁#includeatomicstd::atomic_flag lock_flagATOMIC_FLAG_INIT;voidLock(){while(lock_flag.test_and_set(std::memory_order_acquire)){// 自旋等待}}这段代码逻辑是对的但在高并发下性能会非常差。原因在于底层的缓存一致性协议test_and_set是一个原子的读改写操作它要求当前核心必须拿到缓存行的独占权Modified 状态才能执行。如果有 10 个线程在同时抢这把锁1 个拿到了剩下 9 个在执行while循环。这 9 个线程会疯狂地发起test_and_set导致包含锁的那个缓存行在 9 个 CPU 核心之间疯狂地抢来抢去这就是著名的“缓存乒乓效应”Cache Ping-Pong。总线带宽全被这种无效的抢夺占满了真正拿到锁的线程反而因为总线拥堵而执行得非常慢。为了解决这个问题工业界标准库通常会采用 Test-and-Test-and-Set 优化。在这个优化里memory_order_relaxed扮演了极其关键的角色voidOptimizedLock(){for(;;){// 1. 尝试抢锁需要独占权可能触发缓存失效if(!lock_flag.test_and_set(std::memory_order_acquire)){return;}// 2. 抢锁失败退回只读模式进行轮询Shared 状态while(lock_flag.test(std::memory_order_relaxed)){// 可以插入一条 CPU pause 指令}}}看懂这里面的玄机了吗当线程第一次抢锁失败后它进入了内层的while循环。在这个循环里它只使用普通的load在std::atomic_flag里叫做test去探测锁的状态并且指定了memory_order_relaxed。因为这只是一次load它不需要独占缓存行所有抢不到锁的核心都可以把这个缓存行保持在 Shared 状态大家一起安静地只读。只有当拿着锁的线程释放锁写为false时才会打破这个 Shared 状态。这个时候内层循环里的relaxed load会探测到变化跳出内层循环再去发起昂贵的test_and_set争抢。这里为什么用relaxed是安全的因为内层的只读循环根本不是为了建立跨线程同步它只是为了等待一个抢锁的时机。当它读到false的那一瞬间它并没有获得锁也不能去操作被锁保护的数据。它只是得到了一个信号“锁好像空出来了你可以去抢了”。真正建立跨线程可见性的是跳出内层循环后外层的那个带有acquire语义的test_and_set。这就好比是在抢购门票你不需要一直猛敲购买按钮test_and_set你只需要看着剩余票数relaxed load等看到票数变成 1 了你再发力按购买按钮。看票数只是个动作按按钮成功才是真正的契约建立。在这个场景下relaxed完美地胜任了“廉价探测器”的角色大幅降低了硬件总线的压力。危险边界用 relaxed 做状态发布看完了正确的场景接下来看一个在业务代码里特别容易踩坑的危险操作。这也是为什么大多数指南建议新手远离relaxed的原因它太容易把同步协议拆散了。很多人在明白了relaxed的轻量级之后会忍不住把它用到状态标志位上。比如这种典型的“准备数据 - 打标记”模型#includeatomic#includestringstd::string config_data;std::atomicboolconfig_ready{false};voidProducer(){// 1. 准备普通数据config_datatimeout500;retry3;// 2. 危险用 relaxed 发布状态config_ready.store(true,std::memory_order_relaxed);}voidConsumer(){// 1. 危险用 relaxed 轮询状态while(!config_ready.load(std::memory_order_relaxed)){// 自旋等待}// 2. 读取普通数据ApplyConfig(config_data);}表面上看config_ready这个标记位本身不会有什么数据竞争Consumer似乎也能等到ready变成true。但在 C 内存模型的视角下这段代码已经是一场灾难了。问题出在哪里出在relaxed放弃了跨线程的同步。在Producer线程里写入config_data和写入config_ready是两件独立的事情。由于你告诉编译器和 CPU 对config_ready的写入是relaxed的也就意味着你赋予了它们将这两者重排的权力。CPU 完全可以先把config_ready true刷进主存然后再去慢吞吞地刷config_data的内容。在Consumer线程里情况同样糟糕。即使它看到了config_ready true因为load也是relaxed的它没有建立任何 acquire 语义的内存屏障。CPU 可能会在检查config_ready之前就已经把config_data的旧内存预读到了缓存里。结果就是Consumer跳出了while循环兴冲冲地去读config_data结果读到的是一个空字符串或者是一堆还没初始化完的乱码数据。这就好比快递员把包裹随便塞在门外然后给你发了条短信。由于没有严格的投递协议同步关系你看到短信打开门的时候包裹可能还没运到你家门口或者可能被风吹走了。这种涉及到“普通数据发布”的场景必须老老实实使用release / acquire配对绝不能用relaxed糊弄。跨变量推理的陷阱多原子变量没有统一故事除了不能发布普通数据relaxed还有一个非常反直觉的特性如果你试图用逻辑去推理多个不同的relaxed变量之间的先后关系你往往会得到荒谬的结论。让我们回到第 06 章那个用来验证seq_cst威力的双变量例子如果把它全换成relaxed会发生什么#includeatomicstd::atomicboolx{false};std::atomicbooly{false};intr10;intr20;voidThread1(){x.store(true,std::memory_order_relaxed);// 操作 Ar1y.load(std::memory_order_relaxed);// 操作 B}voidThread2(){y.store(true,std::memory_order_relaxed);// 操作 Cr2x.load(std::memory_order_relaxed);// 操作 D}在默认的seq_cst下程序保证所有操作有一个全局顺序所以r1和r2绝对不可能同时为false。但一旦加上relaxed标签一切都变了。r1 false r2 false成为了完全合法的输出在常理看来这简直是悖论Thread1里操作 A 先于操作 B且r1读到了false说明在Thread1看来操作 B 发生在操作 C写 y之前。所以直觉上A 一定远远早于 C。Thread2里操作 C 先于操作 D且r2读到了false说明在Thread2看来操作 D 发生在操作 A写 x之前。所以直觉上C 又一定远远早于 A。A 早于 CC 又早于 A打破这个悖论的关键在于理解relaxed的核心属性它不强求所有线程对多个变量的时序达成共识。x有自己的修改顺序y也有自己的修改顺序。relaxed允许Thread1眼中的时间线和Thread2眼中的时间线是完全撕裂的。我们可以把 CPU 的底层结构剖开来看看到底是什么引发了这种撕裂。假设Thread1跑在 CPU 核心 1 上Thread2跑在 CPU 核心 2 上。核心 1 执行x.store(true)时为了追求极速它并不会干等这个值被刷入 L3 缓存而是把true扔进自己私有的 Store Buffer写缓冲区然后立刻回头去执行下一条指令也就是y.load()。此时核心 1 去共享缓存里读y读到了初始值false。几乎在同一瞬间核心 2 也在干一模一样的事它把y.store(true)扔进自己私有的 Store Buffer然后立刻回头去读x。因为核心 1 的 Store Buffer 还没来得及把数据排空到共享缓存里所以核心 2 读到的x依然是初始值false。在这个极短的时间缝隙里两个核心都在各自的小天地里完成了“写入”但又都没向全系统广播。在Thread1的视角里自己的写入已经发生了而对方还没动静在Thread2的视角里也是自己的写入已经发生了对方还没动静。relaxed的核心语义就是默许并放纵了这种由于硬件物理隔离造成的认知撕裂。这个例子在工程里有什么实际意义呢它在警告我们如果一段业务逻辑的正确性依赖于对多个原子变量的状态进行组合判断那么绝对不能使用 relaxed。来看一个平时很容易写出来的状态机快照代码#includeatomic#includeiostreamstd::atomicintactive_workers{0};std::atomicintqueued_tasks{0};boolLooksIdle(){intactiveactive_workers.load(std::memory_order_relaxed);intqueuedqueued_tasks.load(std::memory_order_relaxed);returnactive0queued0;}如果你调用LooksIdle()只是为了在监控大屏上打个日志那用relaxed无可厚非因为一两次状态快照的不一致对宏观监控影响不大。但如果你是用这个函数来决定是否“直接关闭线程池”或者“释放核心内存”那就大错特错了。因为relaxed不提供跨变量的时序绑定你可能读到了刚被某个线程减为 0 的active_workers却没读到那个线程同时压入队列的新queued_tasks从而错误地认为系统已经彻底闲置。从 C 代码到汇编指令真实的解剖刚才在理论层面讲了这么多relaxed的影响现在我们不妨接地气一点看看 C 编译器到底是怎么把relaxed翻译成底层机器码的。毕竟所有的内存模型最终都要落地到硅片上执行的指令。我们来看一个最简单的例子读取一个原子变量。#includeatomicstd::atomicintflag{0};intReadRelaxed(){returnflag.load(std::memory_order_relaxed);}intReadSeqCst(){returnflag.load(std::memory_order_seq_cst);}如果你在 x86-64 架构下使用 GCC 或者 Clang 编译这两段代码开启-O2优化你会看到一个令人震惊的现象ReadRelaxed(): mov eax, DWORD PTR flag[rip] ret ReadSeqCst(): mov eax, DWORD PTR flag[rip] ret它们的汇编指令一模一样都是一条最普通的mov指令。在这个瞬间你是不是觉得我们前面讲的那么多“屏障”、“代价”都是骗人的其实不是骗人的而是 x86-64 架构在搞特殊。x86 是强内存模型TSO在它的底层设计中所有的普通 Load 操作本身就自带了 Acquire 语义。也就是说硬件帮你免费承担了很大一部分同步的开销。所以编译器在 x86 上不需要为了seq_cst的 Load 插入额外的内存屏障指令。但如果我们换一个阵地来到典型的弱内存模型 ARM64aarch64架构下编译出来的结果就完全不一样了ReadRelaxed(): ldr w0, [x1] ret ReadSeqCst(): ldar w0, [x1] ret注意看指令的区别relaxed使用的是普通的ldr(Load Register) 指令而seq_cst使用的是专门的ldar(Load-Acquire Register) 指令。在 ARM 架构中ldar是一条带有明确内存屏障语义的特殊指令它的执行周期和对 CPU 流水线的阻塞代价比普通的ldr要大得多。在store写入操作和fetch_add等读改写操作上两种架构的差异会更加巨大。比如在 ARM 上一个seq_cst的写入会被翻译成stlr(Store-Release Register) 甚至是全功能的dmb ish(Data Memory Barrier) 指令而relaxed则只是一条普通的str。这就是为什么在跨平台开发时滥用relaxed或者错用relaxed是最危险的行为。你在开发用的 Macbook以前的 Intel 款上跑得欢天喜地一发布到线上的 ARM 服务器立刻就出现偶发性崩溃而且这种崩溃在 x86 开发机上是绝对复现不出来的。为什么 TSan 抓不到你的 relaxed bug面对并发问题现代 C 工程师的第一反应往往是“不怕我挂上 ThreadSanitizer (TSan) 跑一遍测试有什么问题工具会报出来的。”TSan 确实是抓数据竞争Data Race的神器。如果你把两个普通变量放在不同线程里并发读写TSan 绝对一抓一个准。但是当你引入了memory_order_relaxed之后TSan 往往就变成了瞎子。为什么因为 TSan 的检测逻辑是基于 C内存模型规范的。在 C 标准中只要你对某个变量使用了std::atomic包装无论你用的是什么内存序哪怕是最弱的relaxed对它的并发读写在标准定义上都不属于数据竞争Data Race。TSan 是通过追踪代码中的 happens-before 关系来判断是否有违规访问的。当它看到你使用了relaxed它就会在内部的模型里记录下“好程序员说这里不需要建立同步边”。然后如果你的业务逻辑因为这种缺失的同步边而读到了一个错误的值这叫做“业务逻辑错误”Logic Bug而不叫“数据竞争”。TSan 只负责查违停不负责查你走错路。这就给工程带来了极大的挑战一旦你把标志位或者状态控制变量改成了relaxed你不仅破坏了同步协议还主动把 TSan 这个强大的保安给辞退了。从此以后这段代码的正确性只能完全依赖于你肉眼的 Code Review以及那些漫长而不可靠的拷机测试。面对这种局面有经验的架构师在做代码审查时通常会有一条铁律“证明它是无罪的否则就有罪”。对于任何非计数器、非 ID 类型的relaxed提交PR 作者必须在注释里写出长篇的推导过程证明为什么这里缺少跨线程同步也不会引发业务异常。如果证明不了一律退回改用release/acquire。性能优化的节制与边界既然relaxed这么危险那到底能带来多大的性能收益这其实是一个跟底层硬件架构强相关的问题。在强内存模型Strong Memory Model的架构上比如我们最常用的 x86-64 平台硬件本身就禁止了大部分的指令乱序比如不会出现 Load-Load 重排和 Store-Store 重排。因此在 x86 上一个普通的relaxed load和seq_cst load在生成的汇编指令上往往是一模一样的都是普通的mov指令。性能差异主要体现在store和读-改-写操作上因为seq_cst需要硬件提供像lock前缀或mfence这样的重型同步指令。而在弱内存模型Weak Memory Model的架构上比如 ARM 或者 PowerPC硬件给予了乱序执行极大的自由。这时候relaxed和其他内存序的性能差距就会非常明显因为不写relaxed就意味着要向硬件塞入大量昂贵的屏障指令。所以在做性能评估时千万不能靠脑补也不能拿着单机 x86 跑出来的 Benchmark 就断言“改成 relaxed 性能起飞了”。在工程实践中判断是否使用relaxed我们可以遵循以下这套漏斗原则先问职责这个原子变量只是一个纯粹的旁路数据如统计、采样、ID 生成吗如果是大胆用relaxed。再问依赖这个原子变量的读写是否决定了其他非原子变量的可见性比如经典的 data ready 标志位。如果是坚决不用relaxed老老实实用release/acquire。最后问组合这个变量是否需要和其他原子变量组合起来作为业务状态机推进的判断条件如果是不要用relaxed甚至不要自己手搓考虑用std::mutex把组合状态锁起来。如果经过这层层拷问你发现手头的变量必须承担“一方向内存写好数据另一方负责将状态广播给消费者”的职责这时候光有atomicity已经不够了你必须在两者之间拉起一条牢不可破的同步链。这就是我们下一章要深入拆解的主角——如何用memory_order_release和memory_order_acquire配合完成一次滴水不漏的数据安全发布。码字不易欢迎大家点赞关注评论谢谢
【C++并发系列】第七章:memory_order_relaxed 能用在哪里
博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站如果你稍微研究过 C 的std::atomic多半会在某个时刻经历这样一种心理活动既然默认的memory_order_seq_cst会在底层插入昂贵的内存屏障而我这个原子变量明明只是用来做个简单的计数那我能不能把它换成开销最小的memory_order_relaxed在很多性能优化的复盘里这种诱惑非常真实。在一个几十万行代码的后端项目里常常会有热心的工程师提这样一个 PR全局搜索代码里所有的fetch_add(1)然后统一给它们加上std::memory_order_relaxed标签。PR 的描述往往写得理直气壮“在本地压测过了整体吞吐量提升了 15%且功能没有任何异常建议合并”。如果在 Code Review 时你放过了这个 PR代码合并上线头几个月可能真的风平浪静。但功能没出问题往往只是幸存者偏差。一方面可能因为这个计数器真的只承担了单纯的统计职责误打误撞用对了另一方面更常见的原因是你们的线上服务器跑的是 x86 架构。x86 本身有着非常强壮的硬件内存模型TSOTotal Store Order硬件在底层拦住了大部分的指令乱序。代码逻辑其实已经破洞百出了但 x86 硬件像一个尽职的保姆偷偷帮你把问题掩盖了起来。直到有一天公司决定降本增效把一部分计算节点迁移到 ARM 架构的服务器上比如 AWS 的 Graviton 或者苹果的 M 系列芯片。ARM 是典型的弱内存模型硬件不再提供免费的顺序保证。这个时候那些被强行拆掉的同步屏障就会变成定时炸弹原来在 x86 上跑得好好的代码会在 ARM 上以极低的概率出现各种匪夷所思的段错误、数据撕裂和死锁。把所有的 atomic 操作无脑降级为relaxed就像是把所有路口的红绿灯都拆了——如果本来就是条平时没人走的断头路那确实没问题如果是车水马龙的十字路口不出车祸只是因为大家碰巧没撞上而不是因为你的交通设计有多精妙。这一章我们就把memory_order_relaxed掰开揉碎看看它到底保留了什么放宽了什么在工程里到底应该怎么判断一个场景能不能用relaxed。relaxed 这个名字容易误导看到relaxed松散的、放宽的很多人的第一反应是这是不是意味着它连原子性都不保证了会不会退化成普通变量的读写答案是绝对不会。memory_order_relaxed仍然是标准的原子操作。#includeatomicstd::atomicintrequest_count{0};voidOnRequest(){request_count.fetch_add(1,std::memory_order_relaxed);}即使加上了relaxed标签fetch_add依然是一个不可分割的读-改-写操作。如果有 100 个线程同时调用OnRequest()每个线程调 10000 次最后request_count的值一定是一分不差的 1000000。它绝不会出现像普通int那样因为并发自增而丢失更新的情况。不仅是读-改-写load和store也一样。一个relaxed的store仍然是一个完整的写入不会出现高位写了一半、低位写了另一半的撕裂状态。既然原子性没有打折扣那它到底“放宽”了什么它放宽的是跨线程的顺序约束。它明确声明这次原子操作不和本线程内的其他内存访问包括普通变量和其他原子变量发生绑定。编译器和 CPU 不需要为了这次原子操作去拦截前后的指令不需要插入那些用来保证跨线程可见性的内存屏障。打个比方seq_cst就像是快递员不仅要把这件包裹送到原子性还要保证签收之前发出的所有通知单都已经贴在门上了跨线程顺序。而relaxed则是快递员只管把这件包裹扔进快递柜原子性至于你之前发出的通知单别人看不看得到他一概不管。保留底线单对象的修改顺序虽然relaxed放弃了跨变量的顺序保证但它仍然坚守着最后一道底线每个原子变量自己的修改顺序Modification Order。你可以这样理解在 C 的内存模型里每一个原子对象都自带一本私有的“修改日记”。所有线程对这个原子对象的每一次修改都必须严丝合缝地记在这本日记上形成一条单向的时间线。假设有几个线程不断地对一个原子变量counter进行累加它的修改日记可能是这样的0 - 1 - 2 - 3 - 4 - 5relaxed保证了两件事第一这本日记的顺序是唯一的。虽然不同的线程可能因为缓存延迟的关系看到日记的进度不一样比如线程 A 已经看到了 4而线程 B 还在看 2但他们看到的顺序一定是顺着日记方向的。第二同一个线程里的观察绝对不能倒退。如果线程 A 的一次relaxed load读到了 3那么它下一次再对同一个变量做load时读到的值要么还是 3要么是比 3 更靠后的 4 或 5绝对不可能倒退回 1 或 2。这就好比一个人看日记你可以翻得慢但绝对不能往回翻。为什么relaxed放弃了那么多却依然能死守这条底线这要归功于 CPU 硬件底层的缓存一致性协议比如最经典的 MESI 协议。在 MESI 协议里物理内存的最小同步单位是缓存行Cache Line。当一个 CPU 核心想要修改某个原子变量时它必须先在总线上广播要求其他所有核心把自己本地的那个缓存行标记为“失效Invalid”然后当前核心才能把状态切换为“独占/已修改Modified”并写入新值。这个硬件机制意味着什么意味着对于同一个内存地址在任何一个极微小的物理切面上全宇宙只有一个 CPU 核心拥有写入权。即使没有任何上层的软件内存屏障介入MESI 协议也会保证这一个地址上的数据更新是物理上排队一个接一个发生的。这层硬件底座就是单变量修改顺序Modification Order绝对不会发生历史倒退的物理根基。这个单变量的修改顺序保证是relaxed能够用来做计数器和 ID 分配的根基。如果连这个都保证不了并发逻辑就彻底崩溃了。典型正确场景一旁路计数器既然relaxed保证了自身的原子性又抛弃了昂贵的同步包袱那它最契合的场景就是那些“只关心自己这个值不牵扯别人”的旁路指标统计。比如 Web 服务器里的请求计数、缓存的命中率统计、或者网络库里的丢包记录#includeatomicstructServerMetrics{std::atomiclonglongtotal_requests{0};std::atomiclonglongcache_hits{0};};ServerMetrics g_metrics;voidHandleRequest(boolhit_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);}// 处理具体的业务逻辑...}这段代码里的relaxed用得非常完美。为什么因为total_requests和cache_hits只是旁路数据。别的线程比如监控线程可能隔一秒钟过来load一次这些值把它打印到日志或者发给时序数据库。监控线程根本不在乎“当我看到请求数变成 10000 的时候某个业务变量是不是已经更新了”。计数器的值并不作为某个逻辑分支的控制条件也没有另一个线程在等这个计数器到达某个值然后去读一块内存。它不承担任何“通知”或者“数据发布”的职责它只是一个孤立的数值。在这种场景下使用默认的seq_cst纯粹是浪费性能。把它们降级为relaxed既保证了统计的绝对准确不会丢更新又给了编译器和 CPU 最大的优化自由。典型正确场景二全局单调 ID 分配另一个极其契合relaxed的工程场景是分配全局唯一的单调递增 ID。#includeatomic#includecstdintstd::atomicstd::uint64_tnext_id{1};std::uint64_tAllocateRequestId(){returnnext_id.fetch_add(1,std::memory_order_relaxed);}在微服务网关或者分布式追踪系统Tracing里我们经常需要给每个进来的请求打上一个唯一的数字 ID。这种场景的核心诉求只有一个每次调用AllocateRequestId()返回的值绝对不能重复。fetch_add是原子的读-改-写操作在硬件层面排了队这已经保证了唯一性。至于这个 ID 分配出去之后它和系统里的其他变量到底是个什么时序关系分配器根本不关心。业务线程拿到这个 ID 去写日志、去构造请求对象那是业务线程自己内部的逻辑。这种时候用relaxed就是在明确地向阅读代码的人传达一个设计意图“嘿我只是要个唯一的序号别在我身上找什么复杂的并发同步逻辑。”典型正确场景三自旋锁的 Test-and-Test-and-Set 优化前面的两个场景都是直接对数值做操作接下来我们看一个进阶一点的场景自旋锁底层的性能优化。在第 04 章讲std::atomic_flag时我们写过一个最简单的自旋锁#includeatomicstd::atomic_flag lock_flagATOMIC_FLAG_INIT;voidLock(){while(lock_flag.test_and_set(std::memory_order_acquire)){// 自旋等待}}这段代码逻辑是对的但在高并发下性能会非常差。原因在于底层的缓存一致性协议test_and_set是一个原子的读改写操作它要求当前核心必须拿到缓存行的独占权Modified 状态才能执行。如果有 10 个线程在同时抢这把锁1 个拿到了剩下 9 个在执行while循环。这 9 个线程会疯狂地发起test_and_set导致包含锁的那个缓存行在 9 个 CPU 核心之间疯狂地抢来抢去这就是著名的“缓存乒乓效应”Cache Ping-Pong。总线带宽全被这种无效的抢夺占满了真正拿到锁的线程反而因为总线拥堵而执行得非常慢。为了解决这个问题工业界标准库通常会采用 Test-and-Test-and-Set 优化。在这个优化里memory_order_relaxed扮演了极其关键的角色voidOptimizedLock(){for(;;){// 1. 尝试抢锁需要独占权可能触发缓存失效if(!lock_flag.test_and_set(std::memory_order_acquire)){return;}// 2. 抢锁失败退回只读模式进行轮询Shared 状态while(lock_flag.test(std::memory_order_relaxed)){// 可以插入一条 CPU pause 指令}}}看懂这里面的玄机了吗当线程第一次抢锁失败后它进入了内层的while循环。在这个循环里它只使用普通的load在std::atomic_flag里叫做test去探测锁的状态并且指定了memory_order_relaxed。因为这只是一次load它不需要独占缓存行所有抢不到锁的核心都可以把这个缓存行保持在 Shared 状态大家一起安静地只读。只有当拿着锁的线程释放锁写为false时才会打破这个 Shared 状态。这个时候内层循环里的relaxed load会探测到变化跳出内层循环再去发起昂贵的test_and_set争抢。这里为什么用relaxed是安全的因为内层的只读循环根本不是为了建立跨线程同步它只是为了等待一个抢锁的时机。当它读到false的那一瞬间它并没有获得锁也不能去操作被锁保护的数据。它只是得到了一个信号“锁好像空出来了你可以去抢了”。真正建立跨线程可见性的是跳出内层循环后外层的那个带有acquire语义的test_and_set。这就好比是在抢购门票你不需要一直猛敲购买按钮test_and_set你只需要看着剩余票数relaxed load等看到票数变成 1 了你再发力按购买按钮。看票数只是个动作按按钮成功才是真正的契约建立。在这个场景下relaxed完美地胜任了“廉价探测器”的角色大幅降低了硬件总线的压力。危险边界用 relaxed 做状态发布看完了正确的场景接下来看一个在业务代码里特别容易踩坑的危险操作。这也是为什么大多数指南建议新手远离relaxed的原因它太容易把同步协议拆散了。很多人在明白了relaxed的轻量级之后会忍不住把它用到状态标志位上。比如这种典型的“准备数据 - 打标记”模型#includeatomic#includestringstd::string config_data;std::atomicboolconfig_ready{false};voidProducer(){// 1. 准备普通数据config_datatimeout500;retry3;// 2. 危险用 relaxed 发布状态config_ready.store(true,std::memory_order_relaxed);}voidConsumer(){// 1. 危险用 relaxed 轮询状态while(!config_ready.load(std::memory_order_relaxed)){// 自旋等待}// 2. 读取普通数据ApplyConfig(config_data);}表面上看config_ready这个标记位本身不会有什么数据竞争Consumer似乎也能等到ready变成true。但在 C 内存模型的视角下这段代码已经是一场灾难了。问题出在哪里出在relaxed放弃了跨线程的同步。在Producer线程里写入config_data和写入config_ready是两件独立的事情。由于你告诉编译器和 CPU 对config_ready的写入是relaxed的也就意味着你赋予了它们将这两者重排的权力。CPU 完全可以先把config_ready true刷进主存然后再去慢吞吞地刷config_data的内容。在Consumer线程里情况同样糟糕。即使它看到了config_ready true因为load也是relaxed的它没有建立任何 acquire 语义的内存屏障。CPU 可能会在检查config_ready之前就已经把config_data的旧内存预读到了缓存里。结果就是Consumer跳出了while循环兴冲冲地去读config_data结果读到的是一个空字符串或者是一堆还没初始化完的乱码数据。这就好比快递员把包裹随便塞在门外然后给你发了条短信。由于没有严格的投递协议同步关系你看到短信打开门的时候包裹可能还没运到你家门口或者可能被风吹走了。这种涉及到“普通数据发布”的场景必须老老实实使用release / acquire配对绝不能用relaxed糊弄。跨变量推理的陷阱多原子变量没有统一故事除了不能发布普通数据relaxed还有一个非常反直觉的特性如果你试图用逻辑去推理多个不同的relaxed变量之间的先后关系你往往会得到荒谬的结论。让我们回到第 06 章那个用来验证seq_cst威力的双变量例子如果把它全换成relaxed会发生什么#includeatomicstd::atomicboolx{false};std::atomicbooly{false};intr10;intr20;voidThread1(){x.store(true,std::memory_order_relaxed);// 操作 Ar1y.load(std::memory_order_relaxed);// 操作 B}voidThread2(){y.store(true,std::memory_order_relaxed);// 操作 Cr2x.load(std::memory_order_relaxed);// 操作 D}在默认的seq_cst下程序保证所有操作有一个全局顺序所以r1和r2绝对不可能同时为false。但一旦加上relaxed标签一切都变了。r1 false r2 false成为了完全合法的输出在常理看来这简直是悖论Thread1里操作 A 先于操作 B且r1读到了false说明在Thread1看来操作 B 发生在操作 C写 y之前。所以直觉上A 一定远远早于 C。Thread2里操作 C 先于操作 D且r2读到了false说明在Thread2看来操作 D 发生在操作 A写 x之前。所以直觉上C 又一定远远早于 A。A 早于 CC 又早于 A打破这个悖论的关键在于理解relaxed的核心属性它不强求所有线程对多个变量的时序达成共识。x有自己的修改顺序y也有自己的修改顺序。relaxed允许Thread1眼中的时间线和Thread2眼中的时间线是完全撕裂的。我们可以把 CPU 的底层结构剖开来看看到底是什么引发了这种撕裂。假设Thread1跑在 CPU 核心 1 上Thread2跑在 CPU 核心 2 上。核心 1 执行x.store(true)时为了追求极速它并不会干等这个值被刷入 L3 缓存而是把true扔进自己私有的 Store Buffer写缓冲区然后立刻回头去执行下一条指令也就是y.load()。此时核心 1 去共享缓存里读y读到了初始值false。几乎在同一瞬间核心 2 也在干一模一样的事它把y.store(true)扔进自己私有的 Store Buffer然后立刻回头去读x。因为核心 1 的 Store Buffer 还没来得及把数据排空到共享缓存里所以核心 2 读到的x依然是初始值false。在这个极短的时间缝隙里两个核心都在各自的小天地里完成了“写入”但又都没向全系统广播。在Thread1的视角里自己的写入已经发生了而对方还没动静在Thread2的视角里也是自己的写入已经发生了对方还没动静。relaxed的核心语义就是默许并放纵了这种由于硬件物理隔离造成的认知撕裂。这个例子在工程里有什么实际意义呢它在警告我们如果一段业务逻辑的正确性依赖于对多个原子变量的状态进行组合判断那么绝对不能使用 relaxed。来看一个平时很容易写出来的状态机快照代码#includeatomic#includeiostreamstd::atomicintactive_workers{0};std::atomicintqueued_tasks{0};boolLooksIdle(){intactiveactive_workers.load(std::memory_order_relaxed);intqueuedqueued_tasks.load(std::memory_order_relaxed);returnactive0queued0;}如果你调用LooksIdle()只是为了在监控大屏上打个日志那用relaxed无可厚非因为一两次状态快照的不一致对宏观监控影响不大。但如果你是用这个函数来决定是否“直接关闭线程池”或者“释放核心内存”那就大错特错了。因为relaxed不提供跨变量的时序绑定你可能读到了刚被某个线程减为 0 的active_workers却没读到那个线程同时压入队列的新queued_tasks从而错误地认为系统已经彻底闲置。从 C 代码到汇编指令真实的解剖刚才在理论层面讲了这么多relaxed的影响现在我们不妨接地气一点看看 C 编译器到底是怎么把relaxed翻译成底层机器码的。毕竟所有的内存模型最终都要落地到硅片上执行的指令。我们来看一个最简单的例子读取一个原子变量。#includeatomicstd::atomicintflag{0};intReadRelaxed(){returnflag.load(std::memory_order_relaxed);}intReadSeqCst(){returnflag.load(std::memory_order_seq_cst);}如果你在 x86-64 架构下使用 GCC 或者 Clang 编译这两段代码开启-O2优化你会看到一个令人震惊的现象ReadRelaxed(): mov eax, DWORD PTR flag[rip] ret ReadSeqCst(): mov eax, DWORD PTR flag[rip] ret它们的汇编指令一模一样都是一条最普通的mov指令。在这个瞬间你是不是觉得我们前面讲的那么多“屏障”、“代价”都是骗人的其实不是骗人的而是 x86-64 架构在搞特殊。x86 是强内存模型TSO在它的底层设计中所有的普通 Load 操作本身就自带了 Acquire 语义。也就是说硬件帮你免费承担了很大一部分同步的开销。所以编译器在 x86 上不需要为了seq_cst的 Load 插入额外的内存屏障指令。但如果我们换一个阵地来到典型的弱内存模型 ARM64aarch64架构下编译出来的结果就完全不一样了ReadRelaxed(): ldr w0, [x1] ret ReadSeqCst(): ldar w0, [x1] ret注意看指令的区别relaxed使用的是普通的ldr(Load Register) 指令而seq_cst使用的是专门的ldar(Load-Acquire Register) 指令。在 ARM 架构中ldar是一条带有明确内存屏障语义的特殊指令它的执行周期和对 CPU 流水线的阻塞代价比普通的ldr要大得多。在store写入操作和fetch_add等读改写操作上两种架构的差异会更加巨大。比如在 ARM 上一个seq_cst的写入会被翻译成stlr(Store-Release Register) 甚至是全功能的dmb ish(Data Memory Barrier) 指令而relaxed则只是一条普通的str。这就是为什么在跨平台开发时滥用relaxed或者错用relaxed是最危险的行为。你在开发用的 Macbook以前的 Intel 款上跑得欢天喜地一发布到线上的 ARM 服务器立刻就出现偶发性崩溃而且这种崩溃在 x86 开发机上是绝对复现不出来的。为什么 TSan 抓不到你的 relaxed bug面对并发问题现代 C 工程师的第一反应往往是“不怕我挂上 ThreadSanitizer (TSan) 跑一遍测试有什么问题工具会报出来的。”TSan 确实是抓数据竞争Data Race的神器。如果你把两个普通变量放在不同线程里并发读写TSan 绝对一抓一个准。但是当你引入了memory_order_relaxed之后TSan 往往就变成了瞎子。为什么因为 TSan 的检测逻辑是基于 C内存模型规范的。在 C 标准中只要你对某个变量使用了std::atomic包装无论你用的是什么内存序哪怕是最弱的relaxed对它的并发读写在标准定义上都不属于数据竞争Data Race。TSan 是通过追踪代码中的 happens-before 关系来判断是否有违规访问的。当它看到你使用了relaxed它就会在内部的模型里记录下“好程序员说这里不需要建立同步边”。然后如果你的业务逻辑因为这种缺失的同步边而读到了一个错误的值这叫做“业务逻辑错误”Logic Bug而不叫“数据竞争”。TSan 只负责查违停不负责查你走错路。这就给工程带来了极大的挑战一旦你把标志位或者状态控制变量改成了relaxed你不仅破坏了同步协议还主动把 TSan 这个强大的保安给辞退了。从此以后这段代码的正确性只能完全依赖于你肉眼的 Code Review以及那些漫长而不可靠的拷机测试。面对这种局面有经验的架构师在做代码审查时通常会有一条铁律“证明它是无罪的否则就有罪”。对于任何非计数器、非 ID 类型的relaxed提交PR 作者必须在注释里写出长篇的推导过程证明为什么这里缺少跨线程同步也不会引发业务异常。如果证明不了一律退回改用release/acquire。性能优化的节制与边界既然relaxed这么危险那到底能带来多大的性能收益这其实是一个跟底层硬件架构强相关的问题。在强内存模型Strong Memory Model的架构上比如我们最常用的 x86-64 平台硬件本身就禁止了大部分的指令乱序比如不会出现 Load-Load 重排和 Store-Store 重排。因此在 x86 上一个普通的relaxed load和seq_cst load在生成的汇编指令上往往是一模一样的都是普通的mov指令。性能差异主要体现在store和读-改-写操作上因为seq_cst需要硬件提供像lock前缀或mfence这样的重型同步指令。而在弱内存模型Weak Memory Model的架构上比如 ARM 或者 PowerPC硬件给予了乱序执行极大的自由。这时候relaxed和其他内存序的性能差距就会非常明显因为不写relaxed就意味着要向硬件塞入大量昂贵的屏障指令。所以在做性能评估时千万不能靠脑补也不能拿着单机 x86 跑出来的 Benchmark 就断言“改成 relaxed 性能起飞了”。在工程实践中判断是否使用relaxed我们可以遵循以下这套漏斗原则先问职责这个原子变量只是一个纯粹的旁路数据如统计、采样、ID 生成吗如果是大胆用relaxed。再问依赖这个原子变量的读写是否决定了其他非原子变量的可见性比如经典的 data ready 标志位。如果是坚决不用relaxed老老实实用release/acquire。最后问组合这个变量是否需要和其他原子变量组合起来作为业务状态机推进的判断条件如果是不要用relaxed甚至不要自己手搓考虑用std::mutex把组合状态锁起来。如果经过这层层拷问你发现手头的变量必须承担“一方向内存写好数据另一方负责将状态广播给消费者”的职责这时候光有atomicity已经不够了你必须在两者之间拉起一条牢不可破的同步链。这就是我们下一章要深入拆解的主角——如何用memory_order_release和memory_order_acquire配合完成一次滴水不漏的数据安全发布。码字不易欢迎大家点赞关注评论谢谢