ARTICLE · 1333736
【LevelDB 源码阅读】E01 内存屏障:四门方言
LevelDB 源码笔记 · 番外 E01
内存屏障:四门方言
同一个发布问题——从 store buffer 到 HotSpot、Go runtime,拆开 release/acquire 的四种写法
db/skiplist.h · OpenJDK 22 · Go 1.26阅读约 30 分钟
我们在 07 篇中提到了 SetNext 和 Next 中的有一对 release/acquire,它们是 C++ 提供的六种内存序中的一组。
内存序是并发编程中最容易让人望而生畏的话题之一。但如果对它毫无概念,就很难真正理解各种并发原语为何这样设计,又为何能够正确工作。
因此,我还是想写这样一篇番外,尝试从工程实践的角度,对内存序做一个简单的介绍。这篇文章主要讨论三个问题:
• 内存序背后的理论究竟有哪些?
• 不同语言为程序员提供了什么样的工具?
• 这些工具最终会落到怎样的 CPU 指令上?
之所以值得单独写一篇,不仅因为内存序本身复杂,也因为它在不同语言中的形态差异很大。
C++ 提供六种内存序,由开发者自行选择;Java 很长一段时间里主要依赖 volatile,直到 JDK 9 才通过 VarHandle 暴露更细粒度的控制;Go 至今只提供顺序一致这一档;Rust 的档位与 C++ 接近,却借助类型系统,把相当一部分并发错误挡在了安全代码之外。
同一个问题,四种语言,背后体现的是四种不同的设计立场。
这篇文章独立于主篇之外。即使跳过,也不会影响后续文章的阅读。不过,我仍会尽量让它与主篇保持风格上的连贯。文章后半部分还会进行一些实验,相关代码同样会放在文末附录中。
另外,本文引用的 OpenJDK 代码来自 openjdk-22u/ 稀疏树,tag 为 jdk-22.0.2-ga,并结合 JDK 自带的 lib/src.zip 22.0.2;Go 使用的则是 1.26.0 版本。
同一个「发布」问题——先写数据、再立旗标、别的线程看到旗标后要能读到数据——四门语言给出了四套答案:
•C++ 把各档内存序都摆上货架,由开发者自行选择;
•Java 长期主要提供 volatile,JDK 9 之后才通过 VarHandle 开放细粒度控制;
•Go 只提供顺序一致这一档;
•Rust 保留了与 C++ 接近的表达力,却让数据竞争在安全代码中难以表达。
四套答案最终都会落到相似的几种 CPU 指令上。那么,为什么它们的 API 形态差别如此之大?这正是本文想回答的问题。
本文导航
01 把 07 篇的问题还原到最简单的形态
02 硬件这一层到底发生了什么
03 C++:把每一档都摆上货架
04 Java:比 acquire/release 更强的 volatile
05 Go:不给你选,也是一种设计
06 Rust:把数据竞争逐出安全代码
07 对照与工程启示
01 把 07 篇的问题还原到最简单的形态
先把 SkipList 的场景抽象出来。
写线程分配新节点、填好 key 和各层 next,然后 SetNext 把指针挂上前驱;读线程 Next 拿到这个指针后,要能同时看到节点里已经填好的内容。
07 篇中,我把这种关系称为“打包签收”:release 写之前发生的写入,会随着这次发布一起,对通过 acquire 读到该值的线程可见。
剥掉跳表的外壳,问题只剩下两个变量:
还原到这个最简单的形态后,问题可能出现在哪里,就容易想象了。
• 在写端,如果 data = 1 和 flag = 1 的可见顺序被颠倒,读端就可能先看到旗标,却仍看不到已经写入的数据;
• 在读端,如果对 data 的读取被提到对 flag 的判断之前,同样可能观察到未完成发布的状态。
知道问题可能出现在哪里,并不等于问题容易解决。麻烦在于,“顺序被颠倒”可能发生在两个完全不同的层次。
第一个层次是编译器。
现代编译器会重排、合并,甚至把它认为互不相关的内存访问长期保存在寄存器里。只要不改变单线程程序的可观察行为,它就有权调整指令顺序。在单线程语义下,data = 1 和 flag = 1 谁先谁后往往没有区别,因此编译器可能交换它们。
想约束编译器,需要的是编译器屏障:告诉编译器,某些内存访问不能跨过这条边界移动。
第二个层次是 CPU。
即使编译器老老实实地按源码顺序生成指令,处理器仍然可能乱序执行。下一节会看到,即使是在以“强内存序”著称的 x86 上,仍然存在一种程序可观察的重排。
想约束处理器,需要的是CPU 屏障:它最终会表现为真实的机器指令或具有相应排序语义的访存指令,并付出实际的执行成本。
因此,我们面对的是两层重排,也需要两层约束:编译器屏障约束编译器,CPU 屏障约束处理器。少了任何一层,都不能真正建立跨线程的顺序保证。
只使用编译器屏障,CPU 仍然可能乱序;只考虑 CPU 屏障,编译器又可能在生成机器码之前就改变了访问顺序。后面讨论 C++ 时,我们会遇到一个具体例子:asm volatile("" ::: "memory") 只有编译器这一层的效果。
除此之外,还有一个容易被忽略的问题:可见性。
多核处理器的 cache 一般都有一致性协议,例如 MESI 及其变体。它们保证某个核心的写入最终会传播到其他核心。但“最终”正是问题所在:一致性协议负责让写入传播,却不自动承诺不同写入会以怎样的顺序、在什么时间被其他核心观察到。
于是,完全可能出现这样的局面:写线程依次发出了 A、B 两笔写入,读线程却先观察到 B,随后才观察到 A。
可见性迟早能够兑现,顺序却未必得到承诺。内存序所做的,正是补上这种顺序承诺。
很多资料会把内存序问题简单归结为“CPU 会乱序执行”,但这只是问题的一部分。现代程序真正面对的是三个层次:
1.语言内存模型:规定什么程序行为有定义,程序员可以依赖哪些保证;
2.编译器优化模型:在不破坏语言承诺的前提下,决定代码可以如何变换;
3.CPU 内存模型:规定机器指令在多核间可能以怎样的顺序被观察到。
程序员直接面对的是第一层,真正执行的是第三层,而编译器负责把前者翻译成后者。
把编译器重排、CPU 乱序和写入传播结合起来看,就会发现:在并发程序中不加任何同步地操作共享变量,行为往往无法可靠推断。
现代语言为此在编译器与硬件之上又建立了一层抽象,规定哪些程序行为有定义、哪些优化可以进行。它本质上是程序员与优化器之间的一份契约。
不同语言为这份契约选择了完全不同的写法。这正是本文接下来要讨论的问题。
02 硬件这一层到底发生了什么
要理解屏障,首先要弄清楚:没有屏障时,硬件究竟会做什么。
store buffer:写入先排队
CPU 执行一次内存写入时,如果当前核心尚未持有目标 cache line 的写权限,就需要通过 cache coherence 协议向其他核心发起协商。只有取得该 cache line 的所有权后,写入才能真正提交。
这里的 cache line,通常可以理解为 cache 与内存之间进行传输和一致性管理的基本单位。常见大小是 64 字节,但具体取值由处理器架构决定。
如果 CPU 每次写入都停下来等待所有权协商完成,流水线就可能空转几十个周期。为了避免这种浪费,高性能处理器会在每个核心内部设置一块称为 store buffer 的结构。
写指令先进入 store buffer,然后立即结束。CPU 可以继续执行后面的指令,而 store buffer 则在后台申请 cache line 所有权,并逐步提交其中的写入。
这种设计提高了执行效率,同时也带来一个重要后果:
• 当前核心通常可以立即看到自己的写入,因为后续读取会先检查自己的 store buffer;
• 其他核心只有等待该写入从 store buffer 提交并传播后,才能观察到它。
因此,一个核心观察另一个核心的写入时,看到的结果可能滞后;不同核心看到同一组写入的时间点,也可能不同。
这是弱内存序现象的重要来源。即使是 x86,也没有完全消除这种影响。
在程序员可观察的内存模型层面,x86-TSO 基本只暴露出 Store-Load 这一类重排:写入按照单一顺序对外可见,但稍后的读取可能越过尚未提交的写入。这个模型称为 TSO,即 Total Store Order,x86 的内存模型通常也被称为 x86-TSO。
Dekker litmus:击穿 x86-TSO 的四行代码
观察这一现象,只需要四行代码。本文后续的实验,也都从这四行代码向外展开:
在真正运行之前,不妨先推演一下:
如果所有操作都服从一个全局顺序,即系统满足顺序一致性,那么 r1 和 r2 是否可能同时为 0?
不可能。
可以用反证法快速说明:
• 如果 r1 == 0,说明对 y 的读取发生在 y = 1 之前;而在线程 1 中,x = 1 又先于对 y 的读取,所以全局顺序中 x = 1 必须位于 y = 1 之前;
• 如果 r2 == 0,同理可以推出 y = 1 位于 x = 1 之前。
两个结论不可能同时成立。因此,在顺序一致的世界里,r1 和 r2 至少有一个为 1。
然而,在真实机器上,(0, 0) 确实可能出现。
两个线程分别把自己的写入放进 store buffer,随后立刻读取另一个变量。此时,对方的写入可能仍停留在对方的 store buffer 中,尚未对当前核心可见,因此两个读取都得到 0。
(0, 0) 的出现意味着:虽然程序顺序是“先写后读”,但从跨核可见性的角度看,读取越过了尚未提交的写入。
这类现象称为 Store-Load 重排。在 x86-TSO 对程序暴露的模型中,它是最值得关注的一类重排;而在 ARM 等更弱序的架构中,Load-Load、Load-Store、Store-Store 和 Store-Load 都需要显式考虑。
C++ 三档对照:让 (0,0) 的计数说话
空谈不如上机。
实验代码位于 examples/memorder/dekker.cpp,具体代码我会放到文末附录中。主线程在每一轮中复位 x 和 y,然后通过原子旗标 go 同时放行两个工作线程。两个线程分别执行自己的“写后读”,完成后通过 done.fetch_add(1) 报数。主线程收集结果后,再进入下一轮。
自旋握手保证每一轮中两个线程都会到场。整个实验运行一百万轮,统计 (0, 0) 出现的次数。
litmus 本体只有两行,具体内存序由参数切换:
实验机器为 Intel i7-6920HQ,架构为 x86_64。每档运行一百万轮,结果如下。
需要说明的是,第二档的数据在不同轮次间波动较大,测得过 62.5% 到 89.7% 的结果;另外两档相对稳定。
| C++ 模式 | (0,0) 次数 | 占比 |
|---|---|---|
第二行是本篇最重要的数据之一。
07 篇中说 SkipList 使用 release/acquire 就足够了,这个结论没有问题。因为在发布场景中,写端只需要保证“先初始化数据,再发布指针”,release 写正好约束这个方向。
但在 Dekker litmus 中,acquire/release 版本的 (0, 0) 依然大量出现,定性上与 relaxed 版本没有区别。
原因在于,这两个内存序并不负责建立当前实验所需的 Store-Load 顺序:
•release 主要保证此前的读写不能越过本次发布,移动到它之后;
•acquire 主要保证此后的读写不能越过本次观察,移动到它之前。
而 Dekker 需要的是:当前线程后面的读,不能越过当前线程前面的写。
这一点恰好没有被单独的 release 写和 acquire 读覆盖。
第三行才真正提供了额外保证:seq_cst 版本一百万轮中没有出现一次 (0, 0)。
为什么?
看看编译器生成的指令就清楚了。对同一组原子操作编写一个汇编探针,位于 examples/memorder/asm_probe.cpp,然后使用 clang++ -S -O2 编译。关键输出如下:
examples/memorder/asm_probe.cpp(clang++ -S -O2)
xchg 是原子交换指令。当它的操作数位于内存中时,无论汇编文本中是否显式写出 LOCK 前缀,硬件都会按照 locked 操作处理。
这种操作不能仅在 store buffer 中完成。处理器必须取得相应 cache line 的所有权,并建立 locked 操作要求的顺序约束。其效果之一,就是阻止后续读取越过此前尚未完成的写入。
于是,紧接在 xchg 之后的读取不再能够观察到 Dekker 中的 (0, 0)。
07 篇说“x86 几乎白送 acquire/release”,这里就是具体体现:x86 的普通 mov 已经满足这类 acquire/release 所需的硬件顺序,因此编译器无须插入额外指令。
但 seq_cst 还承诺所有顺序一致原子操作共同服从一个单一全序。这个额外承诺需要付出真实成本,在这里表现为写侧的一条 xchg。
达到类似排序效果并不只有这一种方式,也可以使用 mfence 或其他 locked 指令。HotSpot 和 Linux 内核在实现 StoreLoad 屏障时,通常更偏爱 locked 指令。后文会看到 HotSpot 的具体选择。
顺便看一下独立栅栏:
它在 x86 上会编译成什么?
通常不会产生真实机器指令,汇编中可能只保留一行类似 ##MEMBARRIER 的编译器内部标记。原因不是 acquire fence 没有语义,而是 x86 已经通过硬件内存模型提供了对应的顺序保证。
在 x86 上,通常只有 seq_cst 级别的 fence 才需要生成真实的屏障指令。这个对照在后文讨论 C++ 的两种内存序写法时还会再次用到。
ARM:显式支付屏障成本
把同一份探针交叉编译到 arm64:
得到的指令风格明显不同:
在 x86 上由普通 mov 自然满足的若干顺序,在 ARM 上往往需要通过专门的访存指令或显式 dmb 来表达:
• release 写使用 stlr;
• acquire 读可以使用 ldar 或目标架构允许时的 ldapr;
• 独立栅栏则使用不同范围和类型的 dmb。
这些指令建立的具体约束并不完全相同,但都在控制类似的问题:哪些内存访问必须先完成,哪些操作不能越过边界,以及写入需要传播到什么程度。
这也体现了强序和弱序架构的不同取舍。
弱序架构尽量减少普通访存必须承担的排序约束,只在程序明确要求同步时支付成本;x86-TSO 则让普通指令天然承担更多顺序承诺,降低程序员和编译器在常见场景下的负担。
两种策略背后有不同的历史包袱与性能取舍,也形成了今天服务器和移动平台不同的内存模型风格。
需要注明的是:本文实验机器是 x86_64。arm64 部分只有交叉编译得到的汇编对照,没有在真机上运行。尤其是 ldapr 这类 ARMv8.3 的 RCpc acquire 指令,本文对其行为的讨论来自指令语义,而非真机测量。
03 C++:把每一档都摆上货架
理解了硬件层之后,再回头看 C++ 的设计就容易多了。
C++ 把底层内存模型整理成一套可以由程序员直接操作的控制面板。std::memory_order 一共提供六档,从弱到强如下:
| 内存序 | 主要承诺 | 用“打包签收”类比 |
|---|---|---|
consume 值得单独说明。
它原本试图只沿数据依赖建立顺序,比 acquire 更弱,理论上可以在部分弱序架构上减少屏障。但它的标准定义和编译器实现都十分困难,主流编译器长期将其按 acquire 处理。
因此,实践中的普遍建议是不要使用它。后来的 Rust 索性没有提供对应档位,这并不是遗漏,而是对 C++ 实践经验的吸收。
为什么 std::atomic 的默认内存序是 seq_cst?
这是一个“正确性优先”的选择。多线程程序中的顺序错误往往难以复现,也难以调试。如果语言默认使用较弱内存序,相当于要求每个使用原子的程序员都先成为内存模型专家。
相反,默认采用最严格、最容易推理的档位;只有在确认性能有必要、并能证明较弱顺序足够时,才显式放松。这就把复杂性留给了有能力承担它的人。
前面的实验从反面说明了这种选择的意义:一旦放松顺序,(0, 0) 就会立刻从 0 变成几十万次。
在 C++ 中,表达内存序大致有两种方式。
第一种是把内存序直接绑定在原子操作上:
LevelDB 使用的就是这种方式。db/skiplist.h 中的三组访问器划分得很清楚:
db/skiplist.h
注释中的 “fully initialized version” 正是在描述发布语义:只要读线程通过 acquire 读到了这个指针,就应该同时看到该节点发布前已经完成的初始化。
07 篇已经逐行分析过这里,本文不再展开。
第二种方式是使用独立栅栏:
然后配合较弱的原子操作。
两种写法在某些模式下可以表达相近的同步关系,但它们并不是简单的机械替换,生成的代码也不一定相同。
当内存序直接绑定在具体操作上时,编译器明确知道顺序约束属于哪次读写,通常拥有更大的优化空间;独立栅栏则约束其两侧的一组操作,推理起来更容易出错。
实践中,per-operation 风格更常见,也通常更容易审查。LevelDB 整个代码库中没有使用 atomic_thread_fence,通过 grep 即可验证。
还要特别区分 CPU 屏障与纯编译器屏障。
GCC 和 Clang 中常见的写法:
它告诉编译器:这里可能发生了未知的内存影响,因此不能随意把访存操作跨过这条边界移动。
但这段内联汇编本身是空的,不会生成任何机器指令。它只能约束编译器,无法约束处理器。若把它用于 Dekker 的两个线程,即使编译器不再调整那两行代码,store buffer 仍然照常工作,(0, 0) 依旧可能出现。
它真正适合的是编译器顺序本身需要被控制,而硬件顺序由其他机制保证的场景,例如某些内存映射 IO、内核代码,或明确依赖目标架构顺序特性的底层实现。
最后,还有一个必须拆除的误区:C++ 的 volatile。
C++ 的 volatile 主要表示某次访问具有不可随意消除或合并的外部可观察意义,典型用途是内存映射 IO。它不建立跨线程的 happens-before,也不自动生成同步所需的硬件屏障。
用 C++ volatile 进行线程同步,是并发代码中历史悠久的错误模式之一。
而 Java 的 volatile 完全是另一套语义。两个语言使用同一个关键字,却赋予它完全不同的并发含义,是并发编程中最容易引发误解的同名概念之一。
04 Java:比 acquire/release 更强的 volatile
Java 的立场体现在它的整体设计中:程序员首先面对的是语言定义的抽象机器,而不是具体处理器的内存模型。
JMM,即 Java Memory Model,由 JLS 第 17 章定义。它使用一套抽象规则描述线程、变量和同步动作之间的关系。变量最终映射到寄存器、cache、store buffer,还是其他硬件结构,是 JVM 实现者需要解决的问题。
程序员只对抽象机器编程。volatile、synchronized、final 等语言机制负责保证:无论代码运行在 x86 还是 ARM 上,都能得到符合 JMM 的行为。
volatile 的语义
Java volatile 的语义比单纯的 acquire/release 更强,但又不能在所有方面简单等同于 C++ 的完整 seq_cst 模型。
可以先把它拆成三个部分。
第一,volatile 写具有 release 效果,volatile 读具有 acquire 效果。
因此,07 篇中的“打包签收”关系成立:一个线程在 volatile 写之前所做的普通写,可以通过另一个线程随后观察到该 volatile 值的读而变得可见。
第二,volatile action 之间受 JMM 同步顺序约束。所有同步动作共同服从一个与各线程程序顺序一致的同步顺序,而 volatile 写与随后观察关系之间还会建立 happens-before。
这使 Java volatile 能够表达比单独 acquire/release 更强的跨线程顺序。
第三,volatile 会限制一批编译器优化。例如,编译器不能把本应发生多次的 volatile 读取任意合并成一次普通读取。
因此,在 Dekker litmus 中,如果 x 和 y 都是 volatile,两个线程都通过 volatile 写入和读取,总体顺序约束可以封住 Store-Load 留下的缺口,表现上与 C++ seq_cst 版本一致。
实验代码位于 examples/memorder/DekkerJava.java,使用 OpenJDK 22。每档运行一百万轮,握手方式与 C++ 版本一致:
| Java 模式 | (0,0) 次数 | 占比 |
|---|---|---|
plain 版本的比例明显低于 C++ 实验,但这里不宜直接归因。
JIT 预热、线程调度、握手节奏和具体生成的机器码,都可能改变两个线程撞上重排窗口的概率。多轮实验中,各档的具体比例也会浮动。
真正重要的是稳定的定性关系:
• plain、opaque 和 acquire/release 都能观察到 (0, 0);
• volatile 版本中没有观察到 (0, 0)。
实验过程中,我还踩到了一个很典型的坑。
最初,我使用 volatile int vdone;,并在 worker 中执行 vdone++,结果两个 worker 对计数值的更新会互相覆盖,主线程可能永远等不到计数变成 2。
原因在于,vdone++ 并不是单个不可分割的动作,而是“读取、加一、写回”三步。volatile 保证读写具有可见性和顺序语义,却不把复合读改写自动变成原子操作。
后来改成 AtomicInteger.incrementAndGet() 问题才得以解决。
可见性与原子性是两个正交的承诺。volatile 主要解决前者,不能自动提供后者。
到这里,happens-before 这个词也必须正式引入了。
JMM 用 happens-before 描述一个动作的效果何时必须对另一个动作可见。程序顺序、监视器解锁与后续加锁、volatile 写与后续读、线程启动和线程结束等规则,共同构成 happens-before 关系。
这些关系还具有传递性。
工程上可以使用一个相对朴素的判断方法:
如果希望线程 A 的写入可靠地被线程 B 看到,就应该能够在两者之间连出一条 happens-before 链。
如果所使用的动作不在模型认可的同步规则中,这条链就无法建立,程序也就不能依赖相应的可见性结果。
Java 还有一项很有特色的设计:final 字段语义。
当构造器完成 final 字段赋值,并且对象引用没有在构造过程中提前逃逸时,JMM 会为 final 字段提供额外的初始化安全保证。读取者不会看到这些 final 字段处于构造之前的默认状态。
这并不意味着“引用可以任意错误发布而毫无后果”,也不意味着对象中的所有非 final 状态都自动安全。它更准确地说,是针对 final 字段建立的一组特殊构造和可见性规则。
C++ 没有对应的语言级兜底。普通成员初始化完成后,可以通过 release 发布指针;如果发布过程缺失必要同步并形成数据竞争,程序就可能进入未定义行为。
两种语言的设计取向,在这里体现得很明显:Java 尝试让常见的不可变对象模式更容易安全使用,C++ 则把同步责任更多地交给程序员。
VarHandle 是 JDK 9 对细粒度控制需求的回应。
同一个变量可以通过不同访问模式操作:
•get / set:plain;
•getOpaque / setOpaque:opaque;
•getAcquire / setRelease:acquire/release;
•getVolatile / setVolatile:volatile。
这相当于在 Java 中开放了一套接近 C++ 原子内存序的控制面板。
不过,站在 Java 的设计立场上,这些较弱模式主要服务于并发库和运行时实现者。业务代码的第一选择通常仍应是 volatile、锁、原子类以及 java.util.concurrent 中更高层的抽象。
实验没有再单独加入 VarHandle plain 模式,因为它与普通字段访问相近。相比之下,opaque 更有观察价值:它会保证每次访问确实执行,并为同一变量提供一定的一致性约束,却不建立跨变量的 acquire/release 顺序。
HotSpot 如何把语义翻译成指令
抽象机器的承诺,最终仍然需要翻译成真实指令。
下面查看 HotSpot 解释器在 x86 上的实现。代码来自 openjdk-22u/ 的 jdk-22.0.2-ga。
对于 volatile 写,putfield 指令模板会在实际存储后追加屏障。templateTable_x86.cpp:3241 中:
volatile 读则没有对应的真实屏障。templateTable_x86.cpp:3124 附近保留了如下注释:
这就是“x86 白送 acquire”的源码体现:普通 x86 读取已经满足这里要求的读侧顺序,因此 HotSpot 不必再生成额外指令。
写侧的 StoreLoad 屏障最终落到哪里?
Assembler::membar 位于 assembler_x86.cpp:233。x86 实现中,真正需要额外处理的主要是 StoreLoad 约束。HotSpot 使用的并非 mfence,而是作用于栈附近地址的 locked add:
这个操作在数据意义上几乎是空操作:它只是向一个值加零。但 locked 指令带来的顺序副作用,正好可以建立所需的 StoreLoad 屏障。
实现会选择栈指针下方的地址,并尽量错开一整条 L1 cache line。这样既减少与当前栈帧中实际数据发生依赖的可能,也尽量避免 locked 操作干扰同一 cache line 中的其他内容。
这与 C++ seq_cst store 使用 xchg 的思路非常接近:两边面对的是同一种硬件约束,只是选择了不同的惯用指令表达。
AArch64 上的情况更复杂。
volatile 读在 x86 上不需要真实屏障,但在 ARM 上,不同执行引擎生成的代码可能需要额外协调。templateTable_aarch64.cpp:2606 中有一段直接引用 bug 8179954 的注释:
这段注释指出了一个很有代表性的问题:
解释器生成的读可能采用一套指令序列,C2 编译器生成的写采用另一套指令序列。两种方案单独看都满足各自局部要求,但如果混合运行同一个 Dekker 模式,就未必自动组成 Java volatile 所需的整体顺序。
Java 的承诺不能因为“这段代码由解释器执行,那段代码由 C2 编译”而变化。因此,HotSpot 必须补充屏障,确保不同执行引擎生成的代码能够组合成一致的 JMM 行为。
这正是抽象机器立场带来的额外工程难题:JVM 不仅需要跨 ISA 翻译语义,还要保证解释器、C1、C2 和其他执行路径之间彼此兼容。
MacroAssembler::membar 最终会生成 dmb。相邻屏障还可能根据 kind 合并,尽量减少重复开销。
至于 C2 编译器内部 MemBarNode 的插入、合并与消除,涉及 opto/memnode.cpp、compile.cpp 等更大范围的实现,已经超出本文篇幅。
这里只需记住一点:源码层面看到的屏障,不一定会逐条保留到最终机器码中。JIT 会在不破坏语义的前提下合并或删除冗余屏障。因此,要严格核对运行中的真实指令,仍然需要 hsdis 等反汇编工具。
本文实验机器没有安装 hsdis,因此 Java 部分的证据链由两端组成:一端是 HotSpot 源码,另一端是行为实验。这里如实注明。
最后补一个历史细节。
在 VarHandle 出现之前,JVM 内部常通过 sun.misc.Unsafe,后来迁移到 jdk.internal.misc.Unsafe,提供较低层的原子和有序访问。
Unsafe.java:3897 的 putObjectRelease 自 JDK 12 起被弃用,并委派给新名称 putReferenceRelease。这是从 Unsafe 时代向 VarHandle 时代迁移留下的一处痕迹。
05 Go:不给你选,也是一种设计
Go 的答案从 API 表面就能看出来。
sync/atomic 提供 Load、Store、Add、Swap、CompareAndSwap,以及 atomic.Int64、atomic.Pointer 等类型包装,但没有任何参数允许开发者选择内存序。
它只提供一档:顺序一致。
doc.go:40-48 直接说明:
Additionally, all the atomic operations executed in a program behave as though executed in some sequentially consistent order. This definition provides the same semantics as C++'s sequentially consistent atomics and Java's volatile variables.
也就是说,Go 原子操作的总体语义与 C++ 的顺序一致原子、Java 的 volatile 访问处在同一设计层次。
Go 一直强调:
不要通过共享内存来通信,而要通过通信来共享内存。
业务代码优先使用 channel。channel 收发、sync 包中的锁、goroutine 生命周期相关操作,会在内部建立相应的同步关系。
在这个体系中,内存模型首先服务于标准库和运行时实现者。sync、channel 和调度器的作者需要处理底层顺序,而普通业务代码通常不需要直接选择 Acquire、Release 或 SeqCst。
Go 在 2022 年修订了内存模型,并随 Go 1.19 时代的工具和文档一起强化了原子操作的顺序一致性定义。同时,官方立场始终很明确:存在数据竞争的程序就是错误程序,不应依赖裸共享变量在某台机器上的偶然表现。
从编译器理论上看,C++ 用“未定义行为”表达这条边界;Go 更偏向用工程语言告诉开发者:不要这样写。两者最终都要求程序通过同步消除竞争,但 Go 的 API 和工具链更强调团队协作中的简单性。
落到机器指令上会发生什么?
在 Go 1.26.0 的 GOROOT 中,sync/atomic 的 asm.s 主要是一层薄封装,对应符号通过 JMP 转入 internal/runtime/atomic。
amd64 的关键实现在 atomic_amd64.s 中:
•Store 使用 XCHGL;
•Xchg 同样使用 XCHG;
•Xadd 使用 LOCK XADD;
•Cas 使用 LOCK CMPXCHG。
XCHG 的内存操作数隐含 locked 语义,因此源码不需要显式写出 LOCK 前缀。
这与 C++ 的结果形成了非常直接的对照:
• C++ 的 seq_cst store 可能落到 xchg;
• Go 的普通原子 Store 也落到 XCHG。
同一条指令,在 C++ 中是六档里最强的一档,需要显式选择;在 Go 中则是唯一提供的档位,无须也无法选择。
“约束不同,因此设计不同”,这两行汇编几乎就是全篇的缩影。
Go 1.26 中还有一个值得注意的变化:amd64 的部分原子读取已经不再拥有独立汇编实现。
atomic_amd64.s 的文件注释指出,一些函数会由编译器按语义内联。AMD64.rules:497 中,AtomicLoad64 可以直接重写为 MOVQ。
为了避免普通函数内联干扰观察,我给测试函数加上 //go:noinline,再使用 go tool objdump 验证,最终结果如下:
这里再次体现了 x86 的不对称性:
• 顺序一致的写需要用 XCHG 建立 StoreLoad 顺序;
• 读侧只需要普通 MOVQ,因为 x86 已经提供所需的 acquire 方向约束。
编译器源码 AMD64Ops.go:1153 的注释也直接说明:
Atomic stores and exchanges. Stores use XCHG to get the right memory ordering semantics.
做指令级核对时还有一个容易误判的地方:go compile -S 的行号归属可能存在噪声,对齐指令中的 XCHGL AX, AX 只是 NOP,小函数也经常被内联。因此,最好同时使用 //go:noinline 与 go tool objdump 进行交叉确认。
实验代码位于 examples/memorder/DekkerGo.go。
plain 版本运行一百万轮,观察到 (0, 0) 622,539 次,占 62.3%;atomic.Int64 版本为 0。
race detector 的结果更加直接。使用 go build -race 运行 plain 版本,第一轮就会报警。其中两帧如下:
两个栈指向同一个地址:
• goroutine 8 在第 32 行执行 y = 1;
• goroutine 7 在第 30 行执行 r1 = y。
这正是 litmus 中“一边写、另一边同时读”的竞争现场。
atomic 版本在 -race 下能够正常完成,(0, 0) 仍然为 0。
Go 把 race detector 直接集成进工具链,go test -race 可以很自然地接入 CI。这与它的整体设计保持一致:原子 API 只给一档,再配合一个直接指出数据竞争的检测器,尽可能降低团队使用并发的决策成本。
06 Rust:把数据竞争逐出安全代码
Rust 提供的原子内存序与 C++ 基本对应:
•Relaxed
•Acquire
•Release
•AcqRel
•SeqCst
其中没有 consume。原因前面也已提及:C++ 的 consume 长期难以被编译器准确实现,现实中通常退化为 acquire。Rust 选择不暴露这一档。
不过,Rust 与 C++ 真正的差异并不在内存序数量,而在类型系统。
在 C++ 中,std::atomic<int> 和 int 是不同类型,但语言不会阻止程序员通过普通引用、指针或错误的对象生命周期,在多个线程之间并发访问普通 int。一旦形成数据竞争,程序进入未定义行为。
Rust 中,原子值同样使用独立类型,例如:
•AtomicI64
•AtomicUsize
•AtomicPtr<T>
跨线程共享原子值通常还要配合 Arc 等所有权容器。
对于普通数据,Rust 的借用规则要求:同一时刻要么存在一个可变借用,要么存在任意多个不可变借用。结合 Send 和 Sync 等 trait,这使“两个线程无同步地同时修改普通变量”这种程序形状很难在安全 Rust 中通过编译。
于是,Rust 把一部分问题从运行时转移到了编译期:
数据竞争不再只是“运行后可能出错”,而首先表现为“这种共享形状无法在安全代码中表达”。
这并不意味着 Rust 自动解决了所有并发错误。
当程序已经正确使用原子类型后,开发者仍然可能选错内存序。例如,本该使用 Acquire/Release 的地方误用了 Relaxed,逻辑仍可能出错。类型系统主要阻止的是数据竞争,并不能自动证明无锁算法的顺序关系正确。
unsafe 也是明确存在的逃逸通道。使用裸指针和手工生命周期管理,仍然可以写出数据竞争。但 unsafe 把责任边界写在了代码表面,审查者可以明确知道哪些区域需要额外验证。
如果使用 Rust 重写 SkipList 中的发布模式,核心代码与 LevelDB 几乎是一一对应的:
AtomicPtr::store(Release) 与 load(Acquire),正好对应 LevelDB 的 SetNext 和 Next。
当然,一个生产级无锁跳表还需要解决更多问题,例如:
• 内存分配与整体生命周期;
• 节点回收;
• ABA;
• 指针有效性;
• reclamation 方案。
但就“发布已经初始化完成的节点”这一步而言,两门语言表达的是同一种同步关系。
工具链也是 Rust 并发设计的重要组成部分。
miri 是 Rust 生态中的解释执行和未定义行为检测工具,可以检查多类内存错误与部分并发问题。它并不运行真实机器码,因此适合发现语义级错误,而不是衡量真实硬件上的性能或重排概率。
loom 则面向并发代码的有界模型检验。它会系统探索线程调度和原子操作可能形成的执行组合,帮助无锁算法作者发现只在极少数调度下触发的问题。
实验代码位于 examples/memorder/dekker_rust.rs,通过 rustc -O 编译,每档运行一百万轮:
| Rust 模式 | (0,0) 次数 | 占比 |
|---|---|---|
这个阶梯与 C++ 实验完全同形:
• Relaxed 不能阻止 (0, 0);
• Acquire/Release 也没有覆盖 Dekker 所需的 Store-Load 顺序;
• SeqCst 建立了更强的全局顺序,实验中结果为 0。
在相同硬件和相近编译器后端下,两门语言得到相似的机器行为并不意外。真正不同的,是它们在抵达原子操作之前,允许程序员怎样共享数据。
07 对照与工程启示
走完四门语言,可以把差异收拢成一张表:
| 维度 | C++ | Java |
|---|---|---|
| volatile | ||
(续)
| 维度 | Go | Rust |
|---|---|---|
| -race |
如果只能用一句话概括四门语言的设计,我会选择:约束不同,所以设计不同。
CPU 面对的是相近的硬件现实:store buffer、cache coherence、乱序执行和跨核传播。真正不同的是,各门语言决定把复杂性留给谁承担。
C++ 面向需要底层控制能力的系统程序员。开发者可以为了减少一条 fence,承担证明较弱内存序正确性的心智成本。因此,它开放全部档位,同时把默认值设为最严格的 seq_cst。
Java 面向运行在不同硬件和虚拟机实现上的程序。程序员主要面对 JMM,而不是具体 ISA。为兑现抽象机器的承诺,JVM 必须在 x86、ARM、解释器、C1 和 C2 之间完成语义翻译。
Go 更重视大型团队中的简单性。它不要求业务程序员在多种内存序之间选择,而是只提供顺序一致原子,把常见同步关系封装进 channel 和 sync,再通过 race detector 捕捉裸共享中的错误。
Rust 保留了与 C++ 接近的原子内存序表达力,但使用所有权、借用规则和 Send/Sync,把相当一部分数据竞争变成编译期问题。
四种用户,四种复杂性分配方式,最终形成了四种 API。
再回到 LevelDB。
在 v1.23 中,port/ 目录已经不再提供专门的原子指针封装,std::atomic 直接出现在 db/skiplist.h 中。早期版本中的 port/atomic_pointer.h 曾负责平台适配,随着 C++11 原子库普及,它逐渐成为历史。
07 篇中的 SkipList 为什么只需要其中几档,而不是全部六档?
答案由需求决定。
SkipList 是单写多读结构,关键同步点只有“发布节点指针”:
• 写线程先初始化节点,再用 release 发布指针;
• 读线程用 acquire 取得该指针,从而观察到此前初始化的内容;
•max_height_ 一类“读到旧值也不影响正确性”的提示性状态可以使用 relaxed;
•NoBarrier_* 则用于节点尚未发布、其他线程还无法访问的阶段。
这里不存在跨多个独立原子变量建立统一全序的需求,因此没有必要使用 seq_cst。
反过来说,如果未来有人给 SkipList 增加了两个 flag 之间相互配合的协议,就需要重新证明 acquire/release 是否仍然足够。前面的 Dekker 实验已经说明:一对 release/acquire 并不能自动解决所有跨变量顺序问题。
工程实践上,可以分语言提出几条建议。
对于 C++:
• 先从默认的 seq_cst 开始;
• 只有在性能测试证明有必要时,才放松到 acquire/release 或 relaxed;
• 每一个弱内存序都应该有注释,说明它依赖什么不变量、为何足够;
•skiplist.h 中的注释就是一个很好的范例。
对于 Java:
• 优先选择 volatile、锁、原子类和 java.util.concurrent 中的高层抽象;
• 把 VarHandle 的弱访问模式留给确实需要控制性能的底层库代码;
• 一旦使用 opaque、acquire/release 等模式,最好配合 JCStress 验证。
对于 Go:
• 优先使用 channel 和 sync;
• 需要共享原子状态时使用 sync/atomic,不要依赖普通变量在某台机器上的偶然表现;
• 在 CI 中开启 go test -race。
对于 Rust:
• 先解决共享形状和生命周期问题,再选择原子内存序;
• 不要把 Acquire/Release 当作默认口诀,仍然需要根据同步关系证明;
• 涉及无锁数据结构时,可以使用 Loom 探索调度和内存序组合;
• 必要的 unsafe 应尽量缩小范围,并明确记录安全不变量。
这四门语言还共同提供了一条 API 设计启示。
Go 的设计说明:API 暴露的自由度应该与目标用户真正需要承担的决策相匹配,而不是简单复制底层系统的全部实现细节。
Rust 的设计则说明:如果某类错误能够表达成类型错误,就不应只依赖文档警告和 code review。
因此,在设计自己的并发 API 时,也可以问三个问题:
1. 应该向用户暴露几档控制能力?
2. 默认选择应该偏向安全,还是偏向性能?
3. 错误用法能否由类型系统、编译器检查或动态检测器提前拦住,而不是完全依靠人工审查?
最后,再回到 db/skiplist.h 中的两行代码:
在 Java 中,它们可以表现为一对 volatile 访问,或者更精确地写成 VarHandle.setRelease/getAcquire;在 Go 中,可以使用 atomic.Pointer.Store/Load;在 Rust 中,则是 AtomicPtr 配合 Ordering::Release/Acquire。
四门语言有四种写法,最终却可能落到同一台机器上相近的几条指令。
真正不同的,不是硬件面对的问题,而是每门语言替程序员做了多少决定,又把多少责任留给程序员自己。
LevelDB 选择了 C++,因此这两行代码所表达的每一档顺序,都是作者需要亲自承担的责任。好在它的注释已经把不变量说明得很清楚,而在 07 篇中,我们也逐行完成了验收。
这一篇番外也到这里收束。
主线重新回到数据流:08 篇继续看 Arena。节点从那里生长出来,也最终随着它一同消亡。
下一篇:Arena 与 MemTable 的生死。
如果这篇对你有帮助,欢迎点赞、在看或转发给正在读 LevelDB 的朋友。
附录:本篇示例与实验代码
examples/memorder/ 下的 dekker.cpp、asm_probe.cpp、DekkerJava.java、DekkerGo.go、dekker_rust.rs,与文中的汇编探针、Dekker 计数实验一一对应。
完整代码与编译、运行命令见 GitHub 仓库,或点击「阅读原文」获取。
关注公众号
小 R 今天学点儿啥
持续更新 LevelDB 源码笔记、存储引擎与后端工程实践。如果这篇对你有帮助,欢迎关注、点赞或在看。















































































