[Code翻译]程序设计语言的内存模型

196 阅读46分钟

原文地址:research.swtch.com/plmm

原文作者:link.juejin.cn/?target=htt…

发布时间:2021年7月6日

(内存模型,第2部分)

编程语言的内存模型回答了并行程序可以依靠什么行为在其线程之间共享内存的问题。例如,考虑这个用类似C语言编写的程序,其中x和done一开始都是零。

// Thread 1           // Thread 2
x = 1;                while(done == 0) { /* loop */ }
done = 1;             print(x);

该程序试图将x中的消息从线程1发送到线程2,将doed作为消息准备被接收的信号。如果线程1和线程2,各自运行在自己的专用处理器上,都运行到完成,这个程序是否能保证完成并打印出1,如预期的那样?编程语言的内存模型回答了这个问题和其他类似问题。

尽管每种编程语言在细节上有所不同,但有几个一般性的答案基本上适用于所有现代多线程语言,包括C、C++、Go、Java、JavaScript、Rust和Swift。

  • 首先,如果x和done是普通变量,那么线程2的循环可能永远不会停止。一个常见的编译器优化是在第一次使用时将一个变量加载到一个寄存器中,然后在未来访问该变量时尽可能地重复使用该寄存器。如果线程2在线程1执行之前就将doed复制到一个寄存器中,它可能会在整个循环中一直使用该寄存器,而不会注意到线程1后来修改了doed。
  • 第二,即使线程2的循环停止了,观察到doed == 1,它仍然可能打印出x是0。编译器经常根据优化启发式方法或甚至只是在生成代码时穿越哈希表或其他中间数据结构的方式来重新安排程序的读和写。线程1的编译代码可能最终在完成后而不是在完成前写入x,或者线程2的编译代码可能最终在循环前读取x。

鉴于这个程序是多么的破败,明显的问题是如何修复它。

现代语言提供了特殊的功能,以原子变量或原子操作的形式,允许程序同步其线程。如果我们把doed变成一个原子变量(或者用原子操作来操作它,在采用这种方法的语言中),那么我们的程序就能保证完成并打印1。让doed成为原子变量有很多影响。

  • 线程1的编译代码必须确保对x的写入已经完成,并且在对doed的写入变得可见之前对其他线程可见。
  • 线程2的编译代码必须在循环的每个迭代中(重新)读取done。
  • 线程2的编译代码必须在从doed读出后从x中读出。
  • 编译后的代码必须做任何必要的事情,以禁用可能重新引入任何这些问题的硬件优化。

使done成为原子的最终结果是,程序的行为符合我们的要求,成功地将x中的值从线程1传递到线程2。

在原来的程序中,经过编译器的代码重排,线程1可能在线程2读取x的同一时刻写入x。这是一个数据竞赛。在修改后的程序中,原子变量done的作用是同步访问x:现在线程1不可能在线程2读取x的同时写入x。该程序是无数据种族的。一般来说,现代语言保证无数据链的程序总是以顺序一致的方式执行,就像来自不同线程的操作被任意地交错在一个处理器上,但没有重新排序。这是硬件内存模型中的DRF-SC属性,在编程语言中被采用。

顺便说一句,这些原子变量或原子操作被称为 "同步原子 "更为恰当。诚然,这些操作在数据库的意义上是原子的,允许同时读和写,其行为就像按某种顺序运行一样:在普通变量上的竞赛,在使用原子操作时就不是竞赛了。但更重要的是,原子学能够同步程序的其他部分,提供一种方法来消除非原子数据的竞赛。标准的术语是普通的 "原子",所以这篇文章就用这个术语。除非另有说明,否则请记住将 "原子 "理解为 "同步原子"。

编程语言的内存模型规定了程序员和编译器所需的确切细节,作为他们之间的契约。上面概述的一般特征基本上适用于所有的现代语言,但只是在最近才趋于一致:在21世纪初,有明显更多的变化。即使在今天,不同的语言在二阶问题上也有很大的差异,包括。

  • 原子变量本身的排序保证是什么?
  • 一个变量可以被原子和非原子操作所访问吗?
  • 除了原子操作,还有没有同步机制?
  • 是否有不同步的原子操作?
  • 有冲突的程序是否有任何保证?

