夜雨聆风学习资料网

ARTICLE · 1333736

【LevelDB 源码阅读】E01 内存屏障:四门方言

【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;          // 再立旗标
// ——另一个线程——
if (flag == 1) {   // 看到旗标
use(data);     // 必须看到 data == 1
}

还原到这个最简单的形态后,问题可能出现在哪里,就容易想象了。

• 在写端,如果 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 的四行代码

观察这一现象,只需要四行代码。本文后续的实验,也都从这四行代码向外展开:

// 线程 1        // 线程 2
x = 1;           y = 1;
r1 = y;          r2 = x;

在真正运行之前,不妨先推演一下:

   如果所有操作都服从一个全局顺序,即系统满足顺序一致性,那么 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 本体只有两行,具体内存序由参数切换:   

x.store(1, orders.store);   // relaxed / release / seq_cst
r1 = y.load(orders.load);   // relaxed / acquire  / seq_cst

实验机器为 Intel i7-6920HQ,架构为 x86_64。每档运行一百万轮,结果如下。

需要说明的是,第二档的数据在不同轮次间波动较大,测得过 62.5% 到 89.7% 的结果;另外两档相对稳定。

C++ 模式(0,0) 次数占比
relaxed
805,353
80.5%
release 写 + acquire 读
896,921
89.7%(一轮 62.5%)
seq_cst
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)

store_release:              ## release 写
movl%edi, _x(%rip)      ## 普通 mov,零开销
dekker_acqrel:              ## release 写 + acquire 读(Dekker 内核)
movl$1, _x(%rip)        ## mov
movl    _y(%rip), %eax## mov——两条普通指令,随便重排
store_seqcst:               ## seq_cst 写
xchgl%edi, _x(%rip)      ## xchg,带内存操作数时隐含 LOCK
dekker_seqcst:              ## seq_cst 写 + 读(Dekker 内核)
movl$1, %eax
xchgl%eax, _x(%rip)      ## 写变成了 xchg
movl    _y(%rip), %eax## 后面的读才可信

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 的具体选择。   

顺便看一下独立栅栏:

std::atomic_thread_fence(std::memory_order_acquire)

它在 x86 上会编译成什么?

   通常不会产生真实机器指令,汇编中可能只保留一行类似 ##MEMBARRIER 的编译器内部标记。原因不是 acquire fence 没有语义,而是 x86 已经通过硬件内存模型提供了对应的顺序保证。   

   在 x86 上,通常只有 seq_cst 级别的 fence 才需要生成真实的屏障指令。这个对照在后文讨论 C++ 的两种内存序写法时还会再次用到。   

ARM:显式支付屏障成本

把同一份探针交叉编译到 arm64:

clang++-target arm64-apple-macos -S-O2

得到的指令风格明显不同:

store_relaxed:   strw0, [x8]         ## 普通写
store_release:   stlrw0, [x8]         ## release 专属指令
load_relaxed:    ldrw0, [x8]         ## 普通读
load_acquire:    ldaprw0, [x8]         ## acquire 专属(LDAPR,ARMv8.3)
load_seqcst:     ldarw0, [x8]         ## SC 档(LDAR)
fence_release:   dmbish## 独立栅栏,真实指令
fence_acquire:   dmbishld

   在 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 一共提供六档,从弱到强如下:   

内存序主要承诺用“打包签收”类比
relaxed
只保证本次操作原子,不提供额外跨变量顺序
寄出单件包裹,不负责其他内容
consume
按数据依赖建立顺序,现实中基本退化为 acquire
只让依赖这件包裹的人看到相关内容
acquire
后续读写不能越过本次读移动到前面
签收后再拆包
release
此前读写不能越过本次写移动到后面
把此前内容一起封箱发出
acq_rel
在读改写操作上同时具备 acquire 与 release
既签收又重新发出
seq_cst
在 acquire/release 之外,所有 seq_cst 操作还共同服从单一全序
所有人共用一本登记簿

consume 值得单独说明。

它原本试图只沿数据依赖建立顺序,比 acquire 更弱,理论上可以在部分弱序架构上减少屏障。但它的标准定义和编译器实现都十分困难,主流编译器长期将其按 acquire 处理。

因此,实践中的普遍建议是不要使用它。后来的 Rust 索性没有提供对应档位,这并不是遗漏,而是对 C++ 实践经验的吸收。

   为什么 std::atomic 的默认内存序是 seq_cst?   

