研究!rsc公司

关于编程的想法和链接,通过

RSS(RSS)

编程语言记忆模型
(内存模型,第2部分)
发布于2021年7月6日,星期二。PDF格式

编程语言记忆模型回答了这个问题并行程序可以依赖哪些行为在线程之间共享内存。例如,考虑一下这个类似C语言的程序,其中两者都是x个完成从零开始。

//螺纹1//螺纹2x=1;while(done==0){/*循环*/}完成=1;打印(x);

程序尝试在中发送消息x个从螺纹1到螺纹2,使用完成作为消息准备接收的信号。如果线程1和线程2各自在其自己的专用处理器上运行,都运行到完成,这个程序是否保证按预期完成并打印1?编程语言内存模型回答了这个问题其他人也喜欢。

尽管每种编程语言在细节上有所不同,一些普遍的答案基本上适用于所有问题现代多线程语言,包括C、 C++、Go、Java、JavaScript、Rust和Swift:

考虑到这个程序有多坏,显而易见的问题是如何修复它。

现代语言提供了特殊的功能,以…的形式原子变量原子操作,允许程序同步其线程。如果我们完成原子变量(或操作它使用原子操作(采用这种方法的语言),然后我们的程序保证完成并打印1。制作完成原子有许多影响:

制作的最终结果完成原子是指程序按照我们的意愿行事,成功地将值传入x个从螺纹1到螺纹2。

在原始程序中,在编译器的代码重新排序后,线程1可能正在写入x个在同一时刻那个线程2正在读取它。这是一个数据竞赛.在修订后的计划中,原子变量完成用于同步访问x个:现在线程1不可能是写作x个同时线程2正在读取它。该程序是无数据通道.一般来说,现代语言保证无数据通道程序始终以顺序一致的方式执行,就像来自不同线程的操作一样在单个处理器上任意交错但无需重新排序。这是来自硬件内存模型的DRF-SC属性,在编程语言上下文中采用。

顺便提一下,这些原子变量或原子操作更恰当的说法是“同步原子”的确,操作在数据库意义上是原子的,允许同时读取和写入行为就像按某种顺序顺序运行一样:使用原子时,普通变量的竞争不是竞争。但更重要的是原子论同步程序的其余部分,提供一种方法消除非原子数据上的种族。然而,标准术语是简单的“原子”,这就是这篇文章的用意。只要记住阅读除非另有说明,否则“atomic”为“synchronized atomic(同步原子)”。

编程语言内存模型指定程序员和编译器需要什么的确切细节,作为他们之间的合同。以上概述的一般特征在本质上是正确的所有现代语言,但直到最近,事情才收敛到这一点:在21世纪初,变化明显更多。即使在今天,不同语言之间也有很大差异关于二阶问题,包括:

经过一些准备,这篇文章的其余部分探讨了不同的语言回答这些和相关的问题,以及它们的路径是为了到达那里。这篇文章还强调了一路上的许多错误开始,强调我们仍然在学习什么是有效的还有什么不是。

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

在我们了解任何特定语言的细节之前,简要总结硬件内存模型我们需要牢记这一点。

不同的体系结构允许不同数量的重新排序指令,以便在多个处理器上并行运行的代码可能具有不同的允许的结果取决于体系结构。金本位是顺序一致性,其中任何执行都必须表现为程序在不同处理器上执行的只是交错以某种顺序安装到单个处理器上。这种模型更容易让开发人员进行推理,但目前还没有重要的体系结构提供它,因为担保力度减弱带来了性能提升。

很难做出完全笼统的陈述比较不同的内存模型。相反,它可以帮助关注特定的测试用例,称为石蕊试验.如果两个记忆模型对给定的石蕊测试允许不同的行为,这证明它们是不同的,通常有助于我们了解,至少对于那个测试用例,一个比另一个弱或强。例如,下面是程序的石蕊测试表我们之前检查过:

石蕊测试:信息传递
这个程序能看到吗第1个 = 1,第2页 = 0?

//螺纹1//螺纹2x=1 r1=yy=1 r2=x

在顺序一致的硬件上:否。
在x86(或其他TSO)上:否。
在ARM/POWER上:对!
在任何使用普通变量的现代编译语言中:对!

与前一篇文章一样,我们假设每个示例都以所有共享变量设置为零开始。姓名第页N个表示专用存储,如寄存器或函数局部变量;其他名字如x个是不同的共享(全局)变量。我们询问是否可以设置特定的寄存器在执行结束时。当回答硬件的石蕊测试时,我们假设没有编译器可以重新排序线程中发生的事情:清单中的指令直接翻译为汇编指令交给处理器执行。

结果第1个 = 1,第2页 = 0对应于原始程序的线程2完成其循环(完成)然后打印0。这种结果在任何顺序一致的交织中都不可能实现程序操作。对于汇编语言版本,在x86上无法打印0,但在more上可以打印由于重新排序优化,像ARM和POWER这样的宽松架构在处理器本身中。在现代语言中,可能发生的重新排序在编译过程中,无论怎样都可以实现此结果底层硬件是什么。

不是保证顺序一致性,正如我们前面提到的,今天的处理器保证一种称为“无数据追踪序列一致性”或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=1x=2r1=yr2=z

w个,x个,、和z(z)都是不同的变量,这四个语句可以按编译器认为最佳的任何顺序执行。

正如我们上面提到的,重新排序读写的能力是如此自由保证普通编译程序至少与ARM/POWER松弛存储器模型一样弱,因为编译的程序无法通过石蕊测试。事实上,编译程序的保证较弱。

在硬件文章中,我们以一致性为例ARM/POWER体系结构能够保证:

石蕊试验:一致性
这个程序能看到吗第1个 = 1,第2页 = 2,第3轮 = 2,r4型 = 1?
(螺纹3可参见x个 = 1之前x个 = 2而线程4则相反?)

//螺纹1//螺纹2//螺纹3//螺纹4x=1 x=2 r1=x r3=xr2=x r4=x

在顺序一致的硬件上:否。
在x86(或其他TSO)上:否。
在ARM/POWER上:否。
在任何使用普通变量的现代编译语言中:对!

所有现代硬件保证一致性,也可以被视为单个内存位置上的操作。在这个程序中,一个写入必须覆盖另一个写入,整个系统必须就哪一个是哪一个达成一致。事实证明,由于编译过程中程序重新排序,现代语言甚至不能提供连贯性。

假设编译器重新排序线程4中的两次读取,然后指令按如下顺序交错运行:

//螺纹1//螺纹2//螺纹3//螺纹4//(重新排序)(1) x=1(2)r1=x(3)r4=x(4) x=2(5)r2=x(6)r3=x

结果是第1个 = 1,第2页 = 2,第3轮 = 2,r4型 = 1,这在装配程序中是不可能的但在高级语言中是可能的。在这个意义上,编程语言内存模型都比最宽松的硬件内存模型弱。

但也有一些保证。每个人都同意需要提供DRF-SC,这不允许引入新读取或写入的优化,即使这些优化在单线程代码中是有效的。

例如,考虑以下代码:

如果(c){x++;}其他{…许多代码。。。}

有一个如果语句中包含大量代码其他的而且只有一个x个++在中如果车身。减少分支机构并消除如果整个身体。我们可以通过运行x个++如果然后用一个x个--如果我们错了,在另一个大身体里。也就是说,编译器可能会考虑将该代码重写为:

x++;如果(!c){x——;…许多代码。。。}

这是一个安全的编译器优化吗?在单线程程序中,是的。在多线程程序中,其中x个与其他线程共享什么时候c(c)是false,否:优化将在x个这在原始程序中不存在。

此示例源自Hans-Behm 2004年的论文,线程不能作为库实现,”这使得语言不能沉默关于多线程执行的语义。

编程语言内存模型试图精确地回答以下关于允许哪些优化的问题哪些不是。通过检查写作尝试的历史这些模型在过去几十年中,我们可以了解到哪些是有效的还有什么没有,并了解事情的发展方向。

原始Java内存模型(1996)

Java是第一种尝试写下来的主流语言它对多线程程序的保证。它包括互斥锁并定义了内存顺序他们暗示的要求。它还包括“易失性”原子变量:volatile变量的所有读取和写入都是必需的直接在主存储器中按程序顺序执行,使volatile变量上的操作正常运行以顺序一致的方式。最后,Java也指定了(或至少试图指定)具有数据竞争的程序的行为。其中一部分是要求普通变量具有某种形式的一致性,我们将在下面进行更多的研究。不幸的是,在第一版Java语言规范(1996),至少有两个严重的缺陷。事后诸葛亮很容易解释并使用我们已经制定的初步计划。当时,它们远没有那么明显。

原子需要同步

第一个缺陷是挥发性原子变量不同步,因此,他们没有帮助消除项目其余部分的种族。我们在上面看到的消息传递程序的Java版本是:

整数x;volatile int done;//螺纹1//螺纹2x=1;while(done==0){/*循环*/}完成=1;打印(x);

因为完成声明为volatile,则保证循环完成:编译器无法将其缓存在寄存器中并导致无限循环。但是,程序不能保证打印1。编译器没有被禁止重新排序对的访问x个完成,也没有要求禁止硬件这样做。

因为Java挥发物是非同步原子,您不能使用它们来构建新的同步原语。从这个意义上说,最初的Java内存模型太弱了。

一致性与编译器优化不兼容

原始Java内存模型也太强大了:强制一致性-线程读取新值后对于内存位置,它以后似乎无法读取旧的值基本编译器优化。早些时候,我们研究了重新排序阅读会如何破坏连贯性,但你可能会想,好吧,只是不要重新排序阅读。以下是打破连贯性的一种更微妙的方式另一个优化:公共子表达式消除。

考虑一下这个Java程序:

//p和q可以指向也可以不指向同一物体。int i=p.x;// ... 也许此时另一个线程会写入p.x。。。int j=q.x;int k=p.x;

在这个程序中,公共子表达式消除会注意到第x页计算了两次优化最后一行k=i.但如果第页q个指向同一个对象和另一个线程已写信给第x页在读入之间j个,然后重用旧值对于k个违反连贯性:读入看到了旧的价值观,读入j个看到了更新的值,但后来读到k个重复使用将再次看到旧的价值。无法优化消除冗余读取这会阻碍大多数编译器,使生成的代码变慢。

与编译器相比,硬件更容易提供一致性因为硬件可以应用动态优化:它可以根据精确的给定内存读写序列中涉及的地址。相反,编译器只能应用静态优化:他们必须提前写出一个指令序列无论涉及什么地址和值,都是正确的。在该示例中,编译器无法轻易更改基于是否第页q个碰巧指向同一个物体,至少在没有为这两种可能性编写代码的情况下,导致大量的时间和空间开销。编译器对可能的混叠的不完全了解内存位置之间意味着实际提供一致性需要放弃基本优化。

比尔·普格(Bill Pugh)在其1999年的论文中指出了这个问题和其他问题修复Java内存模型.”

新Java内存模型(2004)

由于这些问题,以及原始Java内存模型即使专家也很难理解,Pugh和其他人开始努力定义一个新的内存模型适用于Java。该模型成为JSR-133,并在2004年发布的Java5.0中采用。规范引用是Java内存模型” (2005),杰里米·曼森(Jeremy Manson)、比尔·普格(Bill Pugh)和莎莉塔·阿德(Sarita Adve),中包含其他详细信息曼森博士论文.新模型遵循DRF-SC方法:保证无数据通道的Java程序以顺序一致的方式执行。

同步原子和其他操作

如前所述,为了编写无数据通道的程序,程序员需要能够在边缘之前建立happens,以确保一个线程不写入非原子变量与另一个线程同时读取或写入它。在Java中,主要同步操作包括:

“后续”是什么意思?Java定义所有锁、解锁和易失性变量访问表现得好像他们发生在顺序一致交织,给出整个程序中所有这些操作的总顺序。“后续”指该总顺序中的后续。即:锁定、解锁和volatile变量访问的总顺序定义了后续的含义,然后,随后定义创建边之前发生的事件通过特定的执行,然后happens-before边定义该特定执行过程中存在数据竞争。如果没有竞争,那么执行将以顺序一致的方式进行。

事实上,volatile访问必须按照某种总体顺序进行意味着在储存缓冲石蕊试验,你不能以第1个 = 0第2页 = 0:

石蕊测试:储存缓冲
这个程序能看到吗第1个 = 0,第2页 = 0?

//螺纹1//螺纹2x=1 y=1r1=y r2=x

在顺序一致的硬件上:否。
在x86(或其他TSO)上:对!
在ARM/POWER上:对!
关于使用挥发物的Java:没有。

在Java中,对于volatile变量x个,读取和写入无法重新排序:第二次写一次,第二次写入之后的读取必须看到第一次写入。如果我们没有顺序一致的要求——比如说,挥发物只需要连贯,两次读取可能会错过写入。

这里有一个重要但微妙的观点:所有同步操作的总顺序与之前发生的关系是分开的。它是确实,在某个方向的边缘之前有一个偶发事件在程序中的每个锁定、解锁或易失性变量访问之间:从写入到观察写入的读取只会得到happens-beforedge。例如,不同互斥锁的锁定和解锁在它们之间的边缘之前没有发生,不同变量的volatile访问也不例外,尽管这些行动必须表现为遵循单个顺序一致的交织。

生动程序的语义

DRF-SC仅保证顺序一致的行为没有数据竞赛的程序。新的Java内存模型与原来的一样,定义了racy程序的行为,原因如下:

新模型没有依赖连贯性,而是重用了之前的事件关系(已经用于决定程序是否有比赛)决定阅读和写作比赛的结果。

Java的具体规则是针对单词大小或更小的变量,读取变量(或字段)x个必须看到某个写入操作存储的值x个.写信给x个可以通过读取进行观察第页假如第页以前没有发生过w个.这意味着第页可以观察以前发生的写入第页(但之前不会被覆盖第页),它可以观察到与第页.

以这种方式使用以前发生过,与同步原子相结合(挥发物)这可能会在边缘之前产生新的情况,是对原始Java内存模型的重大改进。它为程序员提供了更有用的保证,它使大量重要的编译器优化被明确允许。这项工作至今仍是Java的内存模型。也就是说,这还不太正确:happens-before的这种用法存在问题试图定义racy程序的语义。

发生在之前不排除不连贯

定义程序语义的happens-before的第一个问题与连贯性有关(再次!)。(以下示例摘自雅罗斯拉夫·谢夫切克和大卫·阿斯皮纳尔的论文,”Java内存模型中程序转换的有效性” (2007).)

这是一个有三个线程的程序。假设已知线程1和线程2在线程3启动之前完成。

//螺纹1//螺纹2//螺纹3船闸(m1)船闸(m2)x=1 x=2解锁(m1)解锁(m2)锁定(m1)船闸(m2)r1=xr2=x解锁(m2)解锁(m1)

线程1写入x个 = 1保持互斥时平方米.线程2写入x个 = 2保持互斥时平方米.这是不同的互斥锁,所以这两个互斥锁写的是race。然而,只有线程3读取x个,它在获取两个互斥对象后执行此操作。读入第1个可以读或写:两者都发生在它之前,两者都不会完全覆盖另一个。根据同样的论点第2页既可以读也可以写。但严格来说,Java内存模型中什么都没有表示两人必须达成一致:技术上,第1个第2页可以保留读取不同值的x个.也就是说,这个程序可以以第1个第2页持有不同的价值观。当然,没有真正的实现会产生不同的第1个第2页.互斥意味着这两个读取之间没有发生写入。它们必须获得相同的值。但事实上,记忆模型允许不同的读数从某种技术角度来看,它并没有精确地描述真正的Java实现。

情况变得更糟了。如果我们再增加一条指令,x个 = 第1个,两者之间的内容为:

//螺纹1//螺纹2//螺纹3船闸(m1)船闸(m2)x=1 x=2解锁(m1)解锁(m2)锁定(m1)船闸(m2)r1=xx=r1//!?r2=x解锁(m2)解锁(m1)

现在,很明显第2页 = x个read必须使用写入的值x个 = 第1个,因此程序必须在中获得相同的值第1个第2页.这两个值第1个第2页现在保证是平等的。

这两个程序之间的差异意味着编译器有问题。编译器可以看到第1个 = x个然后x个 = 第1个可能很想删除第二个任务,这显然是多余的。但“优化”改变了第二个程序,必须在中看到相同的值第1个第2页,进入第一个程序,从技术上讲第1个不同于第2页.因此,根据Java内存模型,这种优化在技术上是无效的:它改变了程序的含义。显然,这种优化不会改变Java程序执行的含义在您可以想象的任何真实JVM上。但不知何故,Java内存模型不允许这样做,这表明还有更多需要说明的地方。

有关此示例和其他示例的更多信息,参见谢夫切克和阿斯皮纳尔的论文。

以前发生的事不排除偶然性

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

石蕊测试:稀薄空气中的Racy值
这个程序能看到吗第1个 = 42,第2页 = 42?

//螺纹1//螺纹2r1=x r2=yy=r1 x=r2

(显然不是!)

与往常一样,这个程序中的所有变量都从零开始,然后这个程序有效地运行 = x个在一个线程中x个 = 在另一个线程中。可以x个最终42岁?在现实生活中,显然不是。但为什么不呢?事实证明,内存模型不允许出现这种结果。

假设“第1个 = x个“读到42。然后“ = 第1个“将写入42,然后是比赛”第2页 = “可能是42,导致“x个 = 第2页“将42写入x个,并且写下了与原作的比赛(因此可以被原作观察到)”第1个 = x个,”似乎证明了最初的假设是正确的。在本例中,42被称为out-of-hin-air值,因为它看起来没有任何理由然后用循环逻辑证明自己是对的。如果记忆以前是42当前为0,硬件不正确推测还是42?这种猜测可能会成为一种自我实现的预言。(这一论点以前似乎更加模糊幽灵和相关攻击显示了硬件推测的积极性。即便如此,也没有任何硬件以这种方式创造出不合时宜的价值。)

很明显,这个项目不能以第1个第2页设置为42,但之前发生的事情本身并不能解释为什么这不会发生。这再次表明存在某种不完整性。新的Java内存模型花费了大量时间来寻址这种不完整性,关于它更简短。

这个节目有一场比赛x个正在比赛反对在其他线程中写入-so我们可能会反驳说这是一个错误的程序。但这里有一个无数据通道的版本:

石蕊试验:稀薄空气值的非周期性
这个程序能看到吗第1个 = 42,第2页 = 42?

//螺纹1//螺纹2r1=x r2=y如果(r1==42)如果(r2==42y=r1 x=r2

(显然不是!)

x个从零开始,任何顺序一致的执行永远不会执行写操作,所以这个程序没有写操作,所以没有种族。然而,再一次,仅在之前发生的事情并不排除以下可能性:,第1个 = x个看到比赛中不停地写,然后根据这个假设,条件都为真x个最后都是42。这是另一种不合时宜的价值观,但这次是在一个没有竞争的程序中。任何保证DRF-SC的模型都必须保证该程序只在末尾看到所有零,然而,之前发生的事并不能解释为什么。

Java内存模型花费了大量单词我不会试图排除这些类型的配偶假设。不幸的是,五年后,Sarita Adve和Hans Boehm对这项工作发表了以下看法:

以一种既不禁止又不禁止的方式禁止这种违反因果关系的行为其他期望的优化结果出乎意料地困难…经过许多提议和五年的激烈辩论,目前的模式被批准为最佳折衷方案…不幸的是,这个模型非常复杂,有一些令人惊讶的行为,最近发现有一个bug。

(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或着火

与Java不同,C++对带有种族的程序没有任何保证。任何有比赛的节目都属于“未定义行为.”在程序执行的最初几微秒内快速访问被允许在数小时或数天后造成任意错误行为。这通常称为“DRF-SC or Catch Fire”:如果程序是它以顺序一致的方式运行,如果没有数据空间,它可以做任何事情,包括着火。

有关DRF-SC或Catch Fire参数的详细介绍,请参阅Boehm,“内存模型原理” (2007)博姆和阿德,”C++并发内存模型的基础” (2008).

简而言之,有四种常见的理由支持这一立场:

就个人而言,最后一个理由是我觉得唯一有说服力的理由,虽然我观察到可以这样说“允许使用种族检测器”,但没有说明“整数上的一次竞赛可能会使整个程序失效。”

下面是我认为的“记忆模型原理”中的一个例子捕获C++方法的本质及其问题。考虑一下这个程序,它引用了一个全局变量x个.

无符号i=x;如果(i<2){foo:。。。开关(i){案例0:...;断裂;案例1:...;断裂;}}

声明是C++编译器可能持有在寄存器中但如果标签处的代码foo公司复杂。而不是溢出对于函数堆栈,编译器可能会决定加载全球第二次x个到达switch语句时。结果是如果车身, < 2可能不再是真的了。如果编译器执行了类似于编译转换进入之内使用索引表的计算跳转,该代码将从表末尾索引并跳转到意外地址,这可能非常糟糕。

从这个例子和其他类似的例子中,C++内存模型的作者得出的结论是,任何racy访问都必须被允许,才能导致无界对程序未来执行的损害。我个人认为,在多线程程序中,编译器不应假定可以重新加载局部变量喜欢通过重新执行初始化它的内存读取。期望现有的可能是不切实际的为单线程世界编写的C++编译器,找到并修复像这样的代码生成问题,但在新语言方面,我认为我们应该有更高的目标。

离题:C和C中未定义的行为++

另外,C和C++坚持编译器能够对程序中的错误做出任意错误的反应导致了真正荒谬的结果。例如,考虑一下这个项目,它是一个讨论的主题2017年在推特上:

#包含<cstdlib>typedef int(*函数)();静态函数Do;静态int EraseAll(){返回系统(“rm-rf斜线”);}void NeverCalled(){Do=全部擦除;}整型main(){return Do();}

如果你是像Clang这样的现代C++编译器,你可以这样考虑这个程序:

最终结果是Clang将程序优化到:

整型main(){返回系统(“rm-rf斜线”);}

你必须承认:除了这个例子,局部变量的可能性可能突然不要在身体的一半以下如果 (i) < 2)看起来并不奇怪。

本质上,现代C和C++编译器假设没有程序员敢尝试未定义的行为。程序员编写程序时有错误?不可思议!

正如我所说,在新的语言中,我认为我们应该有更高的目标。

获取/发布原子

C++采用顺序一致的原子变量很像(新)Java的易失性变量(与C++volatile无关)。在我们的消息传递示例中,我们可以声明完成作为

原子<int>完成;

然后使用完成就像在Java中一样,它是一个普通变量。或者我们可以声明一个普通整数 完成;然后使用

原子存储(&done,1);

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

以访问它。无论哪种方式完成参与上顺序一致的总顺序原子操作并同步程序的其余部分。

C++还添加了较弱的原子,可以访问使用原子存储显式原子加载显式带有额外的内存排序参数。使用内存顺序相等进行显式调用相当于上面的较短的。

较弱的原子称为获取/释放原子,在这种情况下,后来的采集所观察到的释放产生了一种意外情况——之前从释放到获取的边缘。这个术语意在唤起互斥:释放就像解锁互斥获取就像锁定同一个互斥对象。发布之前执行的写入必须对读取可见在随后的获取之后执行,就像在解锁互斥锁之前执行写操作一样必须对稍后锁定同一互斥锁后执行的读取可见。

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

原子存储(&done,1,memory_order_release);

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

这仍然是正确的。但并非所有项目都会这样。

回想一下,需要顺序一致的原子程序中所有原子的行为要一致使用一些全局交错-执行的总顺序。获取/释放原子不会。它们只需要顺序一致的交织单个内存位置上的操作。也就是说,它们只需要连贯性。结果是一个使用获取/释放原子的程序使用多个内存位置可以观察执行不能用顺序一致交织来解释程序中的所有获取/发布原子,可以说是违反了DRF-SC!

为了显示差异,这里再次给出了存储缓冲区示例:

石蕊测试:储存缓冲
这个程序能看到吗第1个 = 0,第2页 = 0?

//螺纹1//螺纹2x=1 y=1r1=y r2=x

在顺序一致的硬件上:否。
在x86(或其他TSO)上:对!
在ARM/POWER上:对!
在Java上(使用挥发物):没有。
在C++11上(顺序一致的原子):否。
在C++11上(获取/释放原子):对!

C++顺序一致的原子与Java的volatile相匹配。但获得释放原子没有强加订单之间的关系x个以及的订单.特别是,允许程序表现为第1个 = 以前发生过 = 1同时第2页 = x个以前发生过x个 = 1,允许第1个 = 0,第2页 = 0在全程序顺序一致性的矛盾中。这些可能只是因为它们在x86上是免费的。

注意,对于给定的一组特定读取,观察特定写入,C++顺序一致原子论和C++获取/释放原子论在边之前创建相同的happens。它们之间的区别在于,一些特定的读取集观察特定的写入顺序一致的原子不允许但获得/释放原子允许。一个这样的例子是导致第1个 = 0,第2页 = 0在存储缓冲情况下。

获取/发布弱点的一个真实例子

获取/发布原子是在实践中不如原子提供顺序一致性。这里有一个例子。假设我们有一个新的同步原语,使用两种方法的一次性条件变量通知等待.为了简单起见,只有一个线程会调用通知而且只有单个线程将调用等待.我们想安排通知当另一个线程尚未等待。我们可以用一对原子整数来实现这一点:

Cond类{原子<int>完成;原子<int>等待;...};无效条件::notify(){完成=1;if(!等待)回报;// ... 叫醒服务员。。。}无效条件::wait(){等待=1;如果(完成)回报;// ... 睡觉。。。}

此代码的重要部分是那个通知完成检查之前等待,虽然等待等待检查之前完成,以便并发调用通知等待不能导致通知立即返回并等待睡觉。但有了C++获取/释放原子,它们就可以了。他们可能只需要很短的时间,使得bug很难再现和诊断。(更糟糕的是,在64位ARM等一些架构上,实现获取/发布原子的最佳方法是顺序一致的原子,因此,您可以编写在64位ARM上运行良好的代码并且只会在移植到其他系统时发现它不正确。)

根据这一理解,“获取/发布”是一个不幸的名称对于这些原子,因为顺序一致的原子也有同样的作用获取和发布。这些不同之处在于序列一致性的丧失。把这些称为“一致性”原子可能更好。太晚了。

放松的原子

C++并没有仅仅以连贯的获取/释放原子停止。它还引入了非同步原子,称为松弛原子(内存顺序已解除).这些原子根本没有同步效应,它们不会在边缘之前产生任何事件,也没有订购担保。事实上,放松的原子读/写没有区别和一个普通的读/写,除了一场关于放松原子的比赛不被视为比赛,也不会着火。

修改后的Java内存模型的复杂性很大产生于定义具有数据竞争的程序的行为。如果C++采用DRF-SC或Catch Fire,效果会更好禁止带有数据赛跑的程序意味着我们可以扔掉我们之前看过的那些奇怪的例子,这样,C++语言规范最终会比Java语言规范更简单。不幸的是,包括放松的原子最终保留了所有这些担忧,这意味着C++11规范最终并不比Java规范简单。

与Java的内存模型一样,C++11的内存模型也不正确。考虑以前的无数据通道计划:

石蕊试验:稀薄空气值的非周期性
这个程序能看到吗第1个 = 42,第2页 = 42?

//螺纹1//螺纹2r1=x r2=y如果(r1==42)如果(r2==42y=r1 x=r2

(显然不是!)

C++11(普通变量):否。
C++11(松弛原子):对!

在他们的论文中“C11内存模型中常见的编译器优化无效,我们可以采取什么措施” (2015),维克托·瓦菲亚迪斯和其他人表明C++11规范保证此程序必须以x个在以下情况下设置为零x个是普通变量。但如果x个是放松的原子,那么,严格地说,C++11规范并不排除第1个第2页可以两者最终都是42。(惊喜!)

有关详细信息,请参阅论文,C++11规范有一些正式规则,试图禁止使用空值,再加上一些模糊的词语来阻止其他类型的有问题的价值观。这些正式规则就是问题所在,所以C++14放弃了它们,只留下了模糊的单词。引用删除它们的理由,C++11公式被证明是“两者都不充分,因为它使得基本上不可能推理带有内存顺序已解除,并且严重有害,因为它可以说不允许所有合理的内存顺序已解除ARM和POWER等体系结构。”

综上所述,Java试图正式排除所有死刑执行,但均以失败告终。然后,得益于Java的后见之明,C++11试图正式排除一些死刑执行,但也失败了。然后,C++14根本没有说什么正式的话。这不是朝着正确的方向发展。

事实上,Mark Batty等人2015年的一篇论文题为“程序设计语言并发语义问题“给出了这一令人清醒的评估:

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

甚至定义弱序的语义硬件(忽略软件和编译器优化的复杂性)进展不太顺利。张思卓等人2018年发表的一篇论文,题为“构建弱记忆模型“讲述了最近发生的事件:

萨尔卡 于2011年发布了POWER的运营模型,Mador-Haim等人于2012年发布了一个公理模型,该模型已被证明与运营模型相匹配。然而,2014年,阿尔加莱 显示了原始操作模型,以及相应的公理模型,排除了POWER机器上新观察到的行为。再举一个例子,2016年,Flur 给出了ARM的操作模型,没有相应的公理化模型。一年后,ARM明确发布了ISA手册的修订版Flur模型允许的禁止行为,这导致了另一个提出的ARM内存模型。显然,从经验上对弱记忆模型进行形式化是容易出错且具有挑战性的。

过去十年来一直致力于定义和规范所有这些的研究人员非常聪明、有天赋、坚持不懈,我并不想减损他们的努力和成就通过指出结果中的不足之处。我从这些简单的结论中得出结论,这个指定确切行为的问题线程程序,即使没有种族,也是非常微妙和困难的。如今,即使是最优秀、最聪明的研究人员也无法掌握这一点。即使不是这样,当编程语言定义可以被日常开发人员,无需花费十年时间研究并发程序的语义。

C、 锈蚀和快速记忆模型

C11也采用了C++11内存模型,使其成为C/C++11内存模式。

2015年锈蚀1.0.02020年Swift 5.3两者都采用了C/C++内存模型,使用DRF-SC或Catch Fire以及所有原子类型和原子围栏。

这两种语言都采用了C/C++模型,因为它们构建在C/C++编译器工具链(LLVM)上并强调与C/C++代码的紧密集成。

硬件偏离:高效的顺序一致原子

早期的多处理器体系结构多种多样同步机制和内存模型,具有不同程度的可用性。在这种多样性中不同的同步抽象取决于他们对提供的体系结构。为了构造顺序一致的原子变量的抽象,有时,唯一的选择是使用更多的障碍远比严格要求的昂贵,尤其是ARM和POWER。

C、C++和Java都提供了相同的抽象顺序一致的同步原子,硬件设计师理应使这种抽象有效。引入了ARMv8体系结构(32位和64位)激光雷达stlr公司装载和存储说明,提供直接实现。在2017年的一次演讲中,赫伯·萨特声称IBM同意他说他们打算在未来的POWER实现中增加一些有效支持顺序一致的原子,给程序员“更少的理由使用宽松的原子。”我不知道这是否发生了,尽管在2021年,事实证明,与ARMv8相比,POWER的相关性要小得多。

这种收敛的效果是连续一致的原子学现在已经被很好地理解,并且可以有效地实现在所有主要硬件平台上,使其成为编程语言记忆模型。

JavaScript内存模型(2017)

你可能会认为JavaScript是一种臭名昭著的单线程语言,不需要为发生的事情担心内存模型当代码在多个处理器上并行运行时。我当然知道了。但你和我都错了。

JavaScript有网络工作者,允许在另一个线程中运行代码。按照最初的设想,工人只与主JavaScript线程通过显式消息复制。由于没有共享的可写内存,因此没有必要考虑数据竞赛等问题。然而,ECMAScript 2017(ES2017)增加了SharedArrayBuffer(共享阵列缓冲区)对象,这使得主线程和工作线程共享一个可写内存块。为什么要这样做?在一个提案的初稿,列出的第一个原因是将多线程C++代码编译为JavaScript。

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

精确定义活泼程序的行为会导致放松记忆语义的复杂性和如何禁止非中文阅读等。除了这些挑战外,这些挑战与其他地方基本相同,ES2017定义有两个有趣的错误,它们是由与新ARMv8原子指令的语义不匹配。这些示例改编自Conrad Watt .的2020年论文修复和机械化JavaScript松弛内存模型.”

如前一节所述,ARMv8添加了激光雷达stlr公司说明书提供顺序一致的原子加载和存储。这些是针对C++的,它没有定义行为任何有数据竞争的程序。不出所料,这些指令在racy中的行为项目与ES2017作者的期望不符,尤其是它不符合ES2017的要求为活泼的程序行为。

石蕊测试:ES2017 racy在ARMv8上读取
这个程序(使用原子)可以看到吗第1个 = 0,第2页 = 1?

//螺纹1//螺纹2x=1 y=1r1=y x=2(非原子)r2=x

C++:是的(数据竞赛,可以做任何事情)。
Java:程序无法写入。
ARMv8使用激光雷达/stlr公司:是的。
ES2017年:不!(与ARMv8相矛盾)

在这个程序中,所有读取和写入都是顺序一致的原子学除外x个 = 2:线程1写入x个 = 1使用原子存储,但线程2写入x个 = 2使用非原子存储。在C++中,这是一场数据竞赛,所以一切都是徒劳的。在Java中,无法编写此程序:x个必须要么被宣布不稳定的是否;它有时不能以原子方式访问。在ES2017中,内存模型被证明不允许第1个 = 0,第2页 = 1.如果第1个 = 读取0,线程1必须完成在线程2开始之前,在这种情况下,非原子x个 = 2似乎发生在并覆盖x个 = 1,导致原子第2页 = x个改为2。这个解释似乎完全合理,但这不是ARMv8处理器的工作方式。

事实证明,对于ARMv8指令的等效序列,非原子写入x个可以在原子写入之前重新排序,所以这个程序实际上会产生第1个 = 0,第2页 = 1.这在C++中不是问题,因为竞争意味着程序可以做任何事情都可以,但这是ES2017的一个问题,它限制了racy一系列不包括第1个 = 0,第2页 = 1.

由于ES2017的明确目标是使用ARMv8指令实现顺序一致的原子操作,瓦特 报告他们建议的修复,预计将包含在下一版本的标准中,会削弱活泼的行为约束这一结果。(我不清楚当时“下一次修订”是指ES2020还是ES2021。)

瓦特 .的建议更改还包括对第二个错误的修复,首先由瓦特、安德烈亚斯·罗斯伯格和让·皮乔·普哈拉博德确定,其中一个无数据通道的程序是按顺序给出一致ES2017规范的语义。该程序由以下人员提供:

石蕊试验:ES2017无数据追踪计划
这个程序(使用原子)可以看到吗第1个 = 1,第2页 = 2?

//螺纹1//螺纹2x=1 x=2r1=x如果(r1==1){r2=x//非原子}

在顺序一致的硬件上:否。
C++:我对C++专家的能力还不够肯定。
Java:程序无法写入。
ES2017年:对!(违反DRF-SC)。

在这个程序中,所有读取和写入都是顺序一致的原子学除外第2页 = x个,如标记所示。这个程序是无数据通道的:非原子读取,必须参与任何数据竞赛,仅在以下情况下执行第1个 = 1,这证明了螺纹1x个 = 1发生在第1个 = x个因此,在第2页 = x个.DRF-SC意味着该计划必须以顺序一致的方式执行,以便第1个 = 1,第2页 = 2是不可能的,但ES2017规范允许这样做。

ES2017计划行为规范是因此同时太强(它不允许对racy程序执行真正的ARMv8行为)而且太弱(它允许无比赛项目的非连续一致行为)。如前所述,这些错误已修复。尽管如此,这再次提醒我们它可以指定data-race-free和racy的语义程序完全使用happens-before,以及匹配语言记忆模型的微妙程度使用底层硬件内存模型。

令人鼓舞的是,至少目前如此JavaScript避免了添加任何其他原子除了顺序一致的并抵制“DRF-SC或着火”结果是一个作为C/C++编译目标有效的内存模型但更接近Java。

结论

看看C、C++、Java、JavaScript、Rust和Swift,我们可以得出以下结论:

与此同时,处理器制造商似乎已经接受了顺序一致的同步原子对于有效实施,并开始这样做:ARMv8和RISC-V都提供直接支持。

最后,进行了大量的验证和正式分析工作已经深入了解了这些系统并准确地描述了它们的行为。特别令人鼓舞的是,瓦特 .能够在2020年给出JavaScript重要子集的形式化模型并使用定理证明器证明编译的正确性ARM、POWER、RISC-V和x86-TSO。

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

本系列的下一篇文章是“更新Go-Memory模型.”

致谢

这一系列帖子从与以及我很幸运能在谷歌共事的一长串工程师的反馈。我感谢他们。我对任何错误或不受欢迎的意见承担全部责任。