那些以为代码写的是什么顺序CPU就执行什么顺序的人大概率还没被并发的诡异问题折磨过。我第一次遇到重排序Reordering这个概念是在排查一个看起来完全不可能出错的多线程单例代码时。代码逻辑清晰、加了锁、判了空结果在高并发压测下偶尔拿到一个字段全为默认值的半成品对象。那段时间我反复确认自己的代码甚至怀疑是虚拟机或编译器出了 bug直到我真正理解了——你写的指令顺序和 CPU 实际执行的顺序根本就是两回事。重排序是计算机体系结构在追求性能过程中由编译器、CPU、内存子系统共同实施的一种合法作弊。它无处不在却又对上层开发者隐身。这篇内容我想把重排序这个事的来龙去脉完整拆一遍它从哪来、为什么存在、在什么层面上发生、以及它是如何在你不设防时炸掉并发逻辑的。整个话题拆成上下两篇上篇先把重排序为什么存在和它发生在哪几个层面讲透下篇再聚焦于内存屏障、原子操作这些约束手段到底怎么挡住它。任何一种性能优化都不是天上掉下来的重排序能存在本质上是计算机体系结构在过去几十年里为了喂饱 CPU而演化出的复杂机制。理解了这段演进你就理解了重排序的动机。1.1 从顺序执行到流水线性能焦虑的起点最初的 CPU 设计非常本分——取指令、解码、执行、写回一条指令干完所有事再取下一条。这种设计简单可靠但有一个致命问题执行单元的利用率极低。比如一条加法指令真正在运算器里执行只需要几个时钟周期但取指令、解码这些阶段同样要占时间而 CPU 在等待内存时几乎无事可做。后来硬件工程师想出了一个在当时堪称革命性的思路把指令处理过程切成多个阶段让不同指令的不同阶段重叠执行。这就是指令流水线——类似工厂流水线每个工位只处理一个环节一个环节做完立刻交给下一个工位。理想情况下流水线填满后每个时钟周期都能完成一条指令吞吐量直接翻几倍。流水线很好但它有一个很讨厌的限制相邻指令如果存在数据依赖后一条指令就必须等前一条算完。比如a b c; // 指令1 d a e; // 指令2依赖指令1的结果指令2必须等指令1算完 a 才能执行这叫流水线冒险。一旦遇到这种依赖流水线就不得不气泡等待吞吐量瞬间掉回原型。1.2 乱序执行一种先干别的的妥协面对数据依赖造成的停顿硬件工程师继续往前想既然指令2在等指令1的结果那为什么不让指令3、指令4这些不依赖指令1的独立指令先执行反正最终写回结果时保证指令之间的数据依赖关系不被破坏就行。这个思路就是乱序执行的核心。现代 CPU 内部有一个专门的硬件结构通常称为保留站Reservation Station和重排序缓冲ROBReorder Buffer。指令解码后先进入保留站等待它的操作数就绪一旦就绪就算它前面还有指令没执行完它也可以跳到执行单元去执行。执行完后结果先放到重排序缓冲里由硬件保证按原始程序顺序提交结果。ROB 只保证提交结果有序不保证执行过程有序——这就是重排序在 CPU 层面的真正含义。一个非常经典的例子int x load(A); // 指令1: 访问内存很可能 cache miss int y x 1; // 指令2: 依赖指令1必须等 int z load(B); // 指令3: 独立指令可以先执行假设 load(A) 需要 200 个时钟周期load(B) 只需要 5 个时钟周期。顺序执行时指令3必须眼巴巴等着指令1完成再执行白白浪费近 200 个周期。乱序执行时硬件发现指令3与指令1没有依赖关系会让指令3先跑到执行单元去完成等 load(A) 的结果返回后指令2、指令1依次完成提交。同样的代码性能差距可以达到数十倍。1.3 分支预测与推测执行重排序的极致形态乱序执行还只是颠倒顺序分支预测和推测执行则直接把还没确定要不要执行的指令提前跑掉了。CPU 里的流水线一旦遇到条件跳转指令如果停下来等条件判断结果流水线又要气泡。于是硬件引入了分支预测器根据历史规律猜一个跳转方向然后直接往那个方向取指令、执行。如果猜对了那这几百个周期的延迟就被完美隐藏如果猜错了就废弃掉之前推测出来的一切结果重新回到正确路径。这个过程本质上也是重排序——程序原本还在分支判断处徘徊CPU 已经把某个分支后面的指令执行完了。如果这些指令里有对共享内存的写操作那它就会实实在在地对缓存、对其他核产生影响。注意无论 CPU 怎么乱序、怎么推测单条线程内部看起来仍然是程序顺序——因为 ROB 会保证提交有序且发生异常或分支预测失败时会丢弃所有推测结果。也就是说重排序造成的可见乱序发生在多核视角下而不是单核视角。编译器层面的重排序比 CPU 层面的更胆大——它不是基于硬件特性临时调整顺序而是在生成机器码时就根据优化规则把源码重写成了一个语义等价但性能更好的程序。2.1 编译器优化的出发点as-if 原则编译器做任何优化都必须遵守一条底线单线程可观察行为不能变。这条底线在编译原理里有个经典名字叫 as-if 原则——只要我看不出区别你想怎么排都行。基于 as-if 原则一位 -O2 优化后的代码指令顺序经常与源码大相径庭。举一个最简单的例子int a 10; int b 20; int c a b;优化后的代码极有可能是直接把 c 算成常量 30根本不会按源码顺序执行 a10、b20、cab 这三步。这就是常量传播优化带来的重排序。又比如for (int i 0; i 1000; i) { sum data[i]; }编译器可能会做循环展开、向量化把原本顺序累加改成一次算 4 个甚至 8 个数据指令执行的顺序彻底打乱。这些优化都是合法的因为它们没有改变单线程语义。2.2 数据竞争编译器重排的真空区as-if 原则给了编译器极大的自由但它有一个前提假设程序里不存在数据竞争。编译器默认你的代码在并发场景下要么不共享数据要么共享数据时用了锁或原子操作来同步。问题在于如果你的代码里有一个未同步的共享变量两个线程一边读一边写在 C/C 标准里这直接就是未定义行为。未定义行为意味着编译器可以自由假设这种情况永远不会发生然后基于这个假设重排指令——至于这种重排在你那个违反规则的程序里会引发什么后果编译器不负责。这也是很多并发 bug 真正难查的根源代码看起来逻辑没问题甚至单线程跑一万次全部正确但一旦双线程并发跑起来编译器重排后的实际执行顺序完全超出你的预期。你要跟编译器争论这里明明先写了 a 再写 b编译器只会甩出一句反正单线程看不出区别你没加同步我不保证你不炸。2.3 volatile 与编译器屏障一道浅浅的分界线很多开发者会用volatile来标记共享变量期望防止编译器乱动它。但 volatile 的实际效果很有迷惑性对编译器来说volatile 会禁止把多次读取合并成一次删除冗余写入这类优化也会相对地约束 volatile 变量访问之间的重排。对硬件来说volatile 在绝大部分架构上不会生成任何内存屏障指令只影响编译层面的排布。换句话讲volatile能拦得住编译器的一部分手脚却拦不住 CPU 乱序执行也拦不住内存系统里的异步可见性延迟。C11 引入std::atomic之后官方其实已经明确建议并发场景应该用 atomic 和内存序来解决问题而不是依赖 volatile。CPU 内部的乱序执行只发生在执行单元执行指令这一层但数据真正被另一个核心看到还要经过一套更复杂的缓存体系而机制在这个层面引发的乱序普通人最容易忽略。3.1 缓存一致性协议与存储缓冲的临时账本现代 CPU 每个核都有自己的 L1、L2 缓存多核之间通过缓存一致性协议如 x86 的 MESI 族协议保证同一个内存地址的缓存副本是一致的。写数据时CPU 不会立刻穿过缓存把数据写进内存而是先写入自己的 Store Buffer存储缓冲再异步地去申请缓存行的独占权、最后落进缓存。为什么要这么干因为申请独占权需要和其他核对齐耗时可能几百个时钟周期。如果在等获得独占权期间 CPU 空转写密集场景下的性能会惨不忍睹。引入 Store Buffer 后写入先记在临时账本上CPU 可以继续往下执行。问题来了读操作在查询缓存时不会先查 Store Buffer至少在硬件上不会自动这么做除非有特殊机制。于是在一个核内部会出现这样的自我不一致步骤核心0操作核心1操作核心1看到的值1变量 x1写入 Store Buffer读变量 x旧值 02Store Buffer 尚未刷入缓存继续执行0核心0确实先执行了写 x 的指令但在外部视角下直到 Store Buffer 刷入缓存这个写操作才算生效。这相当于一条写指令被延迟到了后面的某条指令之后——一种源自内存子系统的重排序。3.2 失效队列带来的读旧值怪象缓存一致性协议处理某个核修改了缓存行时需要向其他持有了该缓存行副本的核发送失效消息收到响应的核心要把本地副本标记为 Invalid。为了不阻塞别的数据访问收到失效消息的核通常不会立即处理而是把消息丢进一个 Invalid Queue失效队列继续忙自己的事等有空了再真正失效本地缓存行。这套异步机制有一个显而易见的结果核心0把值写进缓存了核心1缓存里却还保留着旧值核心1读的时候读出来的是旧值。跨核视角下写操作和读操作之间的顺序关系被彻底打乱。3.3 为什么这些机制不算体系结构漏洞看到这里你可能会想这种写后立刻读不到的设计也太反直觉了。但在硬件工程师的账本里这不叫 bug这叫取舍——单线程程序本身不存在跨核读取的问题CPU 不必为单线程内的一致性额外买单。只有在多核共享数据的场景下这种自由才会反噬程序逻辑。x86 架构在这一块其实已经算是比较克制的因为它沿用了相对保守的 TSOTotal Store Order模型普通写操作虽然经过 Store Buffer但写入顺序不会任意颠倒读也不能绕过一个尚未完成的先前写。相比之下ARM 和 RISC-V 这类弱内存模型架构允许更多种类的重排序它们对性能的追求更激进同步约束完全交给了软件层的内存屏障。理论说再多都不如一个真实炸掉的场景让人印象深刻。我挑一个并发领域教科书级的翻车案例双重检查锁定简称 DCL。4.1 DCL 单例的隐患在哪里经典的线程安全单例写法如下class Singleton { private: static Singleton* instance; public: static Singleton* getInstance() { if (instance nullptr) { // 第一次检查 lock_guardmutex lock(mtx); if (instance nullptr) { // 第二次检查 instance new Singleton(); // 核心问题点 } } return instance; } };这段代码看起来完美先判空避免锁竞争加锁后再判空保证只有一个线程构造。但instance new Singleton()这行不是原子的它可以拆成三步分配一块内存在内存上调用构造函数完成字段初始化把这块内存的地址赋给 instance 指针。问题就出在第 2 步和第 3 步的顺序上。编译器和 CPU 完全可以先把地址赋给 instance第 3 步再去执行构造函数第 2 步。从当前线程的视角看这样做没问题——反正最后 instance 都会被赋上正确地址构造函数最终也会执行。可如果另一个线程恰好在第 3 步之后、第 2 步之前调用了 getInstance()它会看到instance ! nullptr直接拿走这个地址去使用然后读到一堆尚未初始化的默认值。突然之间单例不单例已经不重要了——你拿到的对象根本不是一个合法构建好的对象。4.2 排查过程比结论更值得参考当年我遇到这个问题的排查过程大概是这样的首先我加了大量日志把创建对象的每个字段都打出来单线程测试完全正常无论怎么跑都是正确的单例。随后我压到几十个线程并发调用故障偶发出现——不是每次都会炸而是跑一段时间后偶发拿到半成品对象。这就说明它和调度时序强相关是一个典型的竞态。接着我在怀疑是不是锁没生效于是加了更重的日志和更慢的构造逻辑结果故障频率反而上升了。这个现象很能说明问题逻辑越慢构造函数和赋值之间的时间窗口就越长其他线程在这个窗口里读到半成品引用的概率就越高。最后我检查生成的汇编代码发现 -O2 优化下instance 指针的赋值确实被调度到了构造函数调用之前。单看每条指令都没问题但组合起来就制造了毁灭性的后果。4.3 另一个常见场景无锁编程里的发布陷阱DCL 算是重排序炸掉并发的一个代表性案例。另一个更隐蔽的场景是发布一个集合对象data loadData(); // 1. 填充大量数据 ready true; // 2. 发布标志位在另一个线程里while (!ready) { /* 自旋等待 */ } process(data); // 使用数据如果ready和data的写入顺序被打乱ready 先被其他核看到别的线程就会在 data 还没填充完时进来处理数据。这种问题在无锁队列、日志系统、消息分发框架里都极易出现而且因为它们通常只在重负载下偶发排查难度非常高。众所周知解决这类问题要靠内存屏障、原子操作、锁这些同步原语把被重排序打乱的顺序焊回去。但这些同步原语具体是怎么挡住 Store Buffer、怎么让 Invalid Queue 乖乖干活这就是下篇要展开的内容了我会把 acquire/release 语义、内存屏障的类型与开销、x86 和 ARM 上屏障的差异逐一对着硬件结构拆开讲。不过在动身去下篇之前有一件事我想先留在你脑子里重排序不是一个你遇到才需要学的知识而是一个没遇到只是因为运气好的隐患。嵌入式、服务端、移动端只要你的程序开了多线程只要有共享可变数据重排序就一直潜伏在那个你未曾设防的角落里。理解了它为何存在你才算真正开始理解并发编程的本质。