在做了一些准备工作之后,这篇文章的其余部分将研究不同的语言是如何回答这些问题和相关问题的,以及它们达到这个目的的路径。这篇文章还强调了一路上的许多错误的开始,以强调我们仍然在学习什么是有效的,什么是无效的。

硬件、试金石测试、之前发生的事情和DRF-SC

在我们讨论任何特定语言的细节之前,简要总结一下我们需要记住的硬件内存模型的教训。

不同的架构允许不同数量的指令重排,因此,在多个处理器上并行运行的代码会根据架构的不同而产生不同的允许结果。黄金标准是顺序一致性,在这种情况下,任何执行都必须表现得像在不同处理器上执行的程序只是按某种顺序交错到一个处理器上。这种模式对开发者来说更容易推理,但是今天没有一个重要的架构提供这种模式,因为较弱的保证会带来性能的提升。

比较不同的内存模型,很难做出完全通用的声明。相反,关注特定的测试案例会有帮助,这被称为试金石测试。如果两个内存模型在特定的试金石测试中允许不同的行为,这就证明它们是不同的,通常可以帮助我们看到,至少对于该测试案例,一个比另一个更弱或更强。例如,这里是我们前面检查的程序的试金石测试形式。

Litmus Test: Message Passing
Can this program see r1 = 1, r2 = 0?

// Thread 1           // Thread 2
x = 1                 r1 = y
y = 1                 r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: yes!
In any modern compiled language using ordinary variables: yes!

和上一篇文章一样,我们假设每个例子开始时都将所有共享变量设置为零。名称rN表示私有存储,如寄存器或函数本地变量;其他名称如x和y是不同的、共享(全局)变量。我们问的是,在执行结束时,寄存器的特定设置是否可能。在回答硬件的试金石测试时,我们假设没有编译器来重新安排线程中发生的事情:列表中的指令被直接翻译成汇编指令,交给处理器执行。

结果r1=1,r2=0对应于原始程序的线程2完成了它的循环(完成了是y),但随后打印了0。对于汇编语言版本来说,在x86上打印0是不可能的,尽管由于处理器本身的重排序优化,在ARM和POWER等更宽松的架构上是可能的。在现代语言中,无论底层硬件如何,在编译过程中可能发生的重排序使这种结果成为可能。

正如我们前面提到的,今天的处理器并不保证顺序一致性,而是保证一种叫做 "无数据链的顺序一致性 "的属性,即DRF-SC(有时也写成SC-DRF)。一个保证DRF-SC的系统必须定义特定的指令,称为同步指令,它提供了一种协调不同处理器(相当于线程)的方法。程序使用这些指令在一个处理器上运行的代码和另一个处理器上运行的代码之间建立一种 "先发生 "的关系。

例如,这里描述的是一个程序在两个线程上的简短执行;像往常一样,每个线程都被假定在自己的专用处理器上。

image.png

我们在上一篇文章中也看到了这个程序。线程1和线程2执行一个同步指令S(a)。在这个特定的程序执行中,两条S(a)指令建立了线程1到线程2之间的先发生关系,所以线程1的W(x)比线程2的R(x)先发生。

不同处理器上的两个事件,如果不按happen-before排序,可能在同一时刻发生:确切的顺序不清楚。我们说它们是同时执行的。数据竞赛是指对一个变量的写与对同一变量的读或另一个写同时执行。提供DRF-SC的处理器(现在所有的处理器)保证没有数据竞赛的程序表现得如同在一个顺序一致的架构上运行。这是一个基本的保证,使得在现代处理器上编写正确的多线程汇编程序成为可能。

正如我们前面所看到的,DRF-SC也是现代语言所采用的基本保证,使其有可能在高级语言中编写正确的多线程程序。

编译器和优化

我们曾几次提到,编译器在生成最终可执行代码的过程中,可能会重新排列输入程序中的操作。让我们仔细看看这个说法以及其他可能导致问题的优化。