这是一个“正确性优先”的选择。多线程程序中的顺序错误往往难以复现,也难以调试。如果语言默认使用较弱内存序,相当于要求每个使用原子的程序员都先成为内存模型专家。

相反,默认采用最严格、最容易推理的档位;只有在确认性能有必要、并能证明较弱顺序足够时,才显式放松。这就把复杂性留给了有能力承担它的人。

   前面的实验从反面说明了这种选择的意义:一旦放松顺序,(0, 0) 就会立刻从 0 变成几十万次。   

在 C++ 中,表达内存序大致有两种方式。

第一种是把内存序直接绑定在原子操作上:

value.store(x, std::memory_order_release);
value.load(std::memory_order_acquire);

   LevelDB 使用的就是这种方式。db/skiplist.h 中的三组访问器划分得很清楚:   

db/skiplist.h

Node* Next(int n) {
assert(n >= 0);
// Use an 'acquire load' so that we observe a fully initialized
// version of the returned Node.
return next_[n].load(std::memory_order_acquire);      // skiplist.h:157
}
voidSetNext(int n, Node* x) {
assert(n >= 0);
// Use a 'release store' so that anybody who reads through this
// pointer observes a fully initialized version of the inserted node.
    next_[n].store(x, std::memory_order_release);          // skiplist.h:163
}
Node* NoBarrier_Next(int n) {
assert(n >= 0);
return next_[n].load(std::memory_order_relaxed);       // skiplist.h:169
}

注释中的 “fully initialized version” 正是在描述发布语义:只要读线程通过 acquire 读到了这个指针,就应该同时看到该节点发布前已经完成的初始化。

07 篇已经逐行分析过这里,本文不再展开。

第二种方式是使用独立栅栏:

std::atomic_thread_fence(...);

然后配合较弱的原子操作。

两种写法在某些模式下可以表达相近的同步关系,但它们并不是简单的机械替换,生成的代码也不一定相同。

当内存序直接绑定在具体操作上时,编译器明确知道顺序约束属于哪次读写,通常拥有更大的优化空间;独立栅栏则约束其两侧的一组操作,推理起来更容易出错。

   实践中,per-operation 风格更常见,也通常更容易审查。LevelDB 整个代码库中没有使用 atomic_thread_fence,通过 grep 即可验证。   

还要特别区分 CPU 屏障与纯编译器屏障。

GCC 和 Clang 中常见的写法:

asmvolatile("" ::: "memory");

它告诉编译器:这里可能发生了未知的内存影响,因此不能随意把访存操作跨过这条边界移动。

   但这段内联汇编本身是空的,不会生成任何机器指令。它只能约束编译器,无法约束处理器。若把它用于 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 int
259,462
25.9%
VarHandle opaque
552,088
55.2%
VarHandle setRelease/getAcquire
630,648
63.1%
volatile
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 中:   

putfield_or_static_helper(byte_no, is_static, rc, obj, off, tos_state);
volatile_barrier(Assembler::Membar_mask_bits(Assembler::StoreLoad |
Assembler::StoreStore));

   volatile 读则没有对应的真实屏障。templateTable_x86.cpp:3124 附近保留了如下注释:   

// [jk] not needed currently
// volatile_barrier(Assembler::Membar_mask_bits(Assembler::LoadLoad |
//                                              Assembler::LoadStore));

这就是“x86 白送 acquire”的源码体现:普通 x86 读取已经满足这里要求的读侧顺序,因此 HotSpot 不必再生成额外指令。

写侧的 StoreLoad 屏障最终落到哪里?

Assembler::membar 位于 assembler_x86.cpp:233。x86 实现中,真正需要额外处理的主要是 StoreLoad 约束。HotSpot 使用的并非 mfence,而是作用于栈附近地址的 locked add:   

lockaddl$0, offset(%rsp)

这个操作在数据意义上几乎是空操作:它只是向一个值加零。但 locked 指令带来的顺序副作用,正好可以建立所需的 StoreLoad 屏障。

实现会选择栈指针下方的地址,并尽量错开一整条 L1 cache line。这样既减少与当前栈帧中实际数据发生依赖的可能,也尽量避免 locked 操作干扰同一 cache line 中的其他内容。

   这与 C++ seq_cst store 使用 xchg 的思路非常接近:两边面对的是同一种硬件约束,只是选择了不同的惯用指令表达。   

AArch64 上的情况更复杂。

   volatile 读在 x86 上不需要真实屏障,但在 ARM 上,不同执行引擎生成的代码可能需要额外协调。templateTable_aarch64.cpp:2606 中有一段直接引用 bug 8179954 的注释:   

