博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站在日常编写多线程 C 代码时我们或多或少都使用过原子操作。当我们在代码中写下counter.fetch_add(1)或者flag.load()时大多数情况下我们并不会传入第二个参数。这并不是因为标准库的接口设计比较简易而是因为它为我们自动填补了一个最强也最安全的默认值std::memory_order_seq_cst顺序一致性语义。很多初学者误以为“省略内存序参数”就意味着这只是一个没有任何附加性能开销的常规原子读写但实际上标准库在一开始就帮你选择了顶配。而标准库之所以这样做是因为顺序一致性提供了一个最符合人类直觉的全局心智模型能让绝大多数开发者在不了解底层硬件机制的前提下依然能写出逻辑正确的并发程序。默认值不是空白是最强默认语义在std::atomic的标准 API 设计中几乎所有成员函数的内存序参数都是带有默认值的。无论是底层的读取、写入还是复合的原子修改操作其函数签名本质上都类似于这样#includeatomicstd::atomicintcounter{0};voidIncrease(){// 默认传入了 std::memory_order_seq_cstcounter.fetch_add(1);}intRead(){// 默认传入了 std::memory_order_seq_cstreturncounter.load();}这种设计体现了 C 标准委员会在安全与性能之间的权衡。多线程并发中的内存序问题非常隐蔽稍有不慎就会引发难以重现的数据竞争和指令重排 Bug。通过将最强一致性级别的seq_cst设为默认值标准库实际上是为所有开发者提供了一道天然的防护栏。只要你坚持不写任何内存序参数并且所有的跨线程共享变量都由std::atomic封装那么你的多线程代码就会像串行程序一样易于推理而不需要去跟各种复杂的编译器和硬件重排特性死磕。这种“顶配默认”的做法实际上跟现代一些系统级语言的设计理念存在着有趣的区别。比如 Rust 语言在原子操作中并没有提供任何隐式的默认内存序你每一次调用都必须显式传入类似Ordering::SeqCst的参数以此逼迫程序员去思考底层的硬件成本而 C 则允许你省去参数但在背后直接扔给你最安全的保障。C 的设计意图很明显在绝大多数不需要追求极致吞吐的业务场景下程序员的第一要务是逻辑正确而不是追求那几个纳秒的硬件延迟。哪怕是一个刚从单线程世界转过来的新手写出counter.load()也绝不会因为编译器或 CPU 重排而栽跟头。这就是“开箱即用”的现代高级语言心智。但对于想要进阶到高并发算法设计领域的工程师来说习惯了默认的安全区往往会在需要精细优化时感到迷茫。理解seq_cst的边界就是通往无锁多线程编程的必经之路。然而这种保护并非免费的午餐。强一致性保证的背后是底层编译器和物理硬件在默默承受性能损失。如果我们想要写出真正高性能的无锁数据结构就必须揭开这个默认参数的神秘面纱看一看它在语义上到底作出了什么担保又在硬件上付出了什么代价。seq_cst 的心智模型一条全局时间线顺序一致性Sequential Consistency简称seq_cst这一术语定义可以追溯到计算机体系结构领域的奠基人 Leslie Lamport 在 1979 年发表的经典论文。在这篇论文中Lamport 对顺序一致性给出了精确的形式化定义。简单来说一个并发系统如果满足以下两个约束就可以被认为是顺序一致的单处理器内程序顺序Program Order Constraint每个独立处理器或线程所执行的全部内存操作顺序必须与该处理器内程序代码所写下的指令执行顺序严格一致。全局交错顺序Interleaving Order Constraint所有的访问内存操作都好似在一个全局的、唯一的中央存取设备上排队执行。每个操作对所有处理器而言在时间上都是在同一个瞬间发生且完全一致的。对于程序员而言这提供了一个极为清晰的并发心智模型全系统中所有线程的seq_cst原子操作都被拉到了同一条全局确定的时间线上排队。你可以把整个多线程系统想象成只有一张共享的“操作流水账”。当线程 A 写入一个变量时这个动作被记在流水账的当前行随后线程 B 读取该变量读到的必定是流水账上一行记录的最新值。任何线程在任何时刻看到的世界都是这张唯一且只增不减的流水账。当然我们需要明确的是现代多核物理硬件绝非真的拉了一个物理总线排队机来让所有 CPU 核心串行执行指令。如果是那样多核处理器的并行吞吐量将彻底崩溃。全局时间线只是 C 语言在语义层面向我们提供的担保。编译器和 CPU 硬件会在幕后协同工作使用各自平台所特有的内存屏障指令和缓存一致性机制在弱一致性的多核乱序执行世界上强行构筑起这一高度一致的语义幻觉从而屏蔽了大量反直觉的数据交错降低了程序员的心智开销。除了物理硬件层面的指令乱序执行限制编译器在编译期的激进优化也是seq_cst必不可少的职责。在单线程视角下编译器有一个神圣的原则只要不改变单线程程序的最终运行结果也就是所谓的 As-if 规则编译器可以把任何指令重排、合并、甚至直接消除。例如如果你连续对一个普通变量写入两次编译器可能会直接把第一次写入优化掉只保留第二次。然而在多线程世界中这种原本聪明的优化可能会导致其他正在观察该变量的线程陷入长久的混乱。当我们将原子操作指定为默认的seq_cst时编译器在编译阶段就会主动收敛自己的优化逻辑。它不仅不会把连续的原子读写进行合并或消除更不会将原子操作前后的指令跨越屏障方向进行挪动。这意味着seq_cst既是一道防范 CPU 硬件在运行期乱序的硬件屏障也是道约束编译器在编译期乱序的软件屏障。只有软件和硬件这两道闸门同时关紧那条我们赖以生存的全局时间线才能够真正稳固。四线程 Dekker 实验下的全局时间线收束为了看清“全局时间线”是如何将多个原子变量的操作约束在一起的我们可以观察一个多线程经典实验也就是常用于验证顺序一致性语义的四线程双标记用例#includeatomic#includecassert#includethreadstd::atomicboolx{false};std::atomicbooly{false};std::atomicintz{0};voidWriteX(){x.store(true);// 默认 seq_cst}voidWriteY(){y.store(true);// 默认 seq_cst}voidReadXThenY(){while(!x.load()){// 轮询等待 x 变为 true}if(y.load()){z.fetch_add(1);}}voidReadYThenX(){while(!y.load()){// 轮询等待 y 变为 true}if(x.load()){z.fetch_add(1);}}intmain(){std::threada(WriteX);std::threadb(WriteY);std::threadc(ReadXThenY);std::threadd(ReadYThenX);a.join();b.join();c.join();d.join();assert(z.load()!0);}在这个实验中如果所有的原子操作都采用默认的seq_cst内存序那么在程序运行结束时z的最终值绝对不可能为0。我们可以通过慢推这其中的因果关系来理解它。因为所有的读写操作都处于顺序一致性seq_cst的约束下所以x.store(true)与y.store(true)这两个写入操作在全局唯一的流水账上必然存在一个明确的先后顺序。这里只有两种情况第一种情况假设x.store(true)先于y.store(true)发生。那么对于读取线程ReadYThenX一旦它成功检测到y.load()变为true并跳出了等待循环说明在全局序列中排在后面的y.store(true)已经被执行。既然排在后面的操作都发生了那么排在它前面的x.store(true)必然早已执行完毕且对所有核心可见。因此ReadYThenX随后执行的x.load()必然能够读到true进而成功执行z.fetch_add(1)。第二种情况如果是y.store(true)先于x.store(true)发生那么根据镜像推理读取线程ReadXThenY里的y.load()必定能读到true从而使z递增。在这两种情况下z的最终值可能是 1一个读取线程递增了它或者 2两个读取线程都递增了它但绝对不可能是 0。这个例子生动地展示了顺序一致性的约束力它能够保证跨越不同原子变量x和y的写入事件在全系统中被所有线程以完全相同的物理先后顺序观察到。这就是为什么它最容易被理解因为程序员甚至可以完全依靠物理时间的先后顺序来推导并发逻辑。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传为了更深刻地理解全局全序的意义我们可以做一个思维实验如果把四线程 Dekker 示例里的seq_cst操作降级为release-acquire释放-获取内存序结果会怎样在release-acquire的语义下x.store(true)拥有release语义x.load()拥有acquire语义同理y也是如此。此时WriteX与ReadXThenY之间能建立同步WriteY与ReadYThenX之间也能建立同步。然而因为release-acquire并不保证跨变量的全局一致顺序所以x的修改顺序和y的修改顺序是彼此孤立的。两个读取线程并不存在一个所有核心都达成了共识的“全局时间线”。在核心 0 看来时间线可能是x.store先于y.store执行但在核心 1 看来时间线可能是y.store先于x.store执行。每个读取线程只能看见自己所依赖的那个链条而无法感知另一个链条的相对时序。这就导致ReadXThenY读到了x为true却判定y为false同时ReadYThenX读到了y为true却判定x为false。由于两者都在各自的本地区域里做出了相反的判定最终z依然会保持为0。这就是为什么seq_cst如此珍贵。它不仅给每个单独的原子变量拉起了屏障还将整个程序中所有标记为seq_cst的操作绑定在了同一个全局一致的时序约束中消除了跨变量的局部相对时序错觉。和 relaxed 的对比Store-Load 重排与写缓冲区如果我们把上述实验中的所有操作都替换成relaxed宽松内存序那么最终z是完全有可能保持为0的。这说明在没有顺序一致性保护的多核世界上两个线程完全可以对同一个写入顺序产生相反的观察结果。我们可以通过一个更精简的双线程 Store-Load 经典用例来观察这种硬件重排现象#includeatomic#includethreadstd::atomicboolx{false};std::atomicbooly{false};intr10;intr20;voidThread1(){x.store(true,std::memory_order_relaxed);// 写入 xr1y.load(std::memory_order_relaxed);// 读取 y}voidThread2(){y.store(true,std::memory_order_relaxed);// 写入 yr2x.load(std::memory_order_relaxed);// 读取 x}如果使用relaxed内存序运行这段代码在某些架构例如 ARM 或 x86 配合硬件重排上最终完全有可能观察到r1 0 r2 0。从人类直觉来看这简直是不可能的物理怪异现象这意味着 Thread1 觉得y的写入还没发生而 Thread2 又觉得x的写入还没发生仿佛双方都抢在对方的写入之前读取了旧值。在底层多核硬件的视角下造成这一现象的根源在于现代 CPU 核心内部的写缓冲区Store Buffer设计。为了让核心跑得更快CPU 内部设计了一个私有的“草稿纸”也就是写缓冲区。当 Thread1 在核心 0 上执行写入时数据并没有立刻写进公共的缓存里而是被随手记在了这页私有的草稿纸上。因为草稿纸是核心 0 独占的核心 1 此时根本看不见这个变化依然读到了x在公共缓存里的旧值false。同理核心 1 也把自己的写入放在了它那页草稿纸上导致核心 0 读到了y的旧值。只有等各自的读取操作都彻底完成之后两个核心才慢吞吞地把草稿纸上的新数据同步到缓存行。这种设计直接导致了读写顺序在外界看来被颠倒了。在relaxed内存序下C 内存模型放任了这种硬件底层设计的暴露。虽然x和y各自的修改是有原子性保证的但它们之间没有统一的时钟对齐。这恰恰反衬出默认的seq_cst内存序是多么的省心它通过强行排空写缓冲区并挂起流水线杜绝了这种 Store-Load 重排的发生帮我们保住了全局时间线。seq_cst 能够安全发布数据在多线程开发中最常见的一种设计模式就是“安全发布”一个线程生产者负责准备好一堆复杂的普通数据然后修改一个原子标志位如ready另一个线程消费者不断轮询这个原子标志位一旦看到标志位被修改就立刻读取之前准备好的普通数据。#includeatomicintdata0;std::atomicboolready{false};voidProducer(){data42;ready.store(true);// 默认 seq_cst store}voidConsumer(){while(!ready.load()){// 默认 seq_cst load}Use(data);// 读取 data 是否安全}在这段代码中data只是一个普通的非原子共享变量而ready是原子的。许多开发者直觉上认为这段代码是安全的事实也确实如此。但这种安全性并不是理所当然的它依然高度依赖于seq_cst所附带的偏序可见性保证。C 标准规定seq_cst原子操作不仅要维护全局时间线它在单变量维度上还天然继承了release-acquire释放-获取语义的所有效果每一个seq_cst写入操作store在语义上都至少具备一个release屏障属性。它规定在此之前的任何普通变量写入如data 42绝对不能被重排到该 store 指令之后。每一个seq_cst读取操作load在语义上都至少具备一个acquire屏障属性。它规定在此之后的任何普通变量读取如Use(data)绝对不能被重排到该 load 指令之前。因此当消费者读到ready为true时生产者和消费者之间成功建立了一个强力的同步关系synchronizes-with。这在逻辑上保证了data 42这行代码执行的时间点一定在Use(data)之前保证了没有数据竞争。很多初学者甚至根本没听过“指令重排”和“发布-获取屏障”却能写出这样正确的同步代码完全是由于标准库默认的seq_cst内存序在底层默默提供了一切所需的安全语义。然而这种安全也是脆弱的。如果有后来的代码维护者为了榨取所谓的极致性能在不完全理解机制的情况下盲目将ready的读写降级为relaxed那么这道隐式的防重排屏障就会瞬间瓦解消费者线程将完全可能读到未初始化完成的脏data造成随机崩溃。seq_cst 解决的是物理重排不是业务竞态因为默认的seq_cst具有如此完备的可见性和防重排保护所以很容易给开发者带来一种幻觉觉得只要把并发数据结构里的所有变量都定义成默认的std::atomic整个系统就是绝对线程安全的。这种思想模糊了物理层面的内存一致性与业务逻辑层面的并发事务Transaction之间的界限。我们通过一个经典的业务取款逻辑来澄清这个边界#includeatomicstd::atomicintbalance{100};boolWithdraw(intamount){// 步骤 1检查余额是否充足if(balance.load()amount){// 步骤 2执行扣减并写回balance.store(balance.load()-amount);returntrue;}returnfalse;}在这个例子中balance的每一次load和store都使用了最强力的默认seq_cst内存序。从硬件和编译器的角度来看没有任何内存可见性延迟或指令乱序问题。读取的一定是内存中最新的值写入的也会第一时间全局可见。然而在多线程并发运行的情况下这个取款业务逻辑是完全错误的。假定账户余额为 100线程 A 和线程 B 同时调用Withdraw(80)线程 A 执行步骤 1balance.load()返回 100。100 80通过判断。此时线程 B 也执行步骤 1由于线程 A 尚未执行步骤 2线程 B 读到的balance依然是 100。100 80也通过判断。线程 A 紧接着执行步骤 2计算出100 - 80 20并执行balance.store(20)。线程 B 也执行步骤 2计算出100 - 80 20执行balance.store(20)。最后两个线程都取款成功然而账户余额却只扣减了一次变成了 20。这在金融业务中显然是不可接受的灾难。问题并不在于内存序不够强而在于“检查余额”和“扣减余额”是两个独立的物理原子操作在它们之间存在着时间上的缝隙无法天然合并成一个不可分割的业务事务。要解决这个业务并发竞态不能只在内存序上做文章而必须使用互斥锁来将这几步逻辑锁死或者利用原子操作的“读-改-写”指令CAS将检查与修改合并为单步操作boolWithdraw(intamount){intcurrentbalance.load();while(currentamount){intdesiredcurrent-amount;// compare_exchange_weak 默认也是 seq_cst并且保证读-改-写三合一的原子性if(balance.compare_exchange_weak(current,desired)){returntrue;}}returnfalse;}这个修正版本虽然依旧使用的是默认的seq_cst内存序但更关键的是它通过compare_exchange_weak这一原语消除了“检查”与“写入”之间的时序缝隙将业务逻辑的安全性建立在了正确的并发算法结构上。顺便提一句这里的compare_exchange_weak为什么被命名为“weak弱的”这是因为在某些硬件平台上特别是 ARM 这种弱内存模型的 LL/SC 架构即使当前内存中的值和期望的值完全一致CAS 操作也有可能因为虚假的上下文切换、CPU 中断或缓存行失效而宣告失败这被称为“伪失败Spurious Failure”。正因如此我们需要在一个while循环里包裹它让它在失败时能自动重试。与之相对的还有compare_exchange_strong它在软件循环里帮我们屏蔽了伪失败但代价是稍微高一点点的底层分支开销。虽然这些 CAS 接口的详细用法我们在后面的第 10 章会专门拆解但从这里你已经能够隐约体会到多线程并发世界的物理复杂性即使我们用上了最顶配的默认顺序一致性也必须在算法层面对伪失败和自旋重试做好充分的准备。这并非 C 的设计缺陷而是物理世界的芯片法则映射到软件工程中的必然产物。顺序一致性在多核处理器上的汇编转换与硬件开销既然顺序一致性为了给程序员营造全局时间线的幻觉付出了性能代价那么我们在使用默认的seq_cst时底层物理硬件上到底发生了什么不同的 CPU 架构设计理念迥异它们在实现顺序一致性时付出的性能代价也天差地别。1. x86-64 强内存模型架构x86-64 属于经典的 TSOTotal Store Order全局存储顺序架构。在硬件设计上x86-64 的行为非常保守它在芯片电路层面就禁止了除 Store-Load 以外的所有内存重排如 Load-Load、Load-Store、Store-Store 均天然有序。这意味着在 x86-64 上普通的读取Load其实就已经具备了 Acquire 屏障语义普通的写入Store就已经具备了 Release 屏障语义无需在汇编中插入额外的硬件同步指令。然而为了防范唯一允许的 Store-Load 重排当我们在 C 代码中使用默认的seq_cst内存序时编译器必须采取强力手段。如果你反汇编一段 x86-64 的seq_cst代码会观察到如下特征对于seq_cst load编译器通常只生成一条普通的mov汇编指令。这意味着在 x86-64 架构上顺序一致性的读取操作在运行时几乎是“免费”的不需要插入任何硬件屏障指令因为硬件本身的 TSOTotal Store Order模型就已经在硬件层面屏蔽了除 Store-Load 以外的所有指令乱序。对于seq_cst store编译器则绝对不能再生成普通的mov指令。为了防止 Store-Load 重排并强行将写缓冲区的内容广播它通常会生成带有lock前缀的原子汇编指令例如lock xchg通过寄存器与物理内存的独占交换来触发硬件同步或者在普通的写入指令后追加一条物理开销极高的mfence全局内存屏障指令。在早期的 x86 处理器上lock前缀确实会通过拉低物理总线上的锁信号Lock Signal来独占内存总线从而阻止其他所有 CPU 核心访问系统内存。这种粗暴的“锁总线”手段性能开销极大几乎会让全系统的并行度瞬间归零。而在现代的 x86 处理器中如果目标数据已经被缓存在 CPU 核心的 cache 中lock前缀则会升级为“缓存锁”。它不再去锁死物理总线而是利用缓存一致性协议如 MESI 协议将对应的 cache line 状态强行标记为独占Exclusive并修改。在这一期间由于缓存行被当前核心独占其他核心如果试图读取该缓存行就必须等待 MESI 状态同步完成从而在逻辑上达到了锁总线的效果但大幅度释放了总线本身的并行带宽。即便如此‘排空 Store Buffer’和‘等待 MESI 协议全局同步’依然会让当前的 CPU 核心陷入数十个甚至上百个 CPU 周期的停顿。相较之下普通的mov指令在 x86-64 上则不需要付出任何这种一致性握手的代价。这也是为什么在 x86-64 平台上如果你去度量高频读写的 atomic 操作会发现写入Store在开启seq_cst后的性能下降极其剧烈而读取Load却几乎能够保持和普通非原子变量读取同等的高速度。2. ARMv8 弱内存模型架构ARMv8 则属于弱内存顺序架构其设计哲学是“把性能留给硬件把同步留给程序员”。在默认状态下ARMv8 允许对任意不具备物理数据依赖关系的内存操作进行全面重排。如果是较老的 ARM 架构如 ARMv7为了在如此混乱的弱内存世界上强行拼凑出顺序一致性语义编译器别无选择只能在每一次普通的读写操作前后都插入沉重的数据内存屏障指令如dmb。这会导致 CPU 不断挂起流水线开销极其惨烈。幸运的是ARMv8 架构在芯片层面引入了专门为 C11 内存模型定制的单向获取/释放指令LDARLoad-Acquire读取操作规定其后的所有内存操作不能被重排到其前面。STLRStore-Release写入操作规定其前的所有内存操作不能被重排到其后面。更为关键的是ARMv8 硬件在设计规范中做出了一条额外强力担保所有的STLR指令与其后的LDAR指令对之间天然保持全局多拷贝原子性Multi-copy Atomicity。这意味着全系统内所有的STLR与LDAR执行轨迹在芯片电路上被收拢到了同一个全局一致的流水线上。因此在 ARMv8 平台上编译器只需极其优雅地将seq_cst store翻译成STLR将seq_cst load翻译成LDAR。没有任何沉重的dmb挂起硬件开销极其微小。这展示了现代芯片硬件设计是如何反向去迎合高级语言标准库默认内存序的发展趋势的。核心内存序特性对比为了帮助你在宏观上快速建立对不同内存序特性的直觉我们可以将几种核心内存序的约束力和底层开销做个横向对比内存序 (Memory Order)单变量修改顺序 (Modification Order)跨变量发布-获取 (Release-Acquire)全局唯一时间线 (Global Interleaving Order)底层硬件开销评估 (x86 / ARMv8)memory_order_relaxed保证无乱序读写自身无无极低等同于普通内存读写release / acquire保证保证仅限于存在同步关系的变量链无中等x86 免费 / ARMv8 廉价单向屏障memory_order_seq_cst保证保证保证全系统所有变量统一全序较高x86 锁缓存/总线ARMv8 全局全序通过这张表格你可以清晰地观察到seq_cst是唯一一个在所有维度都提供最强同步约束的内存序。它不仅接管了单对象和双操作之间的时序更以最高昂的硬件代价在全系统范围内强行拉起了一条铁律般的全局时间线。这也是为什么我们在并发编程时总是建议以此作为安全区起步的原因。并发性能调优的工程推进原则由于不同平台和架构在实现seq_cst时有不同的开销在实际商业级工程开发中我们应当如何理智地推进并发控制和内存序优化建议遵循以下工程原则安全起步默认同步在并发模块的架构设计初期或编写非高频执行路径如服务启动配置初始化、线程终止的stop标志等时直接使用默认的seq_cst或直接使用传统的互斥锁保护。并发编程的首要原则是保证逻辑的正确性避免陷入过早优化的陷阱。工具验证数据说话使用 ThreadSanitizer 等检测工具消除所有潜在的数据竞争然后利用性能剖析工具如perf、火焰图等对系统进行负载压力下的度量。千万不要在没有任何 Profile 数据的支撑下仅凭“脑补”就去修改内存序。在利用工具检测如 ThreadSanitizer简称 TSAN时TSAN 也是在运行时动态检测每个线程内存访问的synchronizes-with关系。如果你的原子变量都使用了默认的seq_cstTSAN 能够准确识别出所有跨线程的操作流并且由于全局一致的物理可见性它不太可能会报出虚假的检测警告。然而一旦你开始乱用relaxed虽然你自以为省下了几十个 CPU 周期但 TSAN 可能会因为缺乏同步屏障而直接给你发出醒目的 Data Race 红色报警甚至让调试分析工作陷入极大的泥潭。因此老老实实写对比盲目追求几个 CPU 时钟周期要有用得多。3. 按需降级精确屏障只有当性能报告非常明确地指出某个原子变量由于默认的顺序一致性开销如 x86 上的总线锁竞争、写缓冲区挂起导致多核扩展性受限时才应当将局部的seq_cst降级为较弱的release-acquire语义对用以保护特定的单向数据传递链。4. 注释随行留存记录任何一处被降级的原子内存序代码旁必须附带详细的行内注释。必须写清楚降级后的内存序在与哪个线程的哪个变量建立何种同步关系它的防重排边界在哪里防止后期的维护者在重构时无意间打破这种脆弱的同步链条。通过这种循序渐进的优化策略我们能够同时兼顾系统的正确性与多核性能。在随后的章节中我们将正式揭开弱内存序的面纱看看如果彻底打碎全局时间线去使用开销近乎为零但分析极其复杂的宽松原子内存序relaxed会带来怎样的并发世界图景。码字不易欢迎大家点赞关注评论谢谢
【C++并发系列】第六章:默认的 memory_order_seq_cst 为什么最容易理解
博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站在日常编写多线程 C 代码时我们或多或少都使用过原子操作。当我们在代码中写下counter.fetch_add(1)或者flag.load()时大多数情况下我们并不会传入第二个参数。这并不是因为标准库的接口设计比较简易而是因为它为我们自动填补了一个最强也最安全的默认值std::memory_order_seq_cst顺序一致性语义。很多初学者误以为“省略内存序参数”就意味着这只是一个没有任何附加性能开销的常规原子读写但实际上标准库在一开始就帮你选择了顶配。而标准库之所以这样做是因为顺序一致性提供了一个最符合人类直觉的全局心智模型能让绝大多数开发者在不了解底层硬件机制的前提下依然能写出逻辑正确的并发程序。默认值不是空白是最强默认语义在std::atomic的标准 API 设计中几乎所有成员函数的内存序参数都是带有默认值的。无论是底层的读取、写入还是复合的原子修改操作其函数签名本质上都类似于这样#includeatomicstd::atomicintcounter{0};voidIncrease(){// 默认传入了 std::memory_order_seq_cstcounter.fetch_add(1);}intRead(){// 默认传入了 std::memory_order_seq_cstreturncounter.load();}这种设计体现了 C 标准委员会在安全与性能之间的权衡。多线程并发中的内存序问题非常隐蔽稍有不慎就会引发难以重现的数据竞争和指令重排 Bug。通过将最强一致性级别的seq_cst设为默认值标准库实际上是为所有开发者提供了一道天然的防护栏。只要你坚持不写任何内存序参数并且所有的跨线程共享变量都由std::atomic封装那么你的多线程代码就会像串行程序一样易于推理而不需要去跟各种复杂的编译器和硬件重排特性死磕。这种“顶配默认”的做法实际上跟现代一些系统级语言的设计理念存在着有趣的区别。比如 Rust 语言在原子操作中并没有提供任何隐式的默认内存序你每一次调用都必须显式传入类似Ordering::SeqCst的参数以此逼迫程序员去思考底层的硬件成本而 C 则允许你省去参数但在背后直接扔给你最安全的保障。C 的设计意图很明显在绝大多数不需要追求极致吞吐的业务场景下程序员的第一要务是逻辑正确而不是追求那几个纳秒的硬件延迟。哪怕是一个刚从单线程世界转过来的新手写出counter.load()也绝不会因为编译器或 CPU 重排而栽跟头。这就是“开箱即用”的现代高级语言心智。但对于想要进阶到高并发算法设计领域的工程师来说习惯了默认的安全区往往会在需要精细优化时感到迷茫。理解seq_cst的边界就是通往无锁多线程编程的必经之路。然而这种保护并非免费的午餐。强一致性保证的背后是底层编译器和物理硬件在默默承受性能损失。如果我们想要写出真正高性能的无锁数据结构就必须揭开这个默认参数的神秘面纱看一看它在语义上到底作出了什么担保又在硬件上付出了什么代价。seq_cst 的心智模型一条全局时间线顺序一致性Sequential Consistency简称seq_cst这一术语定义可以追溯到计算机体系结构领域的奠基人 Leslie Lamport 在 1979 年发表的经典论文。在这篇论文中Lamport 对顺序一致性给出了精确的形式化定义。简单来说一个并发系统如果满足以下两个约束就可以被认为是顺序一致的单处理器内程序顺序Program Order Constraint每个独立处理器或线程所执行的全部内存操作顺序必须与该处理器内程序代码所写下的指令执行顺序严格一致。全局交错顺序Interleaving Order Constraint所有的访问内存操作都好似在一个全局的、唯一的中央存取设备上排队执行。每个操作对所有处理器而言在时间上都是在同一个瞬间发生且完全一致的。对于程序员而言这提供了一个极为清晰的并发心智模型全系统中所有线程的seq_cst原子操作都被拉到了同一条全局确定的时间线上排队。你可以把整个多线程系统想象成只有一张共享的“操作流水账”。当线程 A 写入一个变量时这个动作被记在流水账的当前行随后线程 B 读取该变量读到的必定是流水账上一行记录的最新值。任何线程在任何时刻看到的世界都是这张唯一且只增不减的流水账。当然我们需要明确的是现代多核物理硬件绝非真的拉了一个物理总线排队机来让所有 CPU 核心串行执行指令。如果是那样多核处理器的并行吞吐量将彻底崩溃。全局时间线只是 C 语言在语义层面向我们提供的担保。编译器和 CPU 硬件会在幕后协同工作使用各自平台所特有的内存屏障指令和缓存一致性机制在弱一致性的多核乱序执行世界上强行构筑起这一高度一致的语义幻觉从而屏蔽了大量反直觉的数据交错降低了程序员的心智开销。除了物理硬件层面的指令乱序执行限制编译器在编译期的激进优化也是seq_cst必不可少的职责。在单线程视角下编译器有一个神圣的原则只要不改变单线程程序的最终运行结果也就是所谓的 As-if 规则编译器可以把任何指令重排、合并、甚至直接消除。例如如果你连续对一个普通变量写入两次编译器可能会直接把第一次写入优化掉只保留第二次。然而在多线程世界中这种原本聪明的优化可能会导致其他正在观察该变量的线程陷入长久的混乱。当我们将原子操作指定为默认的seq_cst时编译器在编译阶段就会主动收敛自己的优化逻辑。它不仅不会把连续的原子读写进行合并或消除更不会将原子操作前后的指令跨越屏障方向进行挪动。这意味着seq_cst既是一道防范 CPU 硬件在运行期乱序的硬件屏障也是道约束编译器在编译期乱序的软件屏障。只有软件和硬件这两道闸门同时关紧那条我们赖以生存的全局时间线才能够真正稳固。四线程 Dekker 实验下的全局时间线收束为了看清“全局时间线”是如何将多个原子变量的操作约束在一起的我们可以观察一个多线程经典实验也就是常用于验证顺序一致性语义的四线程双标记用例#includeatomic#includecassert#includethreadstd::atomicboolx{false};std::atomicbooly{false};std::atomicintz{0};voidWriteX(){x.store(true);// 默认 seq_cst}voidWriteY(){y.store(true);// 默认 seq_cst}voidReadXThenY(){while(!x.load()){// 轮询等待 x 变为 true}if(y.load()){z.fetch_add(1);}}voidReadYThenX(){while(!y.load()){// 轮询等待 y 变为 true}if(x.load()){z.fetch_add(1);}}intmain(){std::threada(WriteX);std::threadb(WriteY);std::threadc(ReadXThenY);std::threadd(ReadYThenX);a.join();b.join();c.join();d.join();assert(z.load()!0);}在这个实验中如果所有的原子操作都采用默认的seq_cst内存序那么在程序运行结束时z的最终值绝对不可能为0。我们可以通过慢推这其中的因果关系来理解它。因为所有的读写操作都处于顺序一致性seq_cst的约束下所以x.store(true)与y.store(true)这两个写入操作在全局唯一的流水账上必然存在一个明确的先后顺序。这里只有两种情况第一种情况假设x.store(true)先于y.store(true)发生。那么对于读取线程ReadYThenX一旦它成功检测到y.load()变为true并跳出了等待循环说明在全局序列中排在后面的y.store(true)已经被执行。既然排在后面的操作都发生了那么排在它前面的x.store(true)必然早已执行完毕且对所有核心可见。因此ReadYThenX随后执行的x.load()必然能够读到true进而成功执行z.fetch_add(1)。第二种情况如果是y.store(true)先于x.store(true)发生那么根据镜像推理读取线程ReadXThenY里的y.load()必定能读到true从而使z递增。在这两种情况下z的最终值可能是 1一个读取线程递增了它或者 2两个读取线程都递增了它但绝对不可能是 0。这个例子生动地展示了顺序一致性的约束力它能够保证跨越不同原子变量x和y的写入事件在全系统中被所有线程以完全相同的物理先后顺序观察到。这就是为什么它最容易被理解因为程序员甚至可以完全依靠物理时间的先后顺序来推导并发逻辑。外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传为了更深刻地理解全局全序的意义我们可以做一个思维实验如果把四线程 Dekker 示例里的seq_cst操作降级为release-acquire释放-获取内存序结果会怎样在release-acquire的语义下x.store(true)拥有release语义x.load()拥有acquire语义同理y也是如此。此时WriteX与ReadXThenY之间能建立同步WriteY与ReadYThenX之间也能建立同步。然而因为release-acquire并不保证跨变量的全局一致顺序所以x的修改顺序和y的修改顺序是彼此孤立的。两个读取线程并不存在一个所有核心都达成了共识的“全局时间线”。在核心 0 看来时间线可能是x.store先于y.store执行但在核心 1 看来时间线可能是y.store先于x.store执行。每个读取线程只能看见自己所依赖的那个链条而无法感知另一个链条的相对时序。这就导致ReadXThenY读到了x为true却判定y为false同时ReadYThenX读到了y为true却判定x为false。由于两者都在各自的本地区域里做出了相反的判定最终z依然会保持为0。这就是为什么seq_cst如此珍贵。它不仅给每个单独的原子变量拉起了屏障还将整个程序中所有标记为seq_cst的操作绑定在了同一个全局一致的时序约束中消除了跨变量的局部相对时序错觉。和 relaxed 的对比Store-Load 重排与写缓冲区如果我们把上述实验中的所有操作都替换成relaxed宽松内存序那么最终z是完全有可能保持为0的。这说明在没有顺序一致性保护的多核世界上两个线程完全可以对同一个写入顺序产生相反的观察结果。我们可以通过一个更精简的双线程 Store-Load 经典用例来观察这种硬件重排现象#includeatomic#includethreadstd::atomicboolx{false};std::atomicbooly{false};intr10;intr20;voidThread1(){x.store(true,std::memory_order_relaxed);// 写入 xr1y.load(std::memory_order_relaxed);// 读取 y}voidThread2(){y.store(true,std::memory_order_relaxed);// 写入 yr2x.load(std::memory_order_relaxed);// 读取 x}如果使用relaxed内存序运行这段代码在某些架构例如 ARM 或 x86 配合硬件重排上最终完全有可能观察到r1 0 r2 0。从人类直觉来看这简直是不可能的物理怪异现象这意味着 Thread1 觉得y的写入还没发生而 Thread2 又觉得x的写入还没发生仿佛双方都抢在对方的写入之前读取了旧值。在底层多核硬件的视角下造成这一现象的根源在于现代 CPU 核心内部的写缓冲区Store Buffer设计。为了让核心跑得更快CPU 内部设计了一个私有的“草稿纸”也就是写缓冲区。当 Thread1 在核心 0 上执行写入时数据并没有立刻写进公共的缓存里而是被随手记在了这页私有的草稿纸上。因为草稿纸是核心 0 独占的核心 1 此时根本看不见这个变化依然读到了x在公共缓存里的旧值false。同理核心 1 也把自己的写入放在了它那页草稿纸上导致核心 0 读到了y的旧值。只有等各自的读取操作都彻底完成之后两个核心才慢吞吞地把草稿纸上的新数据同步到缓存行。这种设计直接导致了读写顺序在外界看来被颠倒了。在relaxed内存序下C 内存模型放任了这种硬件底层设计的暴露。虽然x和y各自的修改是有原子性保证的但它们之间没有统一的时钟对齐。这恰恰反衬出默认的seq_cst内存序是多么的省心它通过强行排空写缓冲区并挂起流水线杜绝了这种 Store-Load 重排的发生帮我们保住了全局时间线。seq_cst 能够安全发布数据在多线程开发中最常见的一种设计模式就是“安全发布”一个线程生产者负责准备好一堆复杂的普通数据然后修改一个原子标志位如ready另一个线程消费者不断轮询这个原子标志位一旦看到标志位被修改就立刻读取之前准备好的普通数据。#includeatomicintdata0;std::atomicboolready{false};voidProducer(){data42;ready.store(true);// 默认 seq_cst store}voidConsumer(){while(!ready.load()){// 默认 seq_cst load}Use(data);// 读取 data 是否安全}在这段代码中data只是一个普通的非原子共享变量而ready是原子的。许多开发者直觉上认为这段代码是安全的事实也确实如此。但这种安全性并不是理所当然的它依然高度依赖于seq_cst所附带的偏序可见性保证。C 标准规定seq_cst原子操作不仅要维护全局时间线它在单变量维度上还天然继承了release-acquire释放-获取语义的所有效果每一个seq_cst写入操作store在语义上都至少具备一个release屏障属性。它规定在此之前的任何普通变量写入如data 42绝对不能被重排到该 store 指令之后。每一个seq_cst读取操作load在语义上都至少具备一个acquire屏障属性。它规定在此之后的任何普通变量读取如Use(data)绝对不能被重排到该 load 指令之前。因此当消费者读到ready为true时生产者和消费者之间成功建立了一个强力的同步关系synchronizes-with。这在逻辑上保证了data 42这行代码执行的时间点一定在Use(data)之前保证了没有数据竞争。很多初学者甚至根本没听过“指令重排”和“发布-获取屏障”却能写出这样正确的同步代码完全是由于标准库默认的seq_cst内存序在底层默默提供了一切所需的安全语义。然而这种安全也是脆弱的。如果有后来的代码维护者为了榨取所谓的极致性能在不完全理解机制的情况下盲目将ready的读写降级为relaxed那么这道隐式的防重排屏障就会瞬间瓦解消费者线程将完全可能读到未初始化完成的脏data造成随机崩溃。seq_cst 解决的是物理重排不是业务竞态因为默认的seq_cst具有如此完备的可见性和防重排保护所以很容易给开发者带来一种幻觉觉得只要把并发数据结构里的所有变量都定义成默认的std::atomic整个系统就是绝对线程安全的。这种思想模糊了物理层面的内存一致性与业务逻辑层面的并发事务Transaction之间的界限。我们通过一个经典的业务取款逻辑来澄清这个边界#includeatomicstd::atomicintbalance{100};boolWithdraw(intamount){// 步骤 1检查余额是否充足if(balance.load()amount){// 步骤 2执行扣减并写回balance.store(balance.load()-amount);returntrue;}returnfalse;}在这个例子中balance的每一次load和store都使用了最强力的默认seq_cst内存序。从硬件和编译器的角度来看没有任何内存可见性延迟或指令乱序问题。读取的一定是内存中最新的值写入的也会第一时间全局可见。然而在多线程并发运行的情况下这个取款业务逻辑是完全错误的。假定账户余额为 100线程 A 和线程 B 同时调用Withdraw(80)线程 A 执行步骤 1balance.load()返回 100。100 80通过判断。此时线程 B 也执行步骤 1由于线程 A 尚未执行步骤 2线程 B 读到的balance依然是 100。100 80也通过判断。线程 A 紧接着执行步骤 2计算出100 - 80 20并执行balance.store(20)。线程 B 也执行步骤 2计算出100 - 80 20执行balance.store(20)。最后两个线程都取款成功然而账户余额却只扣减了一次变成了 20。这在金融业务中显然是不可接受的灾难。问题并不在于内存序不够强而在于“检查余额”和“扣减余额”是两个独立的物理原子操作在它们之间存在着时间上的缝隙无法天然合并成一个不可分割的业务事务。要解决这个业务并发竞态不能只在内存序上做文章而必须使用互斥锁来将这几步逻辑锁死或者利用原子操作的“读-改-写”指令CAS将检查与修改合并为单步操作boolWithdraw(intamount){intcurrentbalance.load();while(currentamount){intdesiredcurrent-amount;// compare_exchange_weak 默认也是 seq_cst并且保证读-改-写三合一的原子性if(balance.compare_exchange_weak(current,desired)){returntrue;}}returnfalse;}这个修正版本虽然依旧使用的是默认的seq_cst内存序但更关键的是它通过compare_exchange_weak这一原语消除了“检查”与“写入”之间的时序缝隙将业务逻辑的安全性建立在了正确的并发算法结构上。顺便提一句这里的compare_exchange_weak为什么被命名为“weak弱的”这是因为在某些硬件平台上特别是 ARM 这种弱内存模型的 LL/SC 架构即使当前内存中的值和期望的值完全一致CAS 操作也有可能因为虚假的上下文切换、CPU 中断或缓存行失效而宣告失败这被称为“伪失败Spurious Failure”。正因如此我们需要在一个while循环里包裹它让它在失败时能自动重试。与之相对的还有compare_exchange_strong它在软件循环里帮我们屏蔽了伪失败但代价是稍微高一点点的底层分支开销。虽然这些 CAS 接口的详细用法我们在后面的第 10 章会专门拆解但从这里你已经能够隐约体会到多线程并发世界的物理复杂性即使我们用上了最顶配的默认顺序一致性也必须在算法层面对伪失败和自旋重试做好充分的准备。这并非 C 的设计缺陷而是物理世界的芯片法则映射到软件工程中的必然产物。顺序一致性在多核处理器上的汇编转换与硬件开销既然顺序一致性为了给程序员营造全局时间线的幻觉付出了性能代价那么我们在使用默认的seq_cst时底层物理硬件上到底发生了什么不同的 CPU 架构设计理念迥异它们在实现顺序一致性时付出的性能代价也天差地别。1. x86-64 强内存模型架构x86-64 属于经典的 TSOTotal Store Order全局存储顺序架构。在硬件设计上x86-64 的行为非常保守它在芯片电路层面就禁止了除 Store-Load 以外的所有内存重排如 Load-Load、Load-Store、Store-Store 均天然有序。这意味着在 x86-64 上普通的读取Load其实就已经具备了 Acquire 屏障语义普通的写入Store就已经具备了 Release 屏障语义无需在汇编中插入额外的硬件同步指令。然而为了防范唯一允许的 Store-Load 重排当我们在 C 代码中使用默认的seq_cst内存序时编译器必须采取强力手段。如果你反汇编一段 x86-64 的seq_cst代码会观察到如下特征对于seq_cst load编译器通常只生成一条普通的mov汇编指令。这意味着在 x86-64 架构上顺序一致性的读取操作在运行时几乎是“免费”的不需要插入任何硬件屏障指令因为硬件本身的 TSOTotal Store Order模型就已经在硬件层面屏蔽了除 Store-Load 以外的所有指令乱序。对于seq_cst store编译器则绝对不能再生成普通的mov指令。为了防止 Store-Load 重排并强行将写缓冲区的内容广播它通常会生成带有lock前缀的原子汇编指令例如lock xchg通过寄存器与物理内存的独占交换来触发硬件同步或者在普通的写入指令后追加一条物理开销极高的mfence全局内存屏障指令。在早期的 x86 处理器上lock前缀确实会通过拉低物理总线上的锁信号Lock Signal来独占内存总线从而阻止其他所有 CPU 核心访问系统内存。这种粗暴的“锁总线”手段性能开销极大几乎会让全系统的并行度瞬间归零。而在现代的 x86 处理器中如果目标数据已经被缓存在 CPU 核心的 cache 中lock前缀则会升级为“缓存锁”。它不再去锁死物理总线而是利用缓存一致性协议如 MESI 协议将对应的 cache line 状态强行标记为独占Exclusive并修改。在这一期间由于缓存行被当前核心独占其他核心如果试图读取该缓存行就必须等待 MESI 状态同步完成从而在逻辑上达到了锁总线的效果但大幅度释放了总线本身的并行带宽。即便如此‘排空 Store Buffer’和‘等待 MESI 协议全局同步’依然会让当前的 CPU 核心陷入数十个甚至上百个 CPU 周期的停顿。相较之下普通的mov指令在 x86-64 上则不需要付出任何这种一致性握手的代价。这也是为什么在 x86-64 平台上如果你去度量高频读写的 atomic 操作会发现写入Store在开启seq_cst后的性能下降极其剧烈而读取Load却几乎能够保持和普通非原子变量读取同等的高速度。2. ARMv8 弱内存模型架构ARMv8 则属于弱内存顺序架构其设计哲学是“把性能留给硬件把同步留给程序员”。在默认状态下ARMv8 允许对任意不具备物理数据依赖关系的内存操作进行全面重排。如果是较老的 ARM 架构如 ARMv7为了在如此混乱的弱内存世界上强行拼凑出顺序一致性语义编译器别无选择只能在每一次普通的读写操作前后都插入沉重的数据内存屏障指令如dmb。这会导致 CPU 不断挂起流水线开销极其惨烈。幸运的是ARMv8 架构在芯片层面引入了专门为 C11 内存模型定制的单向获取/释放指令LDARLoad-Acquire读取操作规定其后的所有内存操作不能被重排到其前面。STLRStore-Release写入操作规定其前的所有内存操作不能被重排到其后面。更为关键的是ARMv8 硬件在设计规范中做出了一条额外强力担保所有的STLR指令与其后的LDAR指令对之间天然保持全局多拷贝原子性Multi-copy Atomicity。这意味着全系统内所有的STLR与LDAR执行轨迹在芯片电路上被收拢到了同一个全局一致的流水线上。因此在 ARMv8 平台上编译器只需极其优雅地将seq_cst store翻译成STLR将seq_cst load翻译成LDAR。没有任何沉重的dmb挂起硬件开销极其微小。这展示了现代芯片硬件设计是如何反向去迎合高级语言标准库默认内存序的发展趋势的。核心内存序特性对比为了帮助你在宏观上快速建立对不同内存序特性的直觉我们可以将几种核心内存序的约束力和底层开销做个横向对比内存序 (Memory Order)单变量修改顺序 (Modification Order)跨变量发布-获取 (Release-Acquire)全局唯一时间线 (Global Interleaving Order)底层硬件开销评估 (x86 / ARMv8)memory_order_relaxed保证无乱序读写自身无无极低等同于普通内存读写release / acquire保证保证仅限于存在同步关系的变量链无中等x86 免费 / ARMv8 廉价单向屏障memory_order_seq_cst保证保证保证全系统所有变量统一全序较高x86 锁缓存/总线ARMv8 全局全序通过这张表格你可以清晰地观察到seq_cst是唯一一个在所有维度都提供最强同步约束的内存序。它不仅接管了单对象和双操作之间的时序更以最高昂的硬件代价在全系统范围内强行拉起了一条铁律般的全局时间线。这也是为什么我们在并发编程时总是建议以此作为安全区起步的原因。并发性能调优的工程推进原则由于不同平台和架构在实现seq_cst时有不同的开销在实际商业级工程开发中我们应当如何理智地推进并发控制和内存序优化建议遵循以下工程原则安全起步默认同步在并发模块的架构设计初期或编写非高频执行路径如服务启动配置初始化、线程终止的stop标志等时直接使用默认的seq_cst或直接使用传统的互斥锁保护。并发编程的首要原则是保证逻辑的正确性避免陷入过早优化的陷阱。工具验证数据说话使用 ThreadSanitizer 等检测工具消除所有潜在的数据竞争然后利用性能剖析工具如perf、火焰图等对系统进行负载压力下的度量。千万不要在没有任何 Profile 数据的支撑下仅凭“脑补”就去修改内存序。在利用工具检测如 ThreadSanitizer简称 TSAN时TSAN 也是在运行时动态检测每个线程内存访问的synchronizes-with关系。如果你的原子变量都使用了默认的seq_cstTSAN 能够准确识别出所有跨线程的操作流并且由于全局一致的物理可见性它不太可能会报出虚假的检测警告。然而一旦你开始乱用relaxed虽然你自以为省下了几十个 CPU 周期但 TSAN 可能会因为缺乏同步屏障而直接给你发出醒目的 Data Race 红色报警甚至让调试分析工作陷入极大的泥潭。因此老老实实写对比盲目追求几个 CPU 时钟周期要有用得多。3. 按需降级精确屏障只有当性能报告非常明确地指出某个原子变量由于默认的顺序一致性开销如 x86 上的总线锁竞争、写缓冲区挂起导致多核扩展性受限时才应当将局部的seq_cst降级为较弱的release-acquire语义对用以保护特定的单向数据传递链。4. 注释随行留存记录任何一处被降级的原子内存序代码旁必须附带详细的行内注释。必须写清楚降级后的内存序在与哪个线程的哪个变量建立何种同步关系它的防重排边界在哪里防止后期的维护者在重构时无意间打破这种脆弱的同步链条。通过这种循序渐进的优化策略我们能够同时兼顾系统的正确性与多核性能。在随后的章节中我们将正式揭开弱内存序的面纱看看如果彻底打碎全局时间线去使用开销近乎为零但分析极其复杂的宽松原子内存序relaxed会带来怎样的并发世界图景。码字不易欢迎大家点赞关注评论谢谢