人们普遍认为,编译器可以几乎任意地重新安排普通的内存读写,只要这种重新安排不能改变代码的单线程执行情况。例如,考虑这个程序。

w = 1
x = 2
r1 = y
r2 = z

由于w、x、y和z都是不同的变量,这四个语句可以按照编译器认为最好的顺序执行。

正如我们在上面指出的,如此自由地重新安排读写顺序的能力使得普通编译程序的保证至少与 ARM/POWER 宽松内存模型一样弱,因为编译程序未能通过消息传递的试金石测试。事实上,编译程序的保证更弱。

在硬件文章中,我们将一致性作为 ARM/POWER 体系结构所保证的一个例子来看待。

Litmus Test: Coherence
Can this program see r1 = 1, r2 = 2, r3 = 2, r4 = 1?
(Can Thread 3 see x = 1 before x = 2 while Thread 4 sees the reverse?)

// Thread 1    // Thread 2    // Thread 3    // Thread 4
x = 1          x = 2          r1 = x         r3 = x
                              r2 = x         r4 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): no.
On ARM/POWER: no.
In any modern compiled language using ordinary variables: yes!

所有现代硬件都保证一致性,这也可以看作是对单个内存位置的操作的顺序一致性。在这个程序中,其中一个写操作必须覆盖另一个,整个系统必须同意哪个是哪个。事实证明,由于编译过程中的程序重排,现代语言甚至没有提供一致性。

假设编译器对线程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为假时,x与另一个线程共享,不是的:该优化会在x上引入原程序中不存在的竞赛。

这个例子来自于Hans Boehm在2004年发表的论文《线程不能作为一个库来实现》,其中提出了语言不能对多线程执行的语义保持沉默的观点。

编程语言的内存模型就是试图精确地回答这些关于哪些优化是允许的,哪些是不允许的问题。通过研究过去几十年来编写这些模型的尝试历史,我们可以了解哪些是有效的,哪些是无效的,并了解事情的发展方向。

原始的Java内存模型(1996)

Java是第一个尝试写下它保证多线程程序的主流语言。它包括了mutexes,并定义了它们所暗示的内存排序要求。它还包括了 "易失性 "原子变量:易失性变量的所有读写都需要直接在主内存中按程序顺序执行,使易失性变量上的操作以顺序一致的方式表现。最后,Java还规定了(或至少试图规定)有数据竞赛的程序的行为。其中的一部分是为普通变量规定了一种一致性的形式,我们将在下文中进一步研究。不幸的是,在第一版的《Java语言规范》(1996)中,这种尝试至少有两个严重的缺陷。事后看,用我们已经定下的预案,这些缺陷很容易解释。在当时,它们远没有那么明显。

原子需要同步化

第一个缺陷是,易失性原子变量是非同步的,所以它们无助于消除程序其余部分的竞赛。我们上面看到的消息传递程序的Java版本将是

int x;
volatile int done;

// Thread 1           // Thread 2
x = 1;                while(done == 0) { /* loop */ }
done = 1;             print(x);

因为doed被声明为volatile,所以循环被保证完成:编译器不能将其缓存在寄存器中,从而导致无限循环。然而,该程序不能保证打印1。编译器没有被禁止对x和doed的访问进行重新排序,也没有被要求禁止硬件做同样的事情。

因为Java volatiles是非同步原子,你不能用它们来构建新的同步原语。在这个意义上,最初的Java内存模型太弱了。

相干性与编译器的优化不兼容

最初的Java内存模型也太强大了:强制要求一致性--一旦线程读取了一个内存位置的新值,它就不能再出现读取旧值的情况--这使得基本的编译器优化无法进行。前面我们看了重排读数会如何破坏一致性,但你可能会想,好吧,就不要重排读数了。这里有一种更微妙的方式,一致性可能被另一种优化破坏:普通子表达式消除。

考虑一下这个Java程序。

// p and q may or may not point at the same object.
int i = p.x;
// ... maybe another thread writes p.x at this point ...
int j = q.x;
int k = p.x;