// 8179954: We need to make sure that the code generated for
// volatile accesses forms a sequentially-consistent set of
// operations when combined with STLR and LDAR.  Without a leading
// membar it's possible for a simple Dekker test to fail if loads
// use LDR;DMB but stores use STLR.  This can happen if C2 compiles
// the stores in one method and we interpret the loads in another.
if (!CompilerConfig::is_c1_or_interpreter_only_no_jvmci()){
Label notVolatile;
    __ tbz(flags, ResolvedFieldEntry::is_volatile_shift, notVolatile);
    __ membar(MacroAssembler::AnyAny);
    __ bind(notVolatile);
}

这段注释指出了一个很有代表性的问题:

解释器生成的读可能采用一套指令序列,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 验证,最终结果如下:   

atomic.Int64.Store  →  XCHGQ AX, 0(CX)     # 排空 store buffer
atomic.Int64.Load   →  MOVQ main.v(SB), AX # 普通读,x86 白送
atomic.Int64.Add    →  LOCK XADDQ AX, 0(CX)

这里再次体现了 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 版本,第一轮就会报警。其中两帧如下:   

WARNING: DATA RACE
Write at 0x00000da48ff0 by goroutine 8:
  main.worker()
      .../examples/memorder/DekkerGo.go:32 +0x106
Previous read at 0x00000da48ff0 by goroutine 7:
  main.worker()
      .../examples/memorder/DekkerGo.go:30 +0xd1

两个栈指向同一个地址:

• 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 几乎是一一对应的:

pubstructNode {
    next: [AtomicPtr<Node>; MAX_HEIGHT],
// ...
}
// 写线程(发布)
node.next[i].store(ptr, Ordering::Release);
// 读线程(观察)
let n = node.next[i].load(Ordering::Acquire);

AtomicPtr::store(Release) 与 load(Acquire),正好对应 LevelDB 的 SetNext 和 Next。   

当然,一个生产级无锁跳表还需要解决更多问题,例如:

• 内存分配与整体生命周期;

• 节点回收;

• ABA;

• 指针有效性;

• reclamation 方案。

但就“发布已经初始化完成的节点”这一步而言,两门语言表达的是同一种同步关系。

工具链也是 Rust 并发设计的重要组成部分。

miri 是 Rust 生态中的解释执行和未定义行为检测工具,可以检查多类内存错误与部分并发问题。它并不运行真实机器码,因此适合发现语义级错误,而不是衡量真实硬件上的性能或重排概率。   

loom 则面向并发代码的有界模型检验。它会系统探索线程调度和原子操作可能形成的执行组合,帮助无锁算法作者发现只在极少数调度下触发的问题。   

   实验代码位于 examples/memorder/dekker_rust.rs,通过 rustc -O 编译,每档运行一百万轮:   

Rust 模式(0,0) 次数占比
Relaxed
819,601
82.0%
Acquire/Release
907,527
90.8%
SeqCst
0
0

这个阶梯与 C++ 实验完全同形:

• Relaxed 不能阻止 (0, 0);   

• Acquire/Release 也没有覆盖 Dekker 所需的 Store-Load 顺序;   

• SeqCst 建立了更强的全局顺序,实验中结果为 0。   

在相同硬件和相近编译器后端下,两门语言得到相似的机器行为并不意外。真正不同的,是它们在抵达原子操作之前,允许程序员怎样共享数据。

07 对照与工程启示     

走完四门语言,可以把差异收拢成一张表:

维度C++Java
内存序控制
六档,consume 实际上很少可用
volatile
 加 VarHandle 细化
数据竞争后果
未定义行为
不保证业务所需顺序,部分底层动作仍有语言定义
设计立场
零开销与底层控制
抽象机器屏蔽硬件差异
常见验证工具
TSan
JCStress

(续)

维度GoRust
内存序控制
只提供顺序一致原子
五档,无 consume
数据竞争后果
官方认定为错误程序
安全代码中通常无法表达
设计立场
简单 API 配合高层通信
保留表达力,同时强化静态安全
常见验证工具
-race
Miri / Loom

   如果只能用一句话概括四门语言的设计,我会选择:约束不同,所以设计不同。

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 中的两行代码:     

next_[n].store(x, std::memory_order_release);
next_[n].load(std::memory_order_acquire);

     在 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 源码笔记、存储引擎与后端工程实践。如果这篇对你有帮助,欢迎关注、点赞或在看。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料