编程语言内存模型
这是 Go 语言现任领队 Russ Cox 在 2021 年写的文章 Programming Language Memory Models 的翻译。
本文是第二篇,重点阐述了 C++ 和 Java 对内存模型设计的历史,以及 ECMAScript 2017 的初步尝试。
(内存模型,第二部分)
发布于 2021 年 7 月 6 日星期二。
- 硬件,Litmus 测试,先发生于和 DRF-SC
- 编译器和优化
- 原始的 Java 内存模型(1996)
- 新的 Java 内存模型(2004)
- C++11 内存模型(2011)
- C, Rust 和 Swift 内存模型
- 硬件题外话:高效的顺序一致性原子
- JavaScript 内存模型 (2017)
- 结论
- 致谢
编程语言内存模型回答了并行程序可以依赖哪些行为在其线程之间共享内存的问题。例如,考虑这个类似 C 语言的程序,其中 x
和 done
初始值为 0。
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x);
程序尝试将 x
中的消息从线程 1 发送到线程 2,使用 done
作为消息已准备好接收的信号。如果线程 1 和线程 2 各自在自己的专用处理器上运行,都运行到完成,则此程序是否保证按预期完成并打印 1?编程语言内存模型回答了这个问题和其他类似的问题。
尽管每种编程语言在细节上有所不同,但基本上所有现代多线程语言(包括 C,C++,Go,Java,JavaScript,Rust 和 Swift)都有一些通用答案:
- 首先,如果
x
和done
是普通变量,那么线程 2 的循环可能永远不会停止。常见的编译器优化是在变量首次使用时将其加载到寄存器中,然后尽可能长时间地重用该寄存器以供将来访问该变量。如果线程 2 在线程 1 执行之前将done
复制到寄存器中,则它可能会在整个循环中继续使用该寄存器,而不会注意到线程 1 稍后修改了done
。 - 其次,即使线程 2 的循环确实停止,在观察到
done == 1
之后,它仍然可能打印出x
为 0。编译器通常根据优化启发式方法重新排序程序读取和写入,甚至只是基于生成代码时最终遍历哈希表或其他中间数据结构的方式。线程 1 的编译代码可能最终在“完成”而不是“之前”之后写入x
,或者线程 2 的编译代码可能最终在循环之前读取x
。
在给出程序破坏的原因后,重点是如何修复它。
现代语言以 原子变量 或 原子操作 的形式提供特殊功能,以允许程序同步其线程。如果我们使done
成为原子变量(或使用原子操作,在采用该方法的语言中操作它),那么我们的程序可以保证完成并打印 1。使 done
原子具有许多效果:
- 线程 1 编译的代码必须确保对
x
的写入已完成,并且在写入done
变得可见之前对其他线程可见。 - 线程 2 编译的代码必须在循环的每次迭代中(重新)读取
done
。 - 线程 2 编译的代码必须在从
done
读取后从x
读取。 - 编译的代码必须执行任何必要的操作来禁用可能重新引入任何这些问题的硬件优化。
使 done
原子化的最终结果是程序按照我们想要的方式运行,成功地将 x
中的值从线程 1 传递到线程 2。
在原始程序中,在编译器的代码重新排序之后,线程 1 可能在线程 2 读取它的同时写入 x
。这是一个 数据竞争。在修订后的程序中,原子变量 done
用于同步对 x
的访问:现在线程 1 不可能在线程 2 读取它的同时写入 x
。该程序 无数据竞争。一般来说,现代语言保证无数据争用的程序始终以顺序一致的方式执行,就好像来自不同线程的操作任意但不重新排序地交错到单个处理器上。这是在编程语言上下文中采用的 硬件内存模型中的 DRF-SC 属性。
顺便说一句,这些原子变量或原子操作更合适地称为“同步原子”。确实,这些操作在数据库意义上是原子的,允许同时读取和写入,其行为就像按某种顺序顺序运行:普通变量上的竞争在使用原子时不是竞争。但更重要的是,原子同步程序的其余部分,提供一种消除非原子数据竞争的方法。不过,标准术语是简单的“原子”,所以本文也这么使用。除非另有说明,请记住将“原子”理解为“同步原子”。
编程语言内存模型指定了程序员和编译器所需内容的确切细节,作为它们之间的契约。上面概述的一般特征基本上适用于所有现代语言,但直到最近,事情才趋于一致:在 2000 年代初期,变化明显更多。即使在今天,不同语言在二阶问题上也存在显着差异,包括:
- 原子变量本身的顺序保证是什么?
- 一个变量可以被原子操作和非原子操作访问吗?
- 除了原子之外还有同步机制吗?
- 是否存在不同步的原子操作?
- 带有竞争的程序有任何保证吗?
经过一些准备工作后,本文的其余部分将研究不同的语言如何回答这些问题和相关问题,以及它们实现这一目标所采取的路径。本文还强调了这一过程中的许多错误开始,以强调我们仍然在很大程度上了解什么有效,什么无效。
硬件,Litmus 测试,先发生于和 DRF-SC
在我们了解任何特定语言的详细信息之前,我们需要记住硬件内存模型的经验教训的简要总结。
不同的体系结构允许不同数量的指令重新排序,因此在多个处理器上并行运行的代码可以根据体系结构具有不同的允许结果。黄金标准是顺序一致性,其中任何执行都必须表现得好像在不同处理器上执行的程序只是以某种顺序交错到单个处理器上。该模型对于开发人员来说更容易推理,但目前还没有重要的架构提供它,因为较弱的保证带来了性能提升。
比较不同的内存模型很难做出完全通用的陈述。相反,它可以帮助您专注于特定的测试用例,称为 litmus 测试。如果两个记忆模型对于给定的 litmus 测试允许不同的行为,这证明它们是不同的,并且通常可以帮助我们了解至少对于该测试用例,一个模型是否比另一个更弱或更强。例如,这是我们之前检查的程序的 litmus 测试形式:
Litmus 测试:消息传递
这个程序能观测到
r1 = 1
,r2 = 0
吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
在顺序一致的硬件上:否。
在 x86(或其他 TSO)上:否。
在 ARM/POWER 上:是!
在任何使用普通变量的现代编译语言中:是!
与上一篇文章一样,我们假设每个示例都以所有共享变量初始值都为 0。名称 r
N 表示私有存储,如寄存器或函数局部变量;其他名称如 x
和 y
是不同的、共享的(全局)变量。我们询问在执行结束时是否可以对寄存器进行特定设置。在回答硬件的 litmus 测试时,我们假设没有编译器来重新排序线程中发生的事情:列表中的指令直接转换为提供给处理器执行的汇编指令。
结果 r1 = 1
、r2 = 0
对应于原始程序的线程 2 完成其循环(done
是 y
),但随后打印 0。在程序操作的任何顺序一致交错中都不可能出现此结果。对于汇编语言版本,在 x86 上不可能打印 0,但由于处理器本身的重新排序优化,在 ARM 和 POWER 等更宽松的体系结构上可以打印 0。在现代语言中,无论底层硬件是什么,编译期间可能发生的重新排序都使得这种结果成为可能。
正如我们前面提到的,今天的处理器不是保证顺序一致性,而是保证一个称为“无数据争用顺序一致性”或 DRF-SC(有时也写为 SC-DRF)的属性。保证 DRF-SC 的系统必须定义称为 同步指令 的特定指令,它提供了一种协调不同处理器(相当于线程)的方法。程序使用这些指令在一个处理器上运行的代码与另一个处理器上运行的代码之间创建“先发生于”关系。
例如,这里描述了一个程序在两个线程上的短暂执行;像往常一样,假设每个都位于其自己的专用处理器上:
我们在上一篇文章中也看到了这个程序。线程1和线程2执行同步指令 S(a)。在程序的这个特定执行中,两条 S(a) 指令建立了从线程 1 到线程 2 的 happens-before 关系,因此线程 1 中的 W(x) 先发生于线程 2 的 R(x)。
不同处理器上按 happens-before 排序的两个事件可能同时发生:确切顺序尚不清楚。我们说他们 并发 执行。数据争用是指对变量的写入与对该变量的读取或另一次写入同时执行。提供 DRF-SC 的处理器(现在的所有处理器)保证程序 没有 数据竞争的行为,就像它们在顺序一致的架构上运行一样。这是在现代处理器上编写正确的多线程汇编程序的基本保证。
正如我们之前看到的,DRF-SC也是现代语言所采用的基本保证,使得用高级语言编写正确的多线程程序成为可能。
编译器和优化
我们已经多次提到,编译器可能会在生成最终可执行代码的过程中对输入程序中的操作重新排序。让我们仔细看看该声明以及其他可能导致问题的优化。
人们普遍认为,编译器可以几乎任意地对普通的读取和写入进行重新排序,前提是重新排序不能改变单线程执行时代码的可见行为。例如,考虑以下程序:
w = 1
x = 2
r1 = y
r2 = z
由于 w
、x
、y
和 z
都是不同的变量,因此这四个语句可以按照编译器认为的最佳任何顺序执行。
正如我们上面提到的,如此自由地重新排序读取和写入的能力使得普通编译程序的保证至少与 ARM/POWER 宽松内存模型一样弱,因为编译程序无法通过消息传递 litmus 测试。事实上,对已编译程序的保证较弱。
在硬件文章中,我们将一致性作为 ARM/POWER 架构保证的一个例子:
Litmus 测试:一致性
这个程序能观察到
r1 = 1
,r2 = 2
,r3 = 2
,r4 = 1
吗?(线程 3 能在
x = 1
在x = 2
之前,并且线程 4 看到相反的结果吗?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 x = 2 r1 = x r3 = x
r2 = x r4 = x
在顺序一致的硬件上:否。
在 x86 上(或其他 TSO):否。
在 ARM/POWER 上:否。
在任何使用普通变量的现代编译语言中: 是!
所有现代硬件都保证了一致性,这也可以看作是单个内存位置上操作的顺序一致性。在这个程序中,其中一个写入必须覆盖另一个,整个系统必须同意一个固定顺序。事实证明,由于编译过程中的程序重新排序,现代语言甚至不提供一致性。
假设编译器对线程 4 中的两个读取重新排序,然后指令按以下顺序交错运行:
// Thread 1 // Thread 2 // Thread 3 // Thread 4
// \(reordered\)
(1) x = 1 (2) r1 = x (3) r4 = x
(4) x = 2 (5) r2 = x (6) r3 = x
结果是 r1 = 1
,r2 = 2
,r3 = 2
,r4 = 1
,这在汇编程序中是不可能的,但在高级语言中是可能的。从这个意义上说,编程语言内存模型都比最宽松的硬件内存模型弱。
但还有一些保证。每个人都同意需要提供 DRF-SC,这不允许引入新读取或写入的优化,即使这些优化在单线程代码中有效。
例如,请考虑以下代码:
if (c) {
x++;
} else {
... lots of code ...
}
有一个 if
语句,else
中有很多代码,而 if
主体中只有一个 x++
。减少分支并完全消除 if
主体可能会更“便宜”。我们可以通过在 if
之前运行 x++
来做到这一点,然后如果我们错了,则在大的 else 主体中使用 x--
进行调整。也就是说,编译器可能会考虑将该代码重写为:
x++;
if (!c) {
x--;
... lots of code ...
}
这是安全的编译器优化吗?在单线程程序中,是的。在多线程程序中,当 c
为 false 时,x
与另一个线程共享,否:优化会在 x
上引入原始程序中不存在的竞争。
此示例源自 Hans Boehm 2004 年的论文中的一个,“线程不能作为库实现”,这使得语言不能对多线程执行的语义保持沉默。
编程语言内存模型试图准确回答这些问题:哪些优化是允许的,哪些是不允许的。通过研究过去几十年来尝试编写这些模型的历史,我们可以了解哪些有效,哪些无效,并了解事情的发展方向。
原始的 Java 内存模型(1996)
Java 是第一个尝试写下它对多线程程序的保证的主流语言。它包括互斥体并定义了它们隐含的内存排序要求。它还包括“易失性”原子变量:所有易失性变量的读写都需要直接在主内存中按程序顺序执行,使得对易失性变量的操作以顺序一致的方式表现。最后,Java 还指定了(或至少尝试指定)具有数据竞争的程序的行为。其中一部分是要求普通变量具有某种形式的一致性,我们将在下面详细讨论。不幸的是,这种尝试在Java语言规范(1996)第一版中至少有两个严重的问题缺陷。通过事后诸葛亮和使用我们已经设定的预备知识,它们很容易解释。当时,它们远没有那么明显。
原子需要同步
第一个缺陷是易失性原子变量是非同步的,因此它们无助于消除程序其余部分中的竞争。我们上面看到的消息传递程序的 Java 版本是:
int x;
volatile int done;
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x);
因为 done
被声明为易失性,所以保证循环完成:编译器无法将其缓存在寄存器中并导致无限循环。但是,程序不保证打印 1。不禁止编译器重新排序对 x
和 done
的访问,也不需要禁止硬件执行相同的操作。
由于 Java 易失性是非同步原子,因此您无法使用它们来构建新的同步原语。从这个意义上说,原始的 Java 内存模型太弱了。
一致性与编译器优化不兼容
原始的 Java 内存模型也太强大了:强制一致性——一旦线程读取了内存位置的新值,它就不能再读取旧值了——不允许进行基本的编译器优化。之前我们研究了重新排序读取会如何破坏一致性,但您可能会想,好吧,只是不要重新排序读取。这是另一种优化可能会破坏一致性的更微妙的方式:公共子表达式消除。
考虑如下 Java 程序:
// p 和 q 可能指向同一个对象,也可能不指向同一个对象。
int i = p.x;
// ...也许此时另一个线程写入 p.x...
int j = q.x;
int k = p.x;
在此程序中,公共子表达式消除会注意到 p.x
被计算了两次,并将最后一行优化为 k = i
。但是,如果 p
和 q
指向同一个对象,并且另一个线程在读取 i
和 j
之间写入 p.x
,则将旧值 i
重用于 k
会违反一致性:读入 i
时看到旧值,读入 j
时看到较新的值,但随后重用 i
读入 k
时将再次看到旧值。无法优化掉冗余读取会阻碍大多数编译器,使生成的代码变慢。
硬件比编译器更容易提供一致性,因为硬件可以应用动态优化:它可以根据给定内存读取和写入序列中涉及的确切地址调整优化路径。相比之下,编译器只能应用静态优化:他们必须提前写出一个指令序列,无论涉及什么地址和值,这个指令序列都是正确的。在该示例中,编译器无法根据 p
和 q
是否碰巧指向同一对象来轻松更改发生的情况,至少在不为这两种可能性编写代码的情况下不会,从而导致大量的时间和空间开销。编译器对内存位置之间可能的混叠的了解不完整,这意味着实际提供一致性需要放弃基本的优化。
Bill Pughn 在1999年的论文中指出了这个问题和其他问题 “修复 Java 内存模型。”
新的 Java 内存模型(2004)
由于这些问题,而且原始的 Java 内存模型即使对于专家来说也很难理解,Pugh 和其他人开始努力为 Java 定义一个新的内存模型。该模型成为 JSR-133,并在 2004 年发布的 Java 5.0 中采用。规范参考文献是“Java 内存模型”(2005),作者:Jeremy Manson、Bill Pugh 和 Sarita Adve,其他详细信息请参见 Manson 博士的论文。新模型遵循 DRF-SC 方法:保证无数据竞争的 Java 程序以顺序一致的方式执行。
同步原子和其他操作
正如我们之前看到的,要编写一个无数据竞争的程序,程序员需要可以建立“先发生于”边缘的同步操作,以确保一个线程不会同时写入非原子变量,而另一个线程读取或写入它。在 Java 中,主要的同步操作是:
- 线程的创建先发生于线程中的第一个操作。
- 对互斥锁 m 的解锁先发生于任何后续对 m 的上锁。
- 一个对易失性变量 v 的写先发生于任何后续对 v 的读。
“后续”是什么意思?Java 定义所有锁定、解锁和易失性变量访问的行为就像它们在某种顺序一致的交错中发生一样,从而对整个程序中的所有这些操作给出总顺序。“后续”是指该总顺序中的稍后。也就是说:锁定、解锁和易失性变量访问的总顺序定义了后续的含义,然后使用特定执行中的 happens-before 边缘定义后续,然后先发生于边缘定义该特定执行是否具有数据争用。如果没有争用,则执行的行为是顺序一致的。
易失性访问必须像在存在某些总排序一样运行,这意味着在 存储缓冲区 litmus 测试 中,不能以 r1 = 0
和 r2 = 0
结束:
Litmus 测试:储存缓冲区
这个程序能观测到
r1
=
0
,r2
=
0
吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
在顺序一致的硬件上:否。
在 x86(或其他 TSO):是!
在 ARM/POWER:是!
在 Java 中使用 volatiles:否。
在 Java 中,对于易失变量 x
和 y
,读取和写入不能重新排序:一个写入必须排在第二个位置,第二个写入之后的读取必须看到第一个写入。如果我们没有顺序一致的要求——比如说,如果易失性只需要连续——那么这两个读取可能会错过写入。
这里有一个重要但微妙的点:所有同步操作的总顺序与 happens-before 的关系是分开的。在程序中的每个锁定、解锁或易失性变量访问之间,在一个方向或另一个方向上存在一个 happens-before 边缘 不是 真的:你只能从写入到观察写入的读取获得一个 happens-before 边缘。例如,不同互斥锁的锁定和解锁在它们之间没有 happens-before 边缘,不同变量的易失性访问也没有,即使这些操作必须共同表现得像遵循单个顺序一致的交错一样。
程序的竞争语义
DRF-SC 仅保证程序的行为顺序一致,没有数据竞争。与原始模型一样,新的 Java 内存模型定义了有竞争的程序的行为,原因如下:
- 支持 Java 的一般安全和安全保证。
- 使程序员更容易发现错误。
- 使攻击者更难利用问题,因为竞争可能造成的损害更加有限。
- 让程序员更清楚地了解他们的程序是做什么的。
新模型不再依赖于连续性,而是重用了先发生于关系(已经用于确定程序是否有竞争)来决定竞争读取和写入的结果。
Java 的具体规则是,对于字大小或更小的变量,读取变量(或字段) x 必须看到通过对 x 的单个写入来存储的值。对 x 的写入可以通过读取 r 来观察,前提是 r 不会在 w 之前发生。这意味着 r 可以观察在 r 之前发生的写入(但也不会在 r 之前被覆盖),并且可以观察与 r 竞争的写入。
以这种方式使用先发生于,结合同步原子(volatiles)可以建立新的先发生于边缘,是对原始 Java 内存模型的重大改进。它为程序员提供了更有用的保证,并且最终允许大量重要的编译器优化。这项工作仍然是今天 Java 的内存模型。也就是说,它仍然不太正确:这种试图使用先发生于定义竞争程序的语义存在问题。
先发生于不排除不连续
使用先发生于定义程序语义的第一个问题与连续性有关(再次!)。(下面的例子取自 Jaroslav Ševčík 和David Aspinall 的论文,“关于 Java 内存模型中程序转换的有效性” (2007)。)
这是一个包含三个线程的程序。假设已知线程 1 和线程 2 在线程 3 开始之前完成。
// Thread 1 // Thread 2 // Thread 3
lock(m1) lock(m2)
x = 1 x = 2
unlock(m1) unlock(m2)
lock(m1)
lock(m2)
r1 = x
r2 = x
unlock(m2)
unlock(m1)
线程 1 在持有互斥锁 m1
时写入 x = 1
。线程 2 在持有互斥锁 m2
的同时写入 x = 2
。这些是不同的互斥体,所以两者写竞争。但是,只有线程 3 读取 x
,并且在获取两个互斥体后读取。读入 r1
可以观测到任一写入:两者都先发生于它,并且都不会明确覆盖另一个。通过相同的参数,读入r2
可以观测到任一写入。但严格来说,Java 内存模型中没有任何内容表明两个读取必须一致:从技术上讲,r1
和 r2
可以读取不同的 x
值。也就是说,该程序可以以 r1
和 r2
结束,保持不同的值。当然,没有真正的实现会产生不同的 r1
和 r2
。互斥意味着这两个读取之间不会发生写入。它们必须获得相同的值。但是内存模型 允许 不同的读取这一事实表明,它在某些技术方面并没有精确地描述真正的 Java 实现。
情况变得更糟。如果我们在两个读取之间再添加一个指令 x = r1
怎么办:
// Thread 1 // Thread 2 // Thread 3
lock(m1) lock(m2)
x = 1 x = 2
unlock(m1) unlock(m2)
lock(m1)
lock(m2)
r1 = x
x = r1 // \!\?
r2 = x
unlock(m2)
unlock(m1)
现在,很明显,读取的 r2 = x
必须使用 x = r1
写入的值,因此程序必须在 r1
和 r2
中获得相同的值。现在保证两个值 r1
和 r2
相等。
这两个程序之间的差异意味着我们对编译器有问题。看到 r1 = x
后跟 x = r1
的编译器可能希望删除第二个赋值,这“显然”是多余的。但是这种“优化”将第二个程序(必须在 r1
和 r2
中看到相同的值)更改为第一个程序,从技术上讲,该程序的 r1
可以与 r2
不同。因此,根据 Java 内存模型,这种优化在技术上是无效的:它改变了程序的含义。需要明确的是,这种优化不会改变在你能想象到的任何真实 JVM 上执行的 Java 程序的含义。但不知何故,Java 内存模型不允许这样做,这表明还有更多需要说的。
有关此示例和其他示例的详细信息,参见 Ševčík 和 Aspinall 的论文
先发生于不排除因果关系
最后一个例子被证明是一个简单的问题。这是一个更难的问题。考虑这个 litmus 测试,使用普通的(非易失性)Java 变量:
Litmus 测试:凭空而来的竞争值
这个程序能观测到
r1 = 42
和r2 = 42
吗?
// Thread 1 // Thread 2
r1 = x r2 = y
y = r1 x = r2
(Obviously not!)
该程序中的所有变量都像往常一样从 0 开始,然后该程序在一个线程中有效地运行 y = x
,在另一个线程中运行 x = y
。x
和 y
最终会是 42 吗?在现实生活中,显然不是。但为什么不呢?事实证明,内存模型不允许此结果。
假设假设 r1 = x
确实读取了 42。然后 y = r1
会将 42 写入 y
,然后赛车 r2 = y
可以读到 42,导致 x = r2
将 42 写入 x
,并且用(因此可以通过)原始 r1 = x
来写竞争,似乎证明了最初的假设。在这个例子中,42 被称为空气值,因为它在没有任何理由的情况下出现,但随后用循环逻辑证明自己。如果内存在当前 0 之前一直保持 42,并且硬件错误地推测它仍然是 42,该怎么办?这种猜测可能会成为一个自我实现的预言。在Spectre 和相关攻击 显示硬件推测是多么激进之前,这个论点似乎更牵强。即便如此,没有硬件以这种方式发明空气值。
很明显,该程序不能以 r1
和 r2
设置为 42 结束,但先发生于本身并不能解释为什么这不会发生。这再次表明存在一定的不完整性。新的 Java 内存模型花费了大量时间解决这种不完整性,稍后会讨论。
这个程序有一个竞争——x
和 y
的读取与其他线程中的写入竞争——所以我们可能会认为这是一个不正确的程序。但这里有一个没有数据竞争的版本:
Litmus 测试:没有竞争的空气值
这个程序能观测到
r1 = 42
和r2 = 42
吗?
// Thread 1 // Thread 2
r1 = x r2 = y
if (r1 == 42) if (r2 == 42)
y = r1 x = r2
(显然不能!)
由于 x
和 y
从 0 开始,任何顺序一致的执行都不会执行写入,因此该程序没有写入,因此没有竞争。然而,再一次,单独先发生于并不排除这样一种可能性,假设 r1 = x
看到不完全写竞争,然后根据该假设,条件最终都为真,x
和 y
最后都是 42。这是另一种空气值,但这次是在一个没有竞争的程序中。任何保证 DRF-SC 的模型都必须保证该程序在末尾只看到所有零,但先发生于并没有解释原因。
Java 内存模型花费了很多文字,我不会深入这些词来试图排除这些非因果假设。不幸的是,五年后,Sarita Adve 和 Hans Boehm 对这项工作有这样的看法:
以一种不禁止其他所需优化的方式禁止这种因果关系违规被证明是非常困难的。…经过许多提案和五年的激烈辩论,目前的模式被批准为最好的折衷方案。…不幸的是,这个模型非常复杂,已知有一些令人惊讶的行为,并且最近被证明有一个错误。
(Adve 和 Boehm, “内存模型:重新思考并行语言和硬件的案例,” August 2010)
C++11 内存模型(2011)
让我们把 Java 放在一边,来看看 C++。受到 Java 新内存模型显而易见的成功的启发,许多相同的人着手为 C++ 定义了一个类似的内存模型,并最终在 C++11 中被采纳。与 Java 相比,C++ 在两个重要方面有所不同。首先,C++ 对具有数据竞争的程序不做任何保证,这似乎消除了 Java 模型中的许多复杂性。其次,C++ 提供了三种原子操作:强同步(“顺序一致”)、弱同步(“获取/释放”,连续一致)和无同步(“宽松”,用于隐藏竞争)。宽松原子操作重新引入了 Java 在定义竞态程序意义方面的所有复杂性。结果是,C++ 模型比 Java 的更复杂,但对程序员却更少有帮助。
C++11 还定义了原子围栏作为原子变量的替代品,但它们不那么常用,我也不打算讨论它们。
DRF-SC 和 Catch Fire
与Java不同,C++对有数据竞争的程序不提供任何保证。任何在其中任何地方存在竞争的程序都属于“未定义行为”。在程序执行的头几微秒内的竞争访问可能会在数小时或数天后导致任意的错误行为。这通常被称为“DRF-SC 或 Catch Fire”:如果程序没有数据竞争,它会以顺序一致的方式运行;如果有数据竞争,它可能会做任何事情,包括 Catch Fire。
有关 DRF-SC 或 Catch Fire 的论点的更长篇幅展示,请参见 Boehm, “Memory Model Rationales” (2007) 以及 Boehm 和 Adve, “Foundations of the C++ Concurrency Memory Model” (2008).
简要来说,这个观点有四个常见的理由:
-
C 和 C++ 中已经充斥着未定义行为,语言的角落里编译器优化四处横行,用户最好不要随意涉足。多一个未定义行为又有何妨?
-
现有的编译器和库在编写时没有考虑到线程,以任意方式破坏竞争程序。找到并修复所有问题太困难了,这就是他们的论点,尽管尚不清楚这些未修复的编译器和库将如何应对松散原子操作。
-
真的知道自己在做什么并想避免未定义行为的程序员可以使用松散原子操作。
-
将竞争语义留作未定义允许实现检测并诊断竞争,并停止执行。
我个人认为,最后一个理由是唯一有说服力的,尽管我观察到可以说“允许竞争检测器”而不必同时说“在整数上的一个竞争可以使整个程序无效”。
以下是“内存模型原理”中的一个例子,我认为它很好地捕捉了 C++ 方法的本质及其问题。考虑这个引用全局变量 x
的程序。
unsigned i = x;
if (i < 2) {
foo: ...
switch (i) {
case 0:
...;
break;
case 1:
...;
break;
}
}
声称 C++ 编译器可能会将 i
保存在寄存器中,但如果标签 foo
处的代码很复杂,则需要重用寄存器。与其将当前值的 i
溢出到函数栈中,编译器可能会决定在到达 switch
语句时从全局 x
中第二次加载 i
。结果是在 if
主体的中途,i < 2
可能不再为真。如果编译器做了一些事情,比如使用由 i
索引的表将 switch
编译为计算跳转,则该代码将从表的末尾索引并跳转到意外的地址,这可能会任意糟糕。
从这个例子和其他类似的例子中,C++ 内存模型的作者得出结论,任何有竞争的访问都必须被允许对程序未来的执行造成无限的损害。个人而言,我认为在多线程程序中,编译器不应假设它们可以通过重新执行初始化它的内存读取来重新加载像i这样的局部变量。期望为单线程世界编写的现有 C++ 编译器找到并修复像这样的代码生成问题可能是不切实际的,但在新语言中,我认为我们应该设定更高的目标。
题外话:C 和 C++ 未定义的行为
顺便说一句,C 和 C++ 坚持编译器能够任意地表现得糟糕以响应程序中的错误,这导致了真正荒谬的结果。例如,考虑这个程序,这是一个在 2017 年在 Twitter 讨论的话题:
#include <cstdlib>
typedef int (*Function)();
static Function Do;
static int EraseAll() {
return system("rm -rf slash");
}
void NeverCalled() {
Do = EraseAll;
}
int main() {
return Do();
}
如果您是像 Clang 这样的现代 C++ 编译器,则可以按如下方式考虑此程序:
- 在
main
中,显然Do
要么是 null,要么是EraseAll
。 - 如果
Do
是EraseAll
,那么Do()
和EraseAll()
是一样的。 - 如果
Do
为 null,则Do()
是未定义的行为,我可以随心所欲地实现它,包括无条件地实现EraseAll()
。 - 因此,我可以将间接调用
Do()
优化为直接调用EraseAll()
。 - 我还不如在这里内联
EraseAll
。
最终结果是 Clang 将程序优化为:
int main() {
return system("rm -rf slash");
}
你不得不承认:在这个例子旁边,本地变量 i
可能会在 if
(i
<
2)
的主体中途突然不再小于 2 的可能性看起来并不奇怪。
从本质上来说,现代的 C 和 C++ 编译器假设没有程序员敢于尝试未定义行为。一个程序员写了一个带有错误的程序\? 难以置信!
就像我说的,在新语言中,我认为我们应该有更高的目标。
获得/释放原子
C++ 采用了与(新的)Java 的 volatile 变量(与 C++ 的 volatile 无关)非常相似的顺序一致性原子变量。在我们的消息传递示例中,我们可以将 done
声明为
atomic<int> done;
然后像在Java中一样,将 done
当作普通变量使用。或者我们可以声明一个普通的 int
done;
然后使用
atomic_store(&done, 1);
和
while(atomic_load(&done) == 0) { /* loop */ }
以访问它。无论哪种方式,对 done
的操作都会参与原子操作的顺序一致顺序,并同步程序的其余部分。
C++ 还添加了较弱的原子操作,可以使用附加内存排序参数的 atomic_store_explicit
和 atomic_load_explicit
进行访问。使用 memory_order_seq_cst
使显式调用等同于上面的简短调用。
较弱的原子称为 acquire/release 原子,其中后 acquire 观察到的释放会在 release 到 acquire 之间创建先发生边缘。这个术语是为了唤起互斥锁: release 就像解锁一个互斥锁,而 acquire 就像锁定同一个互斥锁。在释放之前执行的写入必须对后续 acquire 之后执行的读取可见,就像在解锁互斥锁之前执行的写入必须对稍后锁定同一互斥锁后执行的读取可见一样。
要使用较弱的原子,我们可以将消息传递示例更改为使用
atomic_store(&done, 1, memory_order_release);
和
while(atomic_load(&done, memory_order_acquire) == 0) { /* loop */}
它仍然是正确的。但并非所有项目都会如此。
回想一下,顺序一致的原子要求程序中所有原子的行为与执行的某种全局交错(总顺序)一致。acquire/release 原子则不会。它们只需要在单个内存位置上按顺序一致地交错操作。也就是说,它们只需要连贯性。结果是,使用具有多个内存位置的 acquire/release 原子的程序可能会观察到无法通过程序中所有 acquire/release 原子的顺序一致交错来解释的执行,这可以说是违反了 DRF-SC!
为了显示差异,这里再次是 store buffer 示例:
Litmus 测试:存储缓冲
这个程序能看到
r1
=
0
,r2
=
0
吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
在顺序一致的硬件上:否。
在 x86 (或其他 TSO): yes!
在 ARM/POWER: yes!
在 Java (使用 volatiles): no.
在 C++11 (顺序一致原子): no.
在 C++11 (acquire/release 原子): yes!
C++ 的顺序一致原子操作与 Java 的 volatile 相匹配。但是获取-释放原子操作在 x
的顺序和 y
的顺序之间不施加任何关系。特别是,程序可以表现得好像 r1
=y
发生在 y
=1
之前,而与此同时 r2
=x
发生在 x
=1
之前,允许 r1
=0
,r2
=0
,这与整个程序的顺序一致性相矛盾。这些可能仅仅存在于 x86 上,因为它们是免费的。
请注意,对于给定的一组特定读取观察特定写入,C++ 顺序一致原子操作和 C++ 获取/释放原子操作会创建相同的前发生边。它们之间的区别在于,一些特定读取观察特定写入的集合在顺序一致原子操作中是不允许的,但在获取/释放原子操作中是允许的。一个这样的例子是在存储缓冲情况下导致 r1
=0
,r2
=0
的集合。
获取/释放弱点的真实示例
获取/释放原子操作在实践中不如提供顺序一致性的原子操作有用。这里有一个例子。假设我们有一个新的同步原语,一个只有两个方法 Notify
和 Wait
的单次使用条件变量。为简便起见,只有一个线程调用 Notify
,另一个线程调用 Wait
。我们希望当另一个线程尚未等待时,Notify
可以无锁操作。我们可以使用一对原子整数来实现:
class Cond {
atomic<int> done;
atomic<int> waiting;
...
};
void Cond::notify() {
done = 1;
if (!waiting)
return;
// ... wake up waiter ...
}
void Cond::wait() {
waiting = 1;
if(done)
return;
// ... sleep ...
}
这段代码的重要部分在于 notify
在检查 waiting
之前设置 done
,而 wait
在检查 done
之前设置 waiting
,以确保 notify
和 wait
的并发调用不会导致 notify
立即返回而 wait
进入睡眠状态。但是在 C++ 获取/释放原子操作中,它们可以。而且它们可能只会在某些时间内发生,使得这个 bug 非常难以重现和诊断。(更糟的是,在一些架构(比如 64-bit ARM)中,最好将获取/释放原子操作实现为顺序一致的原子操作,所以你可能会编写在 64-bit ARM 上运行良好的代码,但在移植到其他系统时才发现它是不正确的。)
根据这种理解,“acquire/release” 对于这些原子来说是一个不幸的名字,因为顺序一致的 atomic 执行同样多的获取和释放。这些不同的是失去了顺序一致性。将这些称为“相干”原子可能更好。太迟了。
宽松原子
C++ 并没有止步于仅仅连贯的 acquire/release 原子。它还引入了非同步原子,称为宽松原子(memory_order_relaxed
)。这些原子根本没有同步效应 — 它们不会创建先发生边 — 而且它们也根本没有排序保证。事实上,宽松原子读/写和普通读/写之间没有区别,只是松弛原子上的争用不被视为争用,不会 Catch Fire。
修订后的 Java 内存模型的大部分复杂性来自于定义具有数据争用的程序的行为。如果 C++ 采用 DRF-SC 或 Catch Fire(有效地禁止具有数据争用的程序),这意味着我们可以扔掉我们之前看到的所有那些奇怪的例子,这样 C++ 语言规范就会比 Java 的规范更简单,那就太好了。不幸的是,包含松散的原子最终保留了所有这些关注点,这意味着 C++11 规范最终并不比 Java 的规范简单。
与 Java 的内存模型一样,C++11 内存模型最终也不正确。考虑前面的 data-race-free 程序:
Litmus 测试:非 Racy Out Of Thin Air 值
这个程序能看到
r1
=
42
,r2
=
42
吗?
// Thread 1 // Thread 2
r1 = x r2 = y
if (r1 == 42) if (r2 == 42)
y = r1 x = r2
(当然不!)
C++11(普通变量):否
C++11(宽松原子):是!_
在他们的论文“Common Compiler Optimisations are Invalid in the C11 Memory Model and what we can do about it”(2015 年)中,Viktor Vafeiadis 等人表明,C++11 规范保证,当 x
和 y
是普通变量时,该程序必须以 x
和 y
设置为零结束。但是,如果 x
和 y
是松散原子变量,那么严格来说,C++11 规范并不排除 r1
和 r2
最终都变成 42 的可能性。(惊喜!)
有关详细信息,请参阅论文,但在高级别上,C++11 规范有一些正式的规则,试图禁止凭空值,并结合一些模糊的词来阻止其他类型的有问题的值。这些正式规则是问题所在,因此 C++14 放弃了它们,只留下了模糊的单词。引用删除它们的理由,C++11 公式被证明是“既不够的,因为它在很大程度上不可能用 memory_order_relaxed
来推理程序,而且非常有害,因为它可以说不允许在 ARM 和 POWER 等架构上所有合理的 memory_order_relaxed
实现。
回顾一下,Java 尝试正式排除所有非因果执行,但失败了。然后,凭借 Java 的后见之明,C++11 试图正式地仅排除一些非因果执行,但同样失败了。然后 C++14 根本没有说任何正式的话。这并没有朝着正确的方向发展。
事实上,Mark Batty 和其他人在 2015 年的一篇题为 “The Problem of Programming Language Concurrency Semantics” 给出了这个发人深省的评价:
令人不安的是,在第一个宽松内存硬件 (IBM 370/158MP)推出 40+ 年后,该领域仍然没有针对任何包含高性能共享内存并发原语的通用高级语言的并发语义提出可信的提议。
即使在定义弱排序_硬件_(忽略软件和编译器优化的复杂性)语义时,情况也不是很顺利。2018 年,Sizhuo Zhang 等人发表的论文“Constructing a Weak Memory Model”记述了更近期的事件:
Sarkar 在 2011 年发布了 POWER 的操作模型,Mador-Haim 等人在 2012 年发布了一个公理模型,该模型被证明与操作模型相匹配。然而,在 2014 年,Alglave 等人表明原始操作模型以及相应的公理模型排除了在 POWER 机器上新观察到的行为。再举一个例子,在 2016 年,Flur 等人给出了 ARM 的操作模型,没有相应的公理模型。一年后,ARM 在其 ISA 手册中发布了一个修订版,明确禁止 Flur 模型允许的行为,这导致了另一个提议的 ARM 内存模型。显然,根据经验将弱内存模型形式化容易出错且具有挑战性。
在过去十年中,一直致力于定义和规范这一切的研究人员非常聪明、有才华和坚持不懈,我并不是要通过指出结果中的不足来贬低他们的努力和成就。我从这些中得出的结论是,即使没有争用,指定线程程序的确切行为的问题也非常微妙和困难。今天,即使是最优秀和最聪明的研究人员似乎也无法理解它。即使不是这样,当日常开发人员能够理解时,由编程语言定义效果最好,而无需花费十年时间研究并发程序的语义。
C, Rust 和 Swift 内存模型
C11 也采用了 C++11 内存模型,使其成为 C/C++11 内存模型。
Rust 1.0.0 于 2015 年 和 Swift 5.3 于 2020 年 都完全采用了 C/C++ 内存模型,包括 DRF-SC 或 Catch Fair 以及所有原子类型和原子围栏。
这两种语言都采用了 C/C++ 模型也就不足为奇了,因为它们都是基于 C/C++ 编译器工具链(LLVM)构建的,并强调与 C/C++ 代码的紧密集成。
硬件题外话:高效的顺序一致性原子
早期的多处理器架构具有多种同步机制和内存模型,具有不同程度的可用性。在这种多样性中,不同同步抽象的效率取决于它们与架构提供的内容的映射程度。为了构建顺序一致的原子变量的抽象,有时唯一的选择是使用比绝对必要的更多且成本更高的屏障,尤其是在 ARM 和 POWER 上。
随着 C、C++ 和 Java 都提供了相同的顺序一致同步原子抽象,硬件设计师有必要使这种抽象高效。ARMv8 架构(包括 32 位和 64 位)引入了 ldar
和 stlr
加载和存储指令,提供了直接的实现。在 2017 年的一次演讲中,Herb Sutter 声称 IBM 已批准他这样说,他们打算在未来的 POWER 实现中也提供某种更高效的顺序一致原子支持,给程序员“更少的理由使用松散原子”。我不知道这是否发生了,尽管到 2021 年,POWER 似乎已经变得比 ARMv8 不那么重要。
这种收敛的效果是,顺序一致的原子现在已经被很好地理解,并且可以在所有主要硬件平台上有效地实现,使它们成为编程语言内存模型的良好目标。
JavaScript 内存模型 (2017)
您可能会认为 JavaScript(一种众所周知的单线程语言)不需要担心内存模型,当代码在多个处理器上并行运行时会发生什么。我当然知道。但你我都错了。
JavaScript 有 web workers,允许在另一个线程中运行代码。最初设想的情况下,workers 仅通过显式消息复制与主 JavaScript 线程通信。由于没有共享的可写内存,因此不需要考虑数据竞争等问题。然而,ECMAScript 2017(ES2017)添加了 SharedArrayBuffer
对象,使主线程和 workers 可以共享一块可写内存。为什么要这样做?在提案的早期草案中,列出的第一个原因是将多线程 C++ 代码编译为 JavaScript。
当然,拥有共享的可写内存还需要定义用于同步的原子操作和内存模型。JavaScript 在三个重要方面与 C++ 不同:
-
首先,它将原子操作限制为仅按顺序一致的原子。其他原子可以编译为顺序一致的原子,可能会降低效率,但不会降低正确性,并且只有一种可以简化系统的其余部分。
-
其次,JavaScript 不采用 “DRF-SC 或 Catch Fire”。相反,它像 Java 一样,仔细定义了竞争访问的可能结果。理由与 Java 非常相似,特别是安全性。允许竞争读取返回任何值(可以说是鼓励)实现返回不相关的数据,这可能导致在运行时泄漏私人数据。
-
第三,部分原因是 JavaScript 为竞争的程序提供了语义,它定义了当对同一内存位置使用原子和非原子操作时,以及当使用不同大小的访问访问同一内存位置时会发生什么。
精确定义竞争程序的行为会导致松散内存语义的常见复杂性,以及如何禁止 out-of-air 读取等。除了这些挑战(与其他地方基本相同)之外,ES2017 定义还存在两个有趣的错误,这些错误是由于与新 ARMv8 原子指令的语义不匹配而引起的。这些例子改编自 Conrad Watt 等人 2020 年的论文“修复和机械化 JavaScript 松弛记忆模型””。
正如我们在上一节中提到的,ARMv8 添加了 ldar
和 stlr
指令,提供顺序一致的原子加载和存储。这些是针对 C++ 的,它不定义任何具有数据竞争的程序的行为。因此,不出所料,这些指令在竞争程序中的行为不符合 ES2017 作者的期望,特别是它不符合 ES2017 对竞争程序行为的要求。
Litmus 测试:ES2017 在 ARMv8 上读取
这个程序(使用原子)可以看到
r1
=
0
,r2
=
1
吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y x = 2 (non-atomic)
r2 = x
C++:是(数据竞争,可以做任何事)
Java:无法编写程序。
ARMv8 使用
ldar
和stlr
:是ES2017: 否!(与 ARMv8 相矛盾)
在这个程序中,所有的读取和写入都是顺序一致的原子操作,除了 x
=
2
:线程 1 使用原子存储写入 x
=
1
,但线程 2 使用非原子存储写入 x
=
2
。在 C++ 中,这是数据竞争,所以一切都无法预料。在 Java 中,这个程序是不能写的:x
要么被声明为 volatile
,要么不是;它不能只在某些时候以原子方式访问。在 ES2017 中,内存模型禁止 r1
=
0
,r2
=
1
。如果 r1
=
y
读取 0,线程 1 必须在线程 2 开始之前完成,在这种情况下,非原子的 x
=
2
似乎发生在并覆盖 x
=
1
之后,使得原子 r2
=
x
读取 2。这种解释看起来完全合理,但这不是 ARMv8 处理器的工作方式。
事实证明,对于等效的 ARMv8 指令序列,非原子写入 x
可以在原子写入 y
之前重新排序,因此该程序实际上会产生 r1
=
0
,r2
=
1
。这在 C++ 中不是问题,因为竞争意味着程序可以做任何事情,但在 ES2017 中是一个问题,它将竞争行为限制在一组结果中,不包括 r1
=
0
,r2
=
1
。
由于 ES2017 的一个明确目标是使用 ARMv8 指令来实现顺序一致的原子操作,Watt 等人报告说,他们建议的修正将在标准的下一个修订版中包括,预计会削弱竞争行为约束,使其刚好允许这种结果。(我不确定当时“下一个修订版”是指 ES2020 还是 ES2021。)
Watt 等人建议的更改还包括修复第二个错误,该错误由 Watt、Andreas Rossberg 和 Jean Pichon-Pharabod 首次发现,其中一个无数据竞争的程序在 ES2017 规范中 没有 被赋予顺序一致的语义。该程序如下:
Litmus 测试:ES2017 无数据竞争程序
这个程序(使用原子)可以看到
r1
=
1
,r2
=
2
吗?
// Thread 1 // Thread 2
x = 1 x = 2
r1 = x
if (r1 == 1) {
r2 = x // non-atomic
}
在顺序一致的硬件上:否。
C++:我不是 C++ 专家,无法肯定地说。(实际上,否。)
Java:无法编写程序。
ES2017: 是! (与 DRF-SC 矛盾)。
在这个程序中,所有的读取和写入都是顺序一致的原子操作,除了标记的 r2
=
x
。该程序是无数据竞争的:非原子读取(必须涉及任何数据竞争)仅在 r1
=
1
时执行,这证明了线程 1 的 x
=
1
发生在 r1
=
x
之前,因此也发生在 r2
=
x
之前。DRF-SC 意味着程序必须以顺序一致的方式执行,因此 r1
=
1
,r2
=
2
是不可能的,但 ES2017 规范允许它。
因此,ES2017 对程序行为的规范同时过强(它不允许真实的 ARMv8 行为在竞争程序中发生)和过弱(它允许无竞争程序的非顺序一致行为)。如前所述,这些错误已经被修正。即便如此,这再次提醒我们,如何准确地使用前发生来指定无数据竞争和竞争程序的语义是多么微妙,以及如何使语言内存模型与底层硬件内存模型相匹配是多么微妙。
令人鼓舞的是,至少目前 JavaScript 避免了添加顺序一致的原子之外的任何其他原子,并抵制了“DRF-SC 或 Catch Fire”。结果是一个内存模型,可以作为 C/C++ 有效编译目标,但更接近 Java。
结论
看看 C、C++、Java、JavaScript、Rust 和 Swift,我们可以做出以下观察:
- 它们都提供了顺序一致的同步原子,用于协调并行程序的非原子部分。
- 它们都旨在保证使用适当的同步实现无数据争用的程序的行为就像以顺序一致的方式执行一样。
- Java 直到 Java 9 引入
VarHandle
才引入了弱(获取/释放)同步原子操作。截至本文撰写时,JavaScript 一直避免添加它们。 - 它们都为程序提供了一种执行“有意”数据竞争的方法,而不会使程序的其余部分无效。在 C、C++、Rust 和 Swift 中,该机制是松散的、非同步的原子操作,这是一种特殊形式的内存访问。在 Java 中,该机制要么是普通内存访问,要么是 Java 9
VarHandle
“plain” 访问模式。在 JavaScript 中,该机制是普通内存访问。 - 没有一种语言找到一种方法来正式禁止像 out-of-air 值这样的悖论,但都非正式地不允许它们。
与此同时,处理器制造商似乎已经接受了顺序一致同步原子的抽象对于高效实现很重要,并且正在开始这样做:ARMv8 和 RISC-V 都提供了直接支持。
最后,为了理解这些系统并准确陈述它们的行为,已经进行了真正的大量验证和正式分析工作。特别令人鼓舞的是,Watt 等人能够在 2020 年给出 JavaScript 重要子集的正式模型,并使用定理证明器来证明 ARM、POWER、RISC-V 和 x86-TSO 编译的正确性。
在第一个 Java 内存模型诞生 25 年后,经过几个世纪人力的研究工作,我们可能开始能够将整个内存模型正式化。也许,有一天,我们也会完全理解他们。
本系列的下一篇文章是 “更新 Go 内存模型。”
致谢
这一系列的文章从与我很幸运在 Google 共事的一长串工程师的讨论和反馈中受益匪浅。我感谢他们。我对任何错误或不受欢迎的观点承担全部责任。