但如果p和q指向同一个对象,并且另一个线程在读入i和j之间写了p.x,那么对k重新使用旧值i就违反了一致性:读入i看到的是一个旧值,读入j看到的是一个新值,但随后读入k重新使用i会再次看到这个旧值。不能优化多余的读数会阻碍大多数编译器的工作,使生成的代码更慢。

对于硬件来说,一致性比编译器更容易提供,因为硬件可以应用动态优化:它可以根据在给定的内存读写序列中涉及的确切地址来调整优化路径。相比之下,编译器只能应用静态优化:他们必须提前写出一个指令序列,无论涉及什么地址和数值都是正确的。在这个例子中,编译器不能轻易地根据p和q是否恰好指向同一个对象来改变所发生的事情,至少在不写出两种可能性的代码的情况下,会导致大量的时间和空间开销。编译器对内存位置之间可能存在的别名的了解并不完整,这意味着实际提供一致性将需要放弃基本优化。

Bill Pugh在1999年的论文《修复Java内存模型》中指出了这个问题和其他问题。

新的Java内存模型(2004)

由于这些问题,并且由于最初的Java内存模型甚至连专家都难以理解,Pugh和其他人开始努力为Java定义一个新的内存模型。该模型成为JSR-133,并在2004年发布的Java 5.0中被采用。典型的参考文献是Jeremy Manson、Bill Pugh和Sarita Adve撰写的《Java内存模型》(2005年),更多的细节见Manson的博士论文。新模型遵循DRF-SC方法。无数据链的Java程序被保证以一种顺序一致的方式执行。

同步原子学和其他操作

正如我们前面所看到的,为了编写一个无数据链的程序,程序员需要同步操作来建立发生在之前的边,以确保一个线程不会在另一个线程读取或写入一个非原子变量的同时写入该变量。在Java中,主要的同步操作有。

  • 一个线程的创建发生在该线程的第一个动作之前。
  • 对mutex m的解锁发生在m的任何后续锁之前。
  • 对易失性变量v的写入发生在对v的任何后续读取之前。

"后续 "是什么意思?Java定义了所有的锁、解锁和易失性变量访问的行为,就像它们发生在一些顺序一致的交错中一样,给整个程序中所有这些操作一个总的顺序。"后续 "是指在这个总顺序中的后面。也就是说:锁、解锁和易失性变量访问的总顺序定义了后续的含义,然后后续定义了特定的执行所创建的发生前边,然后发生前边定义了该特定执行是否有数据竞赛。如果没有竞赛,那么该执行就会以顺序一致的方式进行。

事实上,易失性访问必须以某种总的顺序行事,这意味着在存储缓冲区的试金石测试中,你不能以r1=0和r2=0结束。

Litmus Test: Store Buffering
Can this program see r1 = 0, r2 = 0?

// Thread 1           // Thread 2
x = 1                 y = 1
r1 = y                r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): yes!
On ARM/POWER: yes!
On Java using volatiles: no.

在Java中,对于易失性变量x和y,读和写不能重新排序:一个写必须排在第二位,而且第二个写之后的读必须看到第一个写。如果我们没有顺序一致的要求--比如说,只要求易失性是一致的,那么两个读可能会错过写。

这里有一个重要但微妙的观点:所有同步操作的总顺序与发生之前的关系是分开的。在程序中的每一个锁、解锁或易失性变量访问之间,并不是真的有一条发生在之前的边:你只得到一条发生在之前的边,从一个写到观察到这个写的读。例如,不同突变体的锁和解锁之间没有发生前边,不同变量的易失性访问也没有发生前边,尽管这些操作总体上必须表现得像遵循一个顺序一致的交织。

狡猾程序的语义

DRF-SC只保证没有数据竞赛的程序的顺序一致行为。新的Java内存模型和原来的一样,定义了饶舌程序的行为,原因有很多。

  • 为了支持Java的一般安全性和安全保证。
  • 为了使程序员更容易发现错误。
  • 使攻击者更难利用问题,因为由于竞赛而可能造成的损害更加有限。
  • 让程序员更清楚他们的程序是干什么的。

新的模型不再依赖一致性,而是重新使用 happens-before 关系(已经用于决定程序是否有竞赛)来决定竞赛性读写的结果。

Java的具体规则是,对于字大小或更小的变量,对一个变量(或字段)x的读取必须看到对x的某个单一写所存储的值。对x的写可以被一个读r观察到,只要r不发生在w之前。

以这种方式使用happen-before,再加上可以建立新的happen-before边的同步原子学(volatiles),是对原始Java内存模型的一个重大改进。它为程序员提供了更有用的保证,并使大量重要的编译器优化得到了明确的允许。这项工作今天仍然是Java的内存模型。也就是说,它也仍然不完全正确:这种使用happens-before来试图定义饶舌程序的语义的做法有问题。

Happens-before并不排除不连贯性

用happens-before来定义程序语义的第一个问题与连贯性有关(再次!)。下面的例子来自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写x = 2,同时持有互斥体m2。这些是不同的互斥,所以这两个写法是竞争的。然而,只有线程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的论文。

发生之前并不排除无因性

最后一个例子被证明是一个简单的问题。这里有一个更难的问题。考虑一下这个试金石测试,使用普通的(非易失性)Java变量。

Litmus Test: Racy Out Of Thin Air Values
Can this program see r1 = 42, r2 = 42?

// Thread 1           // Thread 2
r1 = x                r2 = y
y = r1                x = r2
(Obviously not!)

这个程序中的所有变量一开始都是零的,就像往常一样,然后这个程序在一个线程中有效地运行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 Test: Non-Racy Out Of Thin Air Values
Can this program see r1 = 42, r2 = 42?

// Thread 1           // Thread 2
r1 = x                r2 = y
if (r1 == 42)         if (r2 == 42)
    y = r1                x = r2
(Obviously not!)

由于x和y一开始是零,任何顺序一致的执行都不会执行写操作,所以这个程序没有写操作,所以没有竞赛。不过,仅仅是发生在之前并不能排除这样的可能性:假设r1=x看到了竞速不写,然后从这个假设出发,条件最后都是真的,x和y在最后都是42。这是另一种出空值,但这次是在一个没有比赛的程序中。任何保证DRF-SC的模型都必须保证这个程序在最后只看到所有的零,然而 happens-before并没有解释原因。

Java内存模型花了很多字,我就不多说了,试图排除这类无因假设的情况。不幸的是,五年后,Sarita Adve和Hans Boehm对这项工作有这样的评价。

禁止这种违反因果关系的行为,同时又不禁止其他想要的优化,结果是出乎意料的困难。......经过许多建议和五年的激烈辩论,目前的模型被认为是最好的折衷方案。......不幸的是,这个模型非常复杂,已知有一些令人惊讶的行为,而且最近被证明有一个错误。

(Adve和Boehm,《内存模型。重新思考并行语言和硬件的案例》,2010年8月)

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":如果程序是无数据竞赛的,它就会以顺序一致的方式运行,如果不是,它可以做任何事情,包括起火。

关于DRF-SC或Catch Fire的论点的较长介绍,见Boehm,"内存模型的理由"(2007)和Boehm和Adve,"C++并发内存模型的基础"(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++坚持认为编译器有能力对程序中的bug做出任意糟糕的行为,这导致了真正可笑的结果。例如,考虑这个程序,它是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没有关系)。在我们的消息传递例子中,我们可以将doed声明为

atomic<int> done;

然后像在Java中一样,把doed当作一个普通变量来使用。或者我们可以声明一个普通的int done;然后用

while(atomic_load(&done) == 0) { /* loop */ }

atomic_store(&done, 1)。 和

while(atomic_load(&done) == 0) { /*循环 */ } 来访问它。无论哪种方式,对done的操作都会参与到原子操作的顺序一致的总秩序中,并同步程序的其他部分。

C++还增加了较弱的原子操作,可以使用atomic_store_explicit和atomic_load_explicit访问这些原子操作,并附加一个内存排序参数。使用memory_order_seq_cst使得显式调用等同于上面的短调用。

较弱的原子学被称为获取/释放原子学,其中一个被后来的获取观察到的释放创建了一个从释放到获取的发生前边。这个术语是为了唤起突变:释放就像解锁一个突变,而获取就像锁定同一个突变。在释放之前执行的写必须对随后的获取之后执行的读可见,就像在解锁一个互斥体之前执行的写必须对后来锁定同一互斥体之后执行的读可见。

为了使用较弱的原子学,我们可以将我们的消息传递的例子改为使用

atomic_store(&done, 1, memory_order_release);

while(atomic_load(&done, memory_order_acquire) == 0) { /* loop */ }

并且它仍然是正确的。但不是所有的程序都会。

回顾一下,顺序一致的原子学要求程序中所有原子学的行为与一些全局交织--执行的总顺序--一致。获得/释放原子则不需要。它们只需要对单个内存位置的操作进行顺序上一致的交错。也就是说,它们只需要一致性。其结果是,一个使用多于一个内存位置的获取/释放原子的程序可能会观察到无法用程序中所有获取/释放原子的顺序一致的交错来解释的执行,这可以说是违反了DRF-SC!

为了说明区别,这里再举一个存储缓冲区的例子。

Litmus Test: Store Buffering
Can this program see r1 = 0, r2 = 0?

// Thread 1           // Thread 2
x = 1                 y = 1
r1 = y                r2 = x
On sequentially consistent hardware: no.
On x86 (or other TSO): yes!
On ARM/POWER: yes!
On Java (using volatiles): no.
On C++11 (sequentially consistent atomics): no.
On C++11 (acquire/release atomics): yes!

C++的顺序一致原子学与Java的挥发性相匹配。特别是,允许程序表现为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++的获取/释放原子中,它们可以。(更糟糕的是,在某些架构上,如64位ARM,实现获取/释放原子的最佳方式是顺序一致的原子,所以你可能会写出在64位ARM上运行良好的代码,但在移植到其他系统时才发现它是不正确的。)

基于这种理解,"获取/释放 "对于这些原子来说是一个不幸的名字,因为顺序一致的原子也做同样多的获取和释放。这些原子的不同之处在于失去了顺序一致性。把这些原子学称为 "一致性 "原子学可能更好。太晚了。

放松的原子学

C++并没有停留在仅仅是一致性的获取/释放原子学上。它还引入了非同步化的原子学,称为放松的原子学(memory_order_relaxed)。这些原子学完全没有同步的效果--它们没有创建任何发生在之前的边--而且它们也没有排序保证。事实上,除了松弛原子的竞赛不被认为是竞赛,也不会起火之外,松弛原子的读/写和普通的读/写没有任何区别。

修订后的Java内存模型的大部分复杂性来自于对有数据竞赛的程序行为的定义。如果C++采用了DRF-SC或Catch Fire,有效地禁止了有数据竞赛的程序,就意味着我们可以丢掉我们前面看的那些奇怪的例子,从而使C++语言规范最终比Java的更简单,那就更好了。不幸的是,包括宽松的原子学,最终还是保留了所有这些问题,这意味着C++11的规范最终并不比Java简单。

与Java的内存模型一样,C++11的内存模型最终也是不正确的。考虑一下之前的无数据链程序。

Litmus Test: Non-Racy Out Of Thin Air Values
Can this program see r1 = 42, r2 = 42?

// Thread 1           // Thread 2
r1 = x                r2 = y
if (r1 == 42)         if (r2 == 42)
    y = r1                x = r2
(Obviously not!)

C++11 (ordinary variables): no.
C++11 (relaxed atomics): yes!

在他们的论文 "Common Compiler Optimisations are Invalid in the C11 Memory Model and what we can do about it"(2015)中,Viktor Vafeiadis和其他人表明,当x和y是普通变量时,C++11规范保证这个程序必须以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年发表的一篇题为《编程语言并发语义的问题》的论文给出了这样一个清醒的评价。

令人不安的是,在第一个宽松内存硬件(IBM 370/158MP)推出40多年后,该领域仍然没有一个可靠的建议,用于任何通用高级语言的并发语义,包括高性能共享内存并发原语。

即使是定义弱序硬件的语义(忽略了软件和编译器优化的复杂性)也不是很顺利。张思卓等人在2018年发表了一篇题为 "构建弱内存模型 "的论文,讲述了更多的近期事件。

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的内存模型。

2015年的Rust 1.0.0和2020年的Swift 5.3都完全采用了C/C++的内存模型,包括DRF-SC或Catch Fire以及所有的原子类型和原子栅栏。

这两种语言采用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有网络工作者,它允许在另一个线程中运行代码。按照最初的设想,工作者只能通过明确的消息复制来与JavaScript主线程进行交流。由于没有共享的可写内存,所以不需要考虑数据竞赛等问题。然而,ECMAScript 2017(ES2017)增加了SharedArrayBuffer对象,它让主线程和工作者共享一个可写内存块。为什么要这样做呢?在提案的早期草案中,列出的第一个原因是将多线程的C++代码编译为JavaScript。

当然,拥有共享的可写内存也需要定义同步的原子操作和内存模型。JavaScript在三个重要方面偏离了C++。

  • 首先,它将原子操作限制在只有顺序一致的原子操作。其他的原子操作可以被编译成顺序一致的原子操作,也许在效率上有损失,但在正确性上没有损失,而且只有一种原子操作可以简化系统的其他部分。

  • 第二,JavaScript没有采用 "DRF-SC或Catch Fire"。相反,像Java一样,它仔细地定义了狂暴的访问的可能结果。其理由与Java基本相同,特别是安全性。允许粗暴的读取返回任何值,允许(可以说是鼓励)实现者返回不相关的数据,这可能导致在运行时泄露私人数据

第三,部分原因是JavaScript为饶舌程序提供了语义,它定义了在同一内存位置上使用原子和非原子操作,以及使用不同大小的访问来访问同一内存位置时的情况。

精确地定义饶舌程序的行为会导致放宽内存语义的通常的复杂性,以及如何禁止空气外的读取等等。除了这些挑战(这些挑战大多与其他地方相同),ES2017的定义还有两个有趣的错误,这些错误是由于与新的ARMv8原子指令的语义不匹配而产生的。这些例子改编自Conrad Watt等人在2020年发表的论文 "修复和机械化JavaScript放松的内存模型"。

正如我们在上一节中指出的,ARMv8 增加了 ldar 和 stlr 指令,提供了顺序一致的原子加载和存储。这些都是针对C++的,它没有定义任何具有数据竞赛的程序的行为。因此,毫不奇怪,这些指令在饶舌程序中的行为不符合ES2017作者的期望,特别是它不满足ES2017对饶舌程序行为的要求。

Litmus Test: ES2017 racy reads on ARMv8
Can this program (using atomics) see r1 = 0, r2 = 1?

// Thread 1           // Thread 2
x = 1                 y = 1
r1 = y                x = 2 (non-atomic)
                      r2 = x
C++: yes (data race, can do anything at all).
Java: the program cannot be written.
ARMv8 using ldar/stlr: yes.
ES2017: no! (contradicting ARMv8)

在这个程序中,所有的读和写都是顺序一致的atomics,但x = 2除外:线程1使用原子存储写x = 1,但线程2使用非原子存储写x = 2。在C++中,这是一个数据竞赛,所以所有的赌注都被取消了。在Java中,这个程序不能写:x必须被声明为易失性,或者不被声明为易失性;它不能只在某些时候被原子化地访问。在ES2017中,内存模型原来是不允许r1=0,r2=1。如果r1 = y读取0,线程1必须在线程2开始之前完成,在这种情况下,非原子x = 2似乎发生在x = 1之后并覆盖了x = 1,导致原子r2 = x读取2。这种解释似乎完全合理,但这并不是ARMv8处理器的工作方式。

事实证明,对于ARMv8指令的等效序列,对x的非原子写入可以在对y的原子写入之前重新排序,因此该程序实际上产生了r1 = 0,r2 = 1。这在C++中不是一个问题,因为竞赛意味着程序可以做任何事情,但对于ES2017来说是一个问题,它将饶舌的行为限制在不包括r1=0,r2=1的结果集上。

由于ES2017的明确目标是使用ARMv8指令来实现顺序一致的原子操作,Watt等人报告说,他们建议的修正(预计将包括在标准的下一个修订版中)将削弱饶舌行为约束,足以允许这种结果。我不清楚当时的 "下一版 "是指ES2020还是ES2021)。

瓦特等人建议的修改还包括对第二个错误的修复,这个错误首先由瓦特、安德烈亚斯-罗斯伯格和让-皮雄-帕拉博德发现,ES2017规范没有给一个无数据链的程序提供顺序一致的语义。该程序是由。

Litmus Test: ES2017 data-race-free program
Can this program (using atomics) see r1 = 1, r2 = 2?

// Thread 1           // Thread 2
x = 1                 x = 2
                      r1 = x
                      if (r1 == 1) {
                          r2 = x // non-atomic
                      }
On sequentially consistent hardware: no.
C++: I'm not enough of a C++ expert to say for sure.
Java: the program cannot be written.
ES2017: yes! (violating DRF-SC).

在这个程序中,所有的读和写都是顺序一致的atomics,只有r2=x除外,如标示。这个程序是无数据竞赛的:非原子读,必须参与任何数据竞赛,只在r1 = 1时执行,这证明线程1的x = 1发生在r1 = x之前,因此也发生在r2 = x之前。 DRF-SC意味着程序必须以顺序一致的方式执行,因此r1 = 1,r2 = 2是不可能的,但ES2017规范允许它。

因此,ES2017对程序行为的规范同时过于强势(它不允许饶舌程序的真正ARMv8行为)和过于弱势(它允许无竞赛程序的非顺序一致行为)。如前所述,这些错误已被修正。即便如此,这也再次提醒我们,准确地使用 happens-before 来指定无数据种族和狡猾程序的语义是多么微妙,以及将语言内存模型与底层硬件内存模型相匹配是多么微妙。

令人鼓舞的是,至少在目前,JavaScript避免了在顺序一致的原子之外添加任何其他原子,并抵制了 "DRF-SC或Catch Fire"。其结果是,内存模型与C/C++编译目标一样有效,但更接近于Java。

结论

看看C、C++、Java、JavaScript、Rust和Swift,我们可以做出以下观察。

  • 它们都提供了顺序一致的同步原子,用于协调并行程序的非原子部分。
  • 它们的目的都是为了保证使用适当的同步技术实现无数据链的程序表现得如同以顺序一致的方式执行。
  • Java和JavaScript避免引入弱(获取/释放)同步原子,这似乎是为X86量身定做的。
  • 它们都为程序提供了一种执行 "有意的 "数据竞赛而不使程序的其他部分失效的方法。在C、C++、Rust和Swift中,这种机制是宽松的、非同步的原子学,一种特殊形式的内存访问。在Java和JavaScript中,这种机制是普通的内存访问。
  • 这些语言都没有找到一种方法来正式禁止像空气中的值这样的悖论,但所有非正式地禁止它们。

同时,处理器制造商似乎已经接受了顺序一致的同步原子的抽象性对于有效实现是很重要的,并开始这样做。ARMv8和RISC-V都提供了直接支持。

最后,大量的验证和形式分析工作已经用于理解这些系统并精确地说明其行为。特别令人鼓舞的是,Watt 等人在 2020 年能够给出 JavaScript 的重要子集的形式化模型,并使用定理检验器来证明编译到 ARM、POWER、RISC-V 和 x86-TSO 的正确性。

在第一个Java内存模型出现的25年后,经过许多人世纪的研究努力,我们可能已经开始能够将整个内存模型形式化。也许,有一天,我们也会完全理解它们。

本系列的下一篇文章是关于Go内存模型的,计划在7月12日那一周发表。

鸣谢

这一系列的文章极大地受益于与一长串工程师的讨论和反馈,我很幸运能在谷歌工作。我对他们表示感谢。我对任何错误或不受欢迎的意见承担全部责任。


www.deepl.com 翻译