内存一致性模型
对于 SMP 系统,存在几种内存一致性模型:
- 顺序一致性(Sequential consistency,SC)(所有读操作和所有写操作都是有序的)
- 放松一致性(Relaxed consistency,RC)(允许某些类型的重排序),relaxations 是针对不同地址的访问;相同地址的访问是有序的,就像单核处理器一样
- 放松本地排序
- 一般情况下放宽了 R→R,如果地址相同,则不放宽 R→R,如果第二个地址依赖于第一个操作的结果,则不放宽 R→R
- 放宽 W→R (Total Store Ordering,TSO)
- 放宽 W→W & R→RW
- 放松存储原子性
- 线程可以提前看到自己的写入(即全局执行之前)
- 一个线程可以提前看到另一个线程的写操作(即在它之前)
- 安全措施
- 使用显式围栏指令(又名内存屏障)
- 使用原子 RMW 指令
- 标注用于“同步”的加载/存储指令,以强制它们与其他内存操作之间的顺序
- 放松本地排序
- 弱一致性(Weak consistency,WC)在一个良好同步的程序中,所有在临界区内的重排序都应该被允许
- 放松本地排序
- 如果访问的地址彼此独立(无地址依赖),指令可任意重排,不允许指令跨越同步操作重排(保证同步时一致性)
- 原子性放松
- 线程可以提前读取自己刚写入但尚未对其他线程可见的值(写缓冲行为)
- 安全措施
- SYNCH操作
- Lock / Unlock
- acquire / release
- barrier
- memory fence(如
mfence) - 原子读-改-写(如
atomic_inc)
- SYNCH操作
- 放松本地排序
- 发布一致性(Release Consistency)
- 放松本地排序
- 所有重排序操作允许在 SYNCH 操作之间进行,RELEASE 后跟的正常操作不需要等待 RELEASE 完成,ACQUIRE 不需要等待之前的正常操作完成,SYNCH 操作之间的正常操作不需要等待或延迟临界区外的正常操作
- 原子性放松
- 能较早看到自己的或他人的写入
- 安全措施
- 获取和释放操作
- 放松本地排序
在某些 CPU 上
- 原子操作可以与加载和存储一起重新排序。[14]
- 可能存在不一致的指令缓存管道,这会阻止自修改代码在不使用特殊的指令缓存刷新/重载指令的情况下执行。
- 依赖加载可以被重排序(这是 Alpha 特有的)。如果处理器首先获取某个数据的指针,然后获取数据,它可能不会获取数据本身,而是使用已经缓存且尚未失效的旧数据。允许这种放松使得缓存硬件更简单、更快,但会导致对读者和写者需要内存屏障的要求。
硬件内存模型
内存模型允许编译器执行许多重要的优化。像编译器优化的循环融合会移动程序中的语句,这可能会影响潜在共享的变量的读写操作顺序。读写顺序的变化会导致竞态条件 。没有内存模型,编译器可能根本不会将此类优化应用于多线程程序,或者它可能会应用与多线程不兼容的优化,从而导致错误。
很久以前,当每个人都编写单线程程序时,让程序运行得更快的最有效方法之一就是坐下来什么都不做。下一代硬件和下一代编译器的优化将使程序运行得和以前一样,只是速度更快。在这个童话般的时期,有一个简单的测试来检验优化是否有效:如果程序员无法区分有效程序的未优化和优化执行之间的差异(除了加速),那么优化就是有效的。也就是说,有效的优化不会改变有效程序的行为。
几年前的一个悲伤的一天,硬件工程师让单个处理器变得越来越快的魔咒不再起作用了。作为回应,他们发现了一个新的魔咒,可以让他们创建具有越来越多处理器的计算机,并且操作系统在线程抽象中向程序员公开这种硬件并行性。这种新的魔咒——以操作系统线程的形式提供多个处理器——对硬件工程师来说效果更好,但它给语言设计者、编译器编写者和程序员带来了重大问题。
许多在单线程程序中不可见(因此有效)的硬件和编译器优化会在多线程程序中产生可见的变化。如果有效的优化不会改变有效程序的行为,则必须声明这些优化或现有程序无效。会是哪一个,我们该如何决定?
这是一个使用类 C 语言编写的简单示例程序。在这个程序以及我们将考虑的所有程序中,所有变量最初都设置为零。
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x); 如果线程 1 和线程 2 都运行在自己的专用处理器上,并且都运行完成,那么该程序可以打印 0 吗?
这取决于。这取决于硬件,也取决于编译器。在 x86 多处理器上运行的程序集的直接逐行转换将始终打印 1。但是在 ARM 或 POWER 多处理器上运行的程序集的直接逐行转换可以打印 0。此外,无论底层硬件是什么,标准编译器优化可能会使该程序打印 0 或进入无限循环。
“这要看情况”并不是一个幸福的结局。程序员需要一个明确的答案来确定程序是否可以继续与新硬件和新编译器一起工作。硬件设计人员和编译器开发人员需要一个明确的答案,以了解执行给定程序时硬件和编译代码的行为精确程度。因为这里的主要问题是存储在内存中的数据更改的可见性和一致性,所以该契约称为内存一致性模型或简称为内存模型。
最初,内存模型的目标是定义硬件可以保证程序员编写汇编代码。在这种设置中,编译器不参与其中。二十五年前,人们开始尝试编写内存模型,定义 Java 或 C++ 等高级编程语言向用该语言编写代码的程序员提供的保证。在模型中包含编译器会使定义合理模型的工作变得更加复杂。
这是分别关于硬件内存模型和编程语言内存模型的两篇文章中的第一篇。我写这些文章的目的是为讨论我们可能想要在 Go 的内存模型中进行的潜在更改奠定基础。但要了解 Go 的现状以及我们可能想要走向的方向,首先我们必须了解其他硬件内存模型和语言内存模型目前的状况以及它们实现这一目标所采取的不稳定路径。
再次强调,这篇文章是关于硬件的。假设我们正在为多处理器计算机编写汇编语言。程序员需要计算机硬件提供哪些保证才能编写出正确的程序?四十多年来,计算机科学家一直在寻找这个问题的良好答案。
Sequential Consistency 顺序一致性
Leslie Lamport 1979 年的论文“ How to Make a Multiprocessor Computer That Correctly Executions Multiprocess Programs ”介绍了顺序一致性的概念:
为此类计算机设计和证明多进程算法的正确性的常规方法假设满足以下条件:任何执行的结果都相同,就好像所有处理器的操作都按某种顺序执行一样,并且操作每个单独的处理器按照其程序指定的顺序出现在该序列中。满足此条件的多处理器将被称为顺序一致。
今天,我们不仅讨论计算机硬件,还讨论保证顺序一致性的编程语言,此时程序唯一可能的执行对应于线程操作到顺序执行的某种交错。顺序一致性通常被认为是理想的模型,是程序员最自然使用的模型。它允许您假设程序按照它们在页面上出现的顺序执行,并且各个线程的执行只是按某种顺序交错,但不会以其他方式重新排列。
人们可能会合理地质疑顺序一致性是否应该是理想的模型,但这超出了本文的范围。我只想指出,在今天和 1979 年一样,考虑所有可能的线程交错仍然是“设计和证明多进程算法正确性的惯用方法”。在这四十年里,没有任何东西可以取代它。
之前我问过这个程序是否可以打印0:
// Thread 1 // Thread 2
x = 1; while(done == 0) { /* loop */ }
done = 1; print(x); 为了使程序更容易分析,让我们删除循环和打印,并询问读取共享变量的可能结果:
Litmus Test: Message Passing石蕊测试:消息传递Can this program see r1 = 1, r2 = 0?该程序可以看到r1 = 1 , r2 = 0吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x 我们假设每个示例都以所有共享变量设置为零开始。因为我们试图确定允许硬件执行的操作,所以我们假设每个线程都在其自己的专用处理器上执行,并且没有编译器对线程中发生的事情重新排序:列表中的指令是处理器执行的指令。名称r N表示线程局部寄存器,而不是共享变量,我们询问在执行结束时是否可以对线程局部寄存器进行特定设置。
这种关于示例程序的执行结果的问题称为石蕊测试。因为它有一个二元答案——这个结果可能还是不可能?——石蕊测试为我们提供了一种区分内存模型的清晰方法:如果一个模型允许特定的执行,而另一个模型则不允许,那么这两个模型显然是不同的。不幸的是,正如我们稍后将看到的,特定模型对特定石蕊测试给出的答案往往令人惊讶。
如果此石蕊测试的执行顺序一致,则只有六种可能的交错:
由于没有交错以r1 = 1 、 r2 = 0结束,因此该结果是不允许的。也就是说,在顺序一致的硬件上,石蕊测试(该程序能否看到r1 = 1 , r2 = 0吗?)的答案是“否” 。
顺序一致性的一种良好心理模型是想象所有处理器直接连接到同一共享内存,该内存可以一次服务一个线程的读取或写入请求。不涉及缓存,因此每次处理器需要读取或写入内存时,该请求都会发送到共享内存。一次使用一次的共享内存对所有内存访问的执行施加了顺序:顺序一致性。
(本文中的三个内存模型硬件图改编自 Marranget等人的“ ARM 和 POWER 宽松内存模型教程简介”。)
该图是顺序一致机器的模型,而不是构建顺序一致机器的唯一方法。事实上,可以使用多个共享内存模块和缓存来构建顺序一致的机器,以帮助预测内存获取的结果,但顺序一致意味着机器的行为必须与该模型没有区别。如果我们只是试图理解顺序一致执行的含义,我们可以忽略所有这些可能的实现复杂性并考虑这一模型。
不幸的是,对于我们程序员来说,放弃严格的顺序一致性可以让硬件更快地执行程序,因此所有现代硬件都以各种方式偏离顺序一致性。事实证明,准确定义特定硬件的偏差是相当困难的。本文使用当今广泛使用的硬件中存在的两种内存模型作为两个示例:x86 的内存模型以及 ARM 和 POWER 处理器系列的内存模型。
x86 Total Store Order (x86-TSO)x86 总商店订单 (x86-TSO)
现代 x86 系统的内存模型对应于以下硬件图:
所有处理器仍然连接到单个共享内存,但每个处理器都会在本地写入队列中排队写入该内存。当写入到达共享内存时,处理器继续执行新指令。一个处理器上的内存读取在查阅主内存之前会查阅本地写入队列,但它无法看到其他处理器上的写入队列。其效果是处理器先于其他处理器看到自己的写入。但是,这一点非常重要,所有处理器都同意写入(存储)到达共享内存的(总)顺序,该模型因此得名:总存储顺序,或 TSO。当写入到达共享内存时,任何处理器上的任何未来读取都将看到它并使用该值(直到它被稍后的写入覆盖,或者可能被来自另一个处理器的缓冲写入覆盖)。
写入队列是标准的先进先出队列:内存写入按照处理器执行的顺序应用于共享内存。因为写入顺序由写入队列保留,并且因为其他处理器立即看到对共享内存的写入,所以我们之前考虑的消息通过试金石测试具有与之前相同的结果: r1 = 1 , r2 = 0仍然不可能。
石蕊测试:消息传递,该程序可以看到r1 = 1 , r2 = 0吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x 在顺序一致的硬件上:否。在 x86(或其他 TSO)上:否。
写入队列保证线程 1 在y之前将x写入内存,并且关于内存写入顺序(总存储顺序)的系统范围协议保证线程 2 在了解y之前了解x的新值新的价值。因此,如果r2 = x也看到新的x ,则r1 = y不可能看到新的y 。存储顺序在这里至关重要:线程 1 在y之前写入x ,因此线程 2 一定不能在写入x之前看到对y的写入。
在这种情况下,顺序一致性和 TSO 模型是一致的,但它们对其他石蕊检验的结果存在分歧。例如,这是区分两种模型的常见示例:
测试:写入队列(也称为存储缓冲区)该程序可以看到r1 = 0 , r2 = 0吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x 在顺序一致的硬件上:否。在 x86(或其他 TSO)上:是的!
在任何顺序一致执行中, x = 1或y = 1必须首先发生,然后另一个线程中的读取必须观察它,因此r1 = 0 、 r2 = 0是不可能的。但在 TSO 系统上,可能会出现以下情况:线程 1 和线程 2 都将其写入排队,然后在任一写入写入内存之前从内存中读取,因此两次读取都看到零。
这个例子可能看起来很人为,但是使用两个同步变量确实发生在众所周知的同步算法中,例如Dekker 算法或Peterson 算法,以及临时方案。如果一个线程没有看到另一个线程的所有写入,它们就会中断。
为了修复依赖于更强内存排序的算法,非顺序一致的硬件提供了称为内存屏障(或栅栏)的显式指令,可用于控制排序。我们可以添加内存屏障,以确保每个线程在开始读取之前将其先前的写入刷新到内存:
// Thread 1 // Thread 2
x = 1 y = 1
barrier barrier
r1 = y r2 = x 添加障碍后, r1 = 0 、 r2 = 0再次不可能,并且 Dekker 或 Peterson 的算法将正确工作。障碍有很多种;详细信息因系统而异,超出了本文的范围。重点只是障碍的存在,并为程序员或语言实现者提供了一种在程序的关键时刻强制顺序一致的行为的方法。
最后一个例子,让我们了解为什么该模型被称为商店总订单。在该模型中,有本地写入队列,但读取路径上没有缓存。一旦写入到达主内存,所有处理器不仅同意该值存在,而且还同意该值相对于其他处理器的写入何时到达。考虑这个试金石:
测试:独立写入的独立读取 (IRIW)该程序可以看到r1 = 1 、 r2 = 0 、 r3 = 1 、 r4 = 0吗?(线程 3 和 4 可以看到x和y以不同的顺序变化吗?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 y = 1 r1 = x r3 = y
r2 = y r4 = x On sequentially consistent hardware: no.在顺序一致的硬件上:否。On x86 (or other TSO): no.在 x86(或其他 TSO)上:否。
If Thread 3 sees x change before y, can Thread 4 see y change before x? For x86 and other TSO machines, the answer is no: there is a total order over all stores (writes) to main memory, and all processors agree on that order, subject to the wrinkle that each processor knows about its own writes before they reach main memory.如果线程 3 在y之前看到x发生变化,那么线程 4 能否在x之前看到y发生变化?对于 x86 和其他 TSO 机器,答案是否定的:对主内存的所有存储(写入)都有一个总顺序,并且所有处理器都同意该顺序,但受到每个处理器在到达之前了解自己的写入的影响主存储器。
The Path to x86-TSO x86-TSO 之路
x86-TSO 模型看起来相当干净,但那里的道路充满了路障和错误的转弯。在 20 世纪 90 年代,第一批 x86 多处理器的手册几乎没有提及硬件提供的内存模型。
作为问题的一个例子,Plan 9 是第一个在 x86 上运行的真正的多处理器操作系统(没有全局内核锁)之一。 1997 年,在移植到多处理器 Pentium Pro 的过程中,开发人员偶然发现了意外行为,这些行为归结为写入队列试金石。一段微妙的同步代码假设r1 = 0 、 r2 = 0是不可能的,但它确实发生了。更糟糕的是,英特尔手册对内存型号的详细信息含糊其辞。
在回应邮件列表建议“对锁持保守态度比相信硬件设计者会按照我们的预期行事更好”时,Plan 9 开发人员之一很好地解释了这个问题:
我当然同意。我们将在多处理器中遇到更宽松的排序。问题是,硬件设计者认为什么是保守的?在锁定部分的开头和结尾强制互锁对我来说似乎相当保守,但我显然没有足够的想象力。 Pro 手册详细描述了缓存以及使它们保持一致的因素,但似乎并不关心有关执行或读取顺序的任何详细信息。事实是,我们无法知道自己是否足够保守。
在讨论中,Intel 的一位架构师对内存模型进行了非正式的解释,指出理论上即使是多处理器 486 和 Pentium 系统也可以产生r1 = 0 、 r2 = 0的结果,而 Pentium Pro 只是拥有更大的管道并编写更频繁地暴露行为的队列。
英特尔架构师还写道:
宽松地说,这意味着源自系统中任何一个处理器的事件顺序(如其他处理器所观察到的那样)始终相同。然而,不同的观察者可以对来自两个或多个处理器的事件的交错存在不同意见。未来的英特尔处理器将实现相同的内存排序模型。
“允许不同的观察者对两个或多个处理器的事件交错存在不同意见”的说法是,IRIW 试金石测试的答案可以在 x86 上回答“是”,尽管在上一节中我们看到 x86 的答案“不。”怎么可能呢?
答案似乎是,英特尔处理器从未真正对这一试金石回答“是”,但当时英特尔架构师不愿意为未来的处理器做出任何保证。架构手册中的文字很少,几乎没有任何保证,这使得编程非常困难。
9号计划的讨论并不是一个孤立的事件。从 1999 年 11 月下旬开始, Linux 内核开发人员在他们的邮件列表上花费了一百多条消息,对英特尔处理器提供的保证也产生了类似的困惑。
为了应对在接下来的十年中越来越多的人遇到这些困难,英特尔的一组架构师承担了为当前和未来的处理器写下有关处理器行为的有用保证的任务。第一个成果是 2007 年 8 月发布的《 Intel 64 架构内存排序白皮书》,旨在“让软件编写者清楚地了解不同内存访问指令序列可能产生的结果”。 AMD 同年晚些时候在AMD64 架构程序员手册修订版 3.14中发布了类似的描述。这些描述基于名为“总锁定顺序 + 因果一致性”(TLO+CC)的模型,故意弱于 TSO。在公开演讲中,英特尔架构师表示,TLO+CC“已达到要求,但还不够强大”。特别是,该模型保留了 x86 处理器对 IRIW 试金石回答“是”的权利。不幸的是,内存屏障的定义不够强大,无法重建顺序一致的内存语义,即使在每条指令之后都有一个屏障。更糟糕的是,研究人员观察到实际的 Intel x86 硬件违反了 TLO+CC 模型。例如:
石蕊测试:该程序可以以r1 = 1 、 r2 = 0 、 x = 1结束吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = x x = 2
r2 = y 在顺序一致的硬件上:否。在 x86 TLO+CC 型号 (2007) 上:否。在实际的 x86 硬件上:是的!在 x86 TSO 型号上:是的! (x86-TSO 论文中的示例。)
2008 年晚些时候对 Intel 和 AMD 规范的修订保证了对 IRIW 情况的“否”并加强了内存屏障,但仍然允许意外行为,这些行为似乎不会在任何合理的硬件上出现。例如:
石蕊测试:n5该程序可以以
r1=2,r2=1结束吗?
// Thread 1 // Thread 2
x = 1 x = 2
r1 = x r2 = x 在顺序一致的硬件上:否。关于 x86 规范 (2008):是的!在实际的 x86 硬件上:否。在 x86 TSO 型号上:否。 (x86-TSO 论文中的示例。)
为了解决这些问题,欧文斯等人。基于早期的SPARCv8 TSO 模型,提出了 x86-TSO 模型。当时他们声称“据我们所知,x86-TSO 是健全的,足够强大,可以进行上述编程,并且大致符合供应商的意图。”几个月后,英特尔和 AMD 发布了广泛采用此模型的新手册。
看来所有英特尔处理器从一开始就实现了 x86-TSO,尽管英特尔花了十年时间才决定致力于这一点。回想起来,很明显,英特尔和 AMD 架构师正在努力解决如何编写内存模型,为未来的处理器优化留出空间,同时仍然为编译器编写者和汇编语言程序员提供有用的保证。 “按照要求强,但不要更强”是一个困难的平衡行为。
ARM/POWER Relaxed Memory Model
现在让我们看看一种更加宽松的内存模型,即 ARM 和 POWER 处理器上的模型。在实现层面上,这两个系统在很多方面都有所不同,但保证的内存一致性模型大致相似,并且比 x86-TSO 甚至 x86-TLO+CC 弱很多。
ARM 和 POWER 系统的概念模型是每个处理器读取和写入其自己的完整内存副本,并且每次写入独立地传播到其他处理器,并在写入传播时允许重新排序。
”这里,没有商店总订单。未示出,每个处理器还被允许推迟读取直到它需要结果:读取可以被延迟到稍后的写入之后。在这个宽松的模型中,迄今为止我们看到的每一个试金石的答案都是“是的,这确实可能发生”。
对于通过石蕊测试的原始消息,单个处理器的写入重新排序意味着线程 1 的写入可能无法被其他线程以相同的顺序观察到:
石蕊测试:消息传递,该程序可以看到r1 = 1 , r2 = 0吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x 在顺序一致的硬件上:否。在 x86(或其他 TSO)上:否。在 ARM/POWER:是的!
在 ARM/POWER 模型中,我们可以认为线程 1 和线程 2 各自拥有自己独立的内存副本,并且写入可以按任何顺序在内存之间传播。如果线程 1 的内存在发送x的更新之前将y的更新发送到线程 2,并且如果线程 2 在这两个更新之间执行,它确实会看到结果r1 = 1 , r2 = 0 。
该结果表明 ARM/POWER 内存模型比 TSO 弱:它对硬件的要求较少。 ARM/POWER 模型仍然承认 TSO 所做的各种重新排序:
测试:存储缓冲,该程序可以看到r1 = 0 , r2 = 0吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
在顺序一致的硬件上:否。在 x86(或其他 TSO)上:是的!在 ARM/POWER:是的!
在 ARM/POWER 上,对x和y写入可能会写入本地内存,但当读取发生在相反的线程上时尚未传播。
这是显示 x86 拥有总商店订单意味着什么的试金石:
该程序可以看到r1 = 1 、 r2 = 0 、 r3 = 1 、 r4 = 0吗?(线程 3 和 4 可以看到x和y以不同的顺序变化吗?)
// Thread 1 // Thread 2 // Thread 3 // Thread 4
x = 1 y = 1 r1 = x r3 = y
r2 = y r4 = x
在顺序一致的硬件上:否。在 x86(或其他 TSO)上:否。在 ARM/POWER:是的!
在 ARM/POWER 上,不同的线程可能会以不同的顺序了解不同的写入。不能保证它们就到达主内存的写入总顺序达成一致,因此线程 3 可以在y之前看到x更改,而线程 4 可以在x之前看到y更改。
作为另一个示例,ARM/POWER 系统具有可见的内存读取(加载)缓冲或重新排序,如以下试金石所示:
石蕊测试:负载缓冲,该程序可以看到r1 = 1 , r2 = 1吗?(每个线程的读取可以在另一个线程的写入之后发生吗?)
// Thread 1 // Thread 2
r1 = x r2 = y
y = 1 x = 1
在顺序一致的硬件上:否。在 x86(或其他 TSO)上:否。在 ARM/POWER:是的!
任何顺序一致的交错必须从线程 1 的r1 = x或线程 2 的r2 = y开始。该读取必须看到零,使得结果r1 = 1 , r2 = 1不可能。然而,在 ARM/POWER 内存模型中,允许处理器将读取延迟到指令流中稍后的写入之后,以便y = 1和x = 1在两次读取之前执行。
尽管 ARM 和 POWER 内存模型都允许这种结果,但 Marranget等人。据报道(2012 年)只能在 ARM 系统上凭经验重现它,而不能在 POWER 上重现。在这里,模型与现实之间的分歧开始发挥作用,就像我们检查 Intel x86 时一样:实现比技术保证更强大的模型的硬件会鼓励对更强行为的依赖,并意味着未来较弱的硬件将破坏程序,无论是否有效。
与 TSO 系统一样,ARM 和 POWER 也有障碍,我们可以将其插入到上面的示例中以强制顺序一致的行为。但显而易见的问题是,没有障碍的 ARM/POWER 是否完全排除了任何行为。任何试金石的答案是否都是“不,这不可能发生”?当我们专注于单个内存位置时,它可以。
这是一个试金石,测试即使在 ARM 和 POWER 上也无法发生的事情:
石蕊测试:连贯性Can this program see
r1=1,r2=2,r3=2,r4=1?该程序可以看到r1=1、r2=2、r3=2、r4=1吗?(线程 3 能否在x=2之前看到x=1,而线程 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 上:否。
此石蕊测试与前一个类似,但现在两个线程都写入单个变量x而不是两个不同的变量x和y 。线程 1 和 2 将冲突的值 1 和 2 写入x ,而线程 3 和线程 4 都读取x两次。如果线程 3 看到x = 1被x = 2覆盖,那么线程 4 是否可以看到相反的情况?
答案是否定的,即使在 ARM/POWER 上也是如此:系统中的线程必须就写入单个内存位置的总顺序达成一致。也就是说,线程必须同意哪些写入会覆盖其他写入。这种性质称为一致性。如果没有一致性属性,处理器要么不同意内存的最终结果,要么报告内存位置从一个值翻转到另一个值,然后又回到第一个值。对这样的系统进行编程是非常困难的。
我故意忽略了 ARM 和 POWER 弱内存模型中的许多微妙之处。有关更多详细信息,请参阅Peter Sewell 关于该主题的任何论文。此外,ARMv8 通过使其成为“多副本原子”来增强内存模型,但我不会在这里花太多时间来解释这到底意味着什么。
有两点需要注意。首先,这里有大量令人难以置信的微妙之处,这是非常执着、非常聪明的人十多年来学术研究的主题。我并不声称自己完全理解这一切。这不是我们应该希望向普通程序员解释的东西,也不是我们在调试普通程序时希望保持清晰的东西。其次,允许的情况与观察到的情况之间的差距会导致未来发生不幸的意外。如果当前的硬件没有表现出全部允许的行为——特别是当一开始就很难推理出允许的行为时!——那么不可避免地,编写的程序会意外地依赖于实际硬件的更受限制的行为。如果新芯片的行为限制较少,那么破坏程序的新行为在技术上是硬件内存模型允许的(也就是说,该错误在技术上是你的错)这一事实并不能带来什么安慰。这不是编写程序的方法。
Weak Ordering and Data-Race-Free Sequential Consistency
到目前为止,我希望您确信硬件细节是复杂而微妙的,而不是您每次编写程序时都想解决的问题。相反,它将有助于识别“如果遵循这些简单规则,您的程序只会产生结果,就像通过某种顺序一致的交错一样”的快捷方式。 (我们仍在谈论硬件,因此我们仍在谈论交错的各个汇编指令。)
Sarita Adve 和 Mark Hill 在他们 1990 年的论文“ Weak Ordering – A New Definition ”中准确地提出了这种方法。他们将“弱有序”定义如下。
让同步模型成为一组对内存访问的约束,指定需要如何以及何时进行同步。当且仅当硬件与遵守同步模型的所有软件顺序一致时,硬件相对于同步模型是弱排序的。
尽管他们的论文是关于当时的硬件设计(不是 x86、ARM 和 POWER),但将讨论提升到具体设计之上的想法使得该论文至今仍具有相关性。
我之前说过“有效的优化不会改变有效程序的行为”。这些规则定义了有效的含义,然后任何硬件优化都必须保持这些程序像在顺序一致的机器上一样工作。当然,有趣的细节是规则本身,即定义程序有效的约束。
Adve 和 Hill 提出了一种同步模型,他们称之为无数据竞争 (DRF) 。该模型假设硬件具有与普通内存读取和写入分开的内存同步操作。普通内存读取和写入可能会在同步操作之间重新排序,但可能不会在同步操作之间移动。 (也就是说,同步操作也充当重新排序的障碍。)如果对于所有理想化的顺序一致执行,从不同线程对同一位置的任何两个普通内存访问要么都是,则称程序是无数据争用的读取或通过同步操作分开,迫使一个操作在另一个操作之前发生。
让我们看一些例子,取自 Adve 和 Hill 的论文(为了演示而重新绘制)。这是一个单线程,它执行变量x的写入,然后执行同一变量的读取。
垂直箭头标记单个线程内的执行顺序:先进行写入,然后进行读取。该程序中没有竞争,因为一切都在单个线程中。
相比之下,这个双线程程序中存在竞争:
这里,线程 2 在不与线程 1 协调的情况下写入 x。线程 2 的写入与线程 1 的写入和读取都发生竞争。如果线程 2 正在读取 x,而不是写入 x,则程序将仅在写入之间发生一场竞争。线程 1 中的读取和线程 2 中的读取。每次竞争至少涉及一次写入:两个不协调的读取不会相互竞争。
为了避免竞争,我们必须添加同步操作,这会强制共享同步变量的不同线程上的操作之间的顺序。如果同步 S(a)(同步变量 a,用虚线箭头标记)强制线程 2 的写入在线程 1 完成后发生,则竞争将被消除:
现在线程 2 的写入不能与线程 1 的操作同时发生。
如果线程 2 只是读取,我们只需要与线程 1 的写入同步。两个读取仍然可以同时进行:
线程可以通过一系列同步来排序,甚至可以使用中间线程。该程序没有竞赛:
另一方面,同步变量的使用本身并不能消除竞争:有可能错误地使用它们。这个程序确实有一个竞赛:
线程 2 的读取与其他线程中的写入正确同步(它肯定发生在两者之后),但两个写入本身并不同步。该程序并非无数据竞争。
Adve 和 Hill 将弱排序描述为“软件和硬件之间的契约”,具体来说,如果软件避免数据竞争,那么硬件的行为就好像它是顺序一致的,这比我们在前面部分中研究的模型更容易推理。但硬件如何才能满足合同的要求呢?
Adve 和 Hill 证明了硬件“通过 DRF 弱排序”,这意味着只要满足一组特定的最低要求,它就可以像按顺序一致的排序一样执行无数据争用的程序。我不会详细介绍细节,但要点是,在 Adve 和 Hill 论文发表之后,硬件设计人员有了一个有证据支持的菜谱:做这些事情,你就可以断言你的硬件将按顺序出现无数据竞争的程序。事实上,假设同步操作得到适当的实现,大多数宽松的硬件确实会以这种方式运行,并且会继续这样做。 Adve 和 Hill 最初关注的是 VAX,但当然 x86、ARM 和 POWER 也可以满足这些限制。系统保证无数据争用的程序出现顺序一致性的想法通常缩写为DRF-SC 。
DRF-SC 标志着硬件内存模型的一个转折点,为硬件设计者和软件作者(至少是那些用汇编语言编写软件的人)提供了明确的策略。正如我们将在下一篇文章中看到的,高级编程语言的内存模型问题没有那么简洁的答案。
本系列的下一篇文章是关于编程语言内存模型的。
编程语言内存模型
编程语言内存模型回答了并行程序可以依靠哪些行为在线程之间共享内存的问题。例如,考虑使用类 C 语言编写的这个程序,其中x和done都以零开始。
// 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。编译器经常根据优化启发式甚至哈希表或其他中间数据结构结束的方式重新排序程序读取和写入生成代码时会遍历 up 。线程 1 的编译代码最终可能会在done后写入x,而不是before,或者线程 2 的编译代码最终可能会在循环之前读取x。
考虑到这个程序的损坏程度,显而易见的问题是如何修复它。
现代语言以原子变量或原子操作的形式提供特殊功能,以允许程序同步其线程。如果我们done一个原子变量(或者使用原子操作来操作它,在采用这种方法的语言中),那么我们的程序就保证完成并打印 1。使done原子化有很多效果:
- 线程 1 的已编译代码必须确保对
x的写入已完成,并且在对done的写入可见之前对其他线程可见。 - 线程 2 的编译代码必须在循环的每次迭代中(重新)读取
done。 - 线程 2 的编译代码必须在
done读取后从x读取。 - 编译后的代码必须采取一切必要措施来禁用可能重新引入任何这些问题的硬件优化。
使done原子化的最终结果是程序按照我们想要的方式运行,成功地将x中的值从线程1传递到线程2。
在原始程序中,编译器对代码进行重新排序后,线程 1 可能会在线程 2 读取 x 的同时写入x 。这是一场数据竞赛。在修改后的程序中,原子变量done用于同步对x访问:现在线程 1 不可能在线程 2 读取 x 的同时写入x 。该程序是无数据竞争的。一般来说,现代语言保证无数据争用的程序始终以顺序一致的方式执行,就好像来自不同线程的操作任意但不重新排序地交错到单个处理器上。这是硬件内存模型中的 DRF-SC 属性,在编程语言上下文中采用。
顺便说一句,这些原子变量或原子操作更合适地称为“同步原子”。确实,这些操作在数据库意义上是原子的,允许同时读取和写入,其行为就像按某种顺序顺序运行:普通变量上的竞争在使用原子时不是竞争。但更重要的是,原子同步程序的其余部分,提供一种消除非原子数据竞争的方法。不过,标准术语是简单的“原子”,所以这就是本文所使用的。除非另有说明,请记住将“原子”理解为“同步原子”。
编程语言内存模型指定了程序员和编译器所需内容的确切细节,作为它们之间的契约。上面概述的一般特征基本上适用于所有现代语言,但直到最近,事情才趋于一致:在 2000 年代初期,变化明显更多。即使在今天,不同语言在二阶问题上也存在显着差异,包括:
- 原子变量本身的顺序保证是什么?
- 变量可以通过原子操作和非原子操作访问吗?
- 除了原子之外还有同步机制吗?
- 是否存在不同步的原子操作?
- 包含比赛的项目有任何保证吗?
经过一些准备工作后,本文的其余部分将研究不同的语言如何回答这些问题和相关问题,以及它们实现这一目标所采取的路径。这篇文章还强调了这一过程中的许多错误开始,以强调我们仍然在很大程度上了解什么有效,什么无效。
硬件、Litmus 测试、发生之前和 DRF-SC
在我们了解任何特定语言的详细信息之前,我们需要牢记硬件内存模型的经验教训的简要总结。
不同的体系结构允许不同数量的指令重新排序,因此在多个处理器上并行运行的代码可以根据体系结构具有不同的允许结果。黄金标准是顺序一致性,其中任何执行都必须表现得好像在不同处理器上执行的程序只是按某种顺序交错到单个处理器上。该模型对于开发人员来说更容易推理,但目前还没有重要的架构提供它,因为较弱的保证带来了性能提升。
比较不同的内存模型很难做出完全笼统的陈述。相反,它可以帮助您专注于特定的测试用例,称为石蕊测试。如果两个记忆模型对于给定的石蕊测试允许不同的行为,这证明它们是不同的,并且通常可以帮助我们了解至少对于该测试用例,一个模型是否比另一个更弱或更强。例如,这是我们之前检查的程序的石蕊测试形式:
石蕊测试:消息传递该程序可以看到
r1=1,r2=0吗?
// Thread 1 // Thread 2
x = 1 r1 = y
y = 1 r2 = x
在顺序一致的硬件上:否。在 x86(或其他 TSO)上:否。
关于 ARM/POWER:是的!
在任何使用普通变量的现代编译语言中:是的!
与上一篇文章一样,我们假设每个示例都以所有共享变量设置为零开始。名称r N表示私有存储,如寄存器或函数局部变量;其他名称(例如x和y是不同的共享(全局)变量。我们询问在执行结束时是否可以对寄存器进行特定设置。在回答硬件的石蕊测试时,我们假设没有编译器来重新排序线程中发生的事情:列表中的指令直接转换为提供给处理器执行的汇编指令。
结果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 的先行关系,因此线程 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 宽松内存模型一样弱,因为编译程序无法通过消息传递试金石测试。事实上,对已编译程序的保证较弱。
在硬件帖子中,我们将一致性视为 ARM/POWER 架构确实保证的一个示例:
石蕊测试:连贯性该程序可以看到
r1=1、r2=2、r3=2、r4=1吗?(线程 3 能否在
x=2之前看到x=1,而线程 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 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;
在此程序中,常见子表达式消除会注意到px被计算了两次,并将最后一行优化为k = i 。但是,如果p和q指向同一个对象,并且另一个线程在读取i和j之间写入px ,则将旧值i重用于k会违反一致性:读入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 所著的“ The Java Memory Model ”(2005 年),其他详细信息请参见Manson 的博士论文。 。论文。新模型遵循 DRF-SC 方法:保证无数据争用的 Java 程序以顺序一致的方式执行。
同步原子和其他操作
正如我们之前所看到的,要编写一个无数据争用的程序,程序员需要同步操作,该操作可以建立发生在边缘之前,以确保一个线程不会在另一个线程读取或写入非原子变量的同时写入该变量。在Java中,主要的同步操作有:
- 线程的创建发生在线程中的第一个操作之前。
- 互斥体m的解锁发生在m的任何后续锁定之前。
- 对 易失性变量v 的写入发生在v的任何后续读取之前。
“随后”是什么意思? Java 定义所有锁定、解锁和易失性变量访问的行为就好像它们发生在某种顺序一致的交错中,从而给出整个程序中所有这些操作的总顺序。 “随后”是指在总顺序中较晚的部分。也就是说:锁定、解锁和易失性变量访问的总顺序定义了后续的含义,然后后续定义了特定执行创建的发生之前边缘,然后发生之前边缘定义该特定执行是否具有数据竞赛。如果不存在竞争,则执行将以顺序一致的方式运行。
事实上,易失性访问必须按照某种全序进行操作,这意味着在存储缓冲区 litmus test中,您不能以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上使用易失性:没有。
在Java中,对于易失性变量x和y ,读取和写入不能重新排序:一个写入必须排在第二位,并且第二个写入之后的读取必须看到第一个写入。如果我们没有顺序一致的要求(例如,仅要求易失性一致),则两次读取可能会错过写入。
这里有一个重要但微妙的点:所有同步操作的总顺序与先发生关系是分开的。在程序中的每个锁定、解锁或易失性变量访问之间,在一个方向或另一个方向上存在发生之前边缘是不正确的:您只能从写入到观察写入的读取获得发生之前边缘。例如,不同互斥锁的锁定和解锁在它们之间没有发生前边缘,不同变量的易失性访问也没有,即使这些操作总的来说必须表现得好像遵循单个顺序一致的交错一样。
活泼程序的语义
DRF-SC 仅保证程序的行为顺序一致,没有数据竞争。新的 Java 内存模型与原始内存模型一样,定义了 racy 程序的行为,原因如下:
- 支持Java的一般安全性和安全保证。
- 为了让程序员更容易发现错误。
- 使攻击者更难利用问题,因为竞赛可能造成的损害更加有限。
- 让程序员更清楚他们的程序是做什么的。
新模型没有依赖一致性,而是重用了发生之前关系(已用于确定程序是否存在竞争)来决定竞争读写的结果。
Java 的具体规则是,对于字大小或更小的变量,对变量(或字段) x的读取必须看到对x 的单个写入所存储的值。如果r不在w之前发生,则可以通过读取r来观察对x 的写入。这意味着r可以观察在r之前发生的写入(但在r之前也不会被覆盖),并且它可以观察与r竞争的写入。
以这种方式使用happens-before,结合可以建立新的happens-before边缘的同步原子(易失性),是对原始Java内存模型的重大改进。它为程序员提供了更有用的保证,并且明确允许进行大量重要的编译器优化。这项工作至今仍然是 Java 的内存模型。也就是说,它仍然不太正确:使用“happens-before”来尝试定义活泼程序的语义存在问题。
发生在之前并不排除不连贯的情况
定义程序语义时发生之前的第一个问题与一致性有关(再次!)。 (以下示例取自 Jaroslav Ševčík 和 David Aspinall 的论文“ On the Validity of Program Transformations in the Java Memory Model ”(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 的论文。
发生在之前并不排除因果关系
事实证明,最后一个例子是一个简单的问题。这是一个更难的问题。考虑这个试金石,使用普通(非易失性)Java 变量:
石蕊测试:凭空而来的活泼值该程序可以看到
r1=42,r2=42吗?
// Thread 1 // Thread 2
r1 = x r2 = y
y = r1 x = r2
(显然不是!)
与往常一样,该程序中的所有变量一开始都归零,然后该程序在一个线程中有效运行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的读取与其他线程中的写入竞争——因此我们可能会认为这是一个不正确的程序。但这里有一个无数据竞争的版本:
石蕊测试:凭空而来的非活跃值该程序可以看到
r1=42,r2=42吗?
// Thread 1 // Thread 2
r1 = x r2 = y
if (r1 == 42) if (r2 == 42)
y = r1 x = r2
(显然不是!)
由于x和y从零开始,任何顺序一致的执行都不会执行写入,因此该程序没有写入,因此不存在竞争。不过,再一次,单独发生之前并不能排除这样的可能性:假设r1 = x看到赛车不完全写入,然后从该假设出发,条件最终都为真,并且x和y都是 42在最后。这是另一种无中生有的价值,但这次是在一个没有比赛的节目中。任何保证 DRF-SC 的模型都必须保证该程序仅在末尾看到全零,但发生之前并没有解释原因。
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 或着火
与 Java 不同,C++ 不为存在竞争的程序提供任何保证。任何有竞争的程序都会陷入“未定义的行为”。程序执行的前几微秒内的竞争访问可能会在数小时或数天后导致任意错误行为。这通常被称为“DRF-SC 或 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处的代码很复杂,则需要重用寄存器。编译器可能会决定在到达 switch 语句时从全局x再次加载i ,而不是将i的当前值溢出到函数堆栈。结果是,在if主体的中间, i < 2可能不再为 true。如果编译器执行类似使用由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;
然后像使用普通变量一样使用done ,就像在Java中一样。或者我们可以声明一个普通的int done;然后使用
atomic_store(&done, 1);
和
while(atomic_load(&done) == 0) { /* loop */ }
来访问它。无论哪种方式, 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 测试:存储缓冲该程序可以看到
r1=0,r2=0吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y r2 = x
在顺序一致的硬件上:否。在 x86(或其他 TSO)上:是的!
关于 ARM/POWER:是的!
在Java上(使用易失性):没有。
在 C++11(顺序一致原子)上:否。
在 C++11 上(获取/释放原子):是的!
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++ 获取/释放原子,它们可以。而且他们可能只会花费一小部分时间,使得错误很难重现和诊断。 (更糟糕的是,在某些架构(例如 64 位 ARM)上,实现获取/释放原子的最佳方法是顺序一致的原子,因此您可能编写在 64 位 ARM 上运行良好的代码,但在移植到其他架构时才发现它是不正确的。系统。)
根据这种理解,“获取/释放”对于这些原子来说是一个不幸的名称,因为顺序一致的原子执行同样多的获取和释放操作。它们的不同之处在于顺序一致性的损失。最好将这些称为“相干”原子。为时已晚。
轻松原子
C++ 并没有止步于仅仅连贯的获取/释放原子。它还引入了非同步原子,称为宽松原子( memory_order_relaxed )。这些原子根本没有同步效果——它们不创建发生在边缘之前——而且它们也根本没有顺序保证。事实上,宽松原子读/写和普通读/写之间没有区别,只是宽松原子上的竞争不被视为竞争并且不会着火。
修订后的 Java 内存模型的大部分复杂性源于通过数据竞争定义程序的行为。如果 C++ 采用 DRF-SC 或 Catch Fire(有效地禁止具有数据竞争的程序)意味着我们可以丢弃之前看到的所有那些奇怪的示例,那么 C++ 语言规范最终会比 Java 语言规范更简单,那就太好了。不幸的是,包括宽松的原子最终保留了所有这些问题,这意味着 C++11 规范最终并不比 Java 的规范简单。
与 Java 的内存模型一样,C++11 内存模型也以错误告终。考虑之前的无数据竞争程序:
石蕊测试:凭空而来的非活跃值该程序可以看到
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(宽松原子):是的!
Viktor Vafeiadis 等人在论文“ Common Compiler Optimizations are Invalid in the C11 Memory Model and how we can do about it ”(2015 年)中表明,C++11 规范保证该程序必须以x和y设置为结束当x和y是普通变量时为零。但如果x和y是松弛原子,那么严格来说,C++11 规范并不排除r1和r2可能都以 42 结束。(惊讶!)
有关详细信息,请参阅该论文,但在较高层面上,C++11 规范有一些正式规则试图禁止无中生有的值,并结合一些模糊的词语来阻止其他类型的有问题的值。这些正式规则是问题所在,因此 C++14 放弃了它们,只留下了模糊的词语。引用删除它们的理由,C++11 的表述被证明“既不够充分,因为它基本上不可能推理带有memory_order_relaxed的程序,而且严重有害,因为它可以说memory_order_relaxed在ARM 和 POWER 等架构。”
回顾一下,Java 试图正式排除所有非因果执行,但失败了。然后,凭借 Java 的后见之明,C++11 试图正式排除一些非因果执行,但也失败了。 C++14 根本就没有说什么正式的话。这没有朝着正确的方向发展。
事实上,Mark Batty 等人在 2015 年发表的一篇题为“编程语言并发语义问题”的论文给出了这样发人深省的评估:
令人不安的是,在第一个宽松内存硬件(IBM 370/158MP)推出 40 多年后,该领域仍然没有对任何通用高级语言(包括高性能共享)的并发语义提出可靠的建议。 -内存并发原语。
即使定义弱序硬件的语义(忽略软件和编译器优化的复杂性)也不是很顺利。张思卓等人在 2018 年发表的一篇题为《构建弱记忆模型》的论文中讲述了最近发生的事件:
萨卡等人。 Mador-Haim 等人于 2011 年发布了 POWER 的运营模型。 Alglave 等人在 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 为 racy 程序提供了语义,它定义了在同一内存位置上使用原子和非原子操作时以及使用不同大小的访问来访问同一内存位置时会发生什么。
精确定义活泼程序的行为会导致宽松的内存语义以及如何禁止无中生有的读取等通常的复杂性。除了这些与其他地方基本相同的挑战之外,ES2017 定义还有两个有趣的错误,这些错误是由于与新 ARMv8 原子指令的语义不匹配而引起的。这些示例改编自 Conrad Watt等人的 2020 年论文“ Repairing and Mechanising the JavaScript Relaxed Memory Model ”。
正如我们在上一节中提到的,ARMv8 添加了ldar和stlr指令,提供顺序一致的原子加载和存储。这些是针对 C++ 的,它不定义任何具有数据竞争的程序的行为。因此,毫不奇怪,这些指令在 racy 程序中的行为与 ES2017 作者的期望不符,特别是它不满足 ES2017 对 racy 程序行为的要求。
Litmus 测试:ARMv8 上的 ES2017 racy 读取这个程序(使用原子)可以看到
r1=0,r2=1吗?
// Thread 1 // Thread 2
x = 1 y = 1
r1 = y x = 2 (non-atomic)
r2 = x
C++:是的(数据竞争,可以做任何事情)。Java:无法编写程序。
使用
ldar/stlr的 ARMv8:是的。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 之后发生并覆盖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 行为)又太弱(它允许无竞争程序的非顺序一致行为)。如前所述,这些错误已得到修复。即便如此,这再次提醒我们,精确地使用happens-before来指定无数据争用和活泼程序的语义是多么微妙,以及将语言内存模型与底层硬件内存模型。
令人鼓舞的是,至少目前 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“普通”访问模式。在 JavaScript 中,该机制是普通的内存访问。 - 没有一种语言能找到一种方法来正式禁止诸如凭空出现的价值观之类的悖论,但所有语言都非正式地禁止它们。
与此同时,处理器制造商似乎已经认识到顺序一致同步原子的抽象对于高效实现非常重要,并开始这样做:ARMv8 和 RISC-V 都提供直接支持。
最后,为了理解这些系统并准确地陈述它们的行为,我们进行了大量的验证和形式分析工作。瓦特等人特别令人鼓舞。我们能够在 2020 年给出 JavaScript 重要子集的正式模型,并使用定理证明器来证明编译到 ARM、POWER、RISC-V 和 x86-TSO 的正确性。
在第一个 Java 内存模型问世 25 年后,经过几个世纪的研究努力,我们可能开始能够形式化整个内存模型。或许,有一天,我们也会彻底理解他们。
内存顺序模型
std::memory_order指定如何围绕原子操作对内存访问(包括常规的非原子内存访问)进行排序。在多核系统上没有任何限制的情况下,当多个线程同时读取和写入多个变量时,一个线程可以观察到值的变化顺序与另一线程写入它们的顺序不同。事实上,多个读者线程之间的变化的明显顺序甚至可能不同。由于内存模型允许的编译器转换,即使在单处理器系统上也会出现一些类似的效果。
memory_order_relaxed(Relaxed-ordering)
对读取或写入没有同步或排序约束,仅保证此线程上此操作的原子性,这里提到的“原子性”指的是读写原子性,也就是操作本身是不可分割的,不会被其他线程的操作干扰。
典型应用:递增引用计数器
宽松内存排序的典型用途是递增计数器,例如std::shared_ptr的引用计数器,因为这只需要原子性,而不需要排序或同步(请注意,递减std::shared_ptr计数器需要与析构函数进行获取-释放同步)。
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<int> cnt = {0};
void f() {
for (int n = 0; n < 1000; ++n)
cnt.fetch_add(1, std::memory_order_relaxed);
}
int main() {
std::vector<std::thread> v;
for (int n = 0; n < 10; ++n)
v.emplace_back(f);
for (auto& t : v)
t.join();
std::cout << "Final counter value is " << cnt << '\n';
} 输出
Final counter value is 10000 memory_order_consume(Release-Consume ordering)
memory_order_consume 是一种内存顺序选项,它的作用是实现依赖性排序,确保在当前线程中依赖于加载值的操作不会被重排到加载操作之前。这种内存顺序的主要特点是,它不会对不相关的操作施加同步或排序约束,从而达到一种轻量级的同步效果。
这里有几个关键点来帮助理解这段话:
- 依赖性排序:
- 使用
memory_order_consume进行的加载操作,会保证在当前线程中依赖于加载值的操作不能被编译器或硬件重排序到加载之前。例如,如果线程从一个原子变量加载了一个指针,那么所有对这个指针解引用的操作(即通过这个指针访问的数据)都不会被重排到加载操作之前。依赖性排序限制了只和加载结果有依赖关系的操作,非依赖操作可以重排。
- 使用
- 受影响的内存位置:
- 这里指的是,使用
memory_order_consume进行加载的那个变量。对于依赖于此加载结果的任何变量访问,它们的顺序会被保护,防止它们在加载之前被访问。
- 这里指的是,使用
- 释放-消费配对的可见性:
- 在另一个线程中,如果对同一个原子变量执行了
memory_order_release的写操作,那么这个写入操作所影响的相关变量在当前线程中的memory_order_consume加载之后是可见的。也就是说,在一个线程中释放的内容(数据)可以被另一个线程中的消费操作看到。这种保证允许我们用memory_order_release和memory_order_consume实现线程间的数据传递。
- 在另一个线程中,如果对同一个原子变量执行了
- 对编译器优化的影响:
-
memory_order_consume的效果在大多数硬件平台上只影响编译器的优化,而不会影响 CPU 的重排序行为。这意味着编译器在生成代码时会避免对依赖关系的操作进行重排,但硬件层面通常不需要额外的内存屏障。这个特点使得memory_order_consume理论上非常高效,但它的实现依赖于编译器的依赖分析,因此在实践中支持较少。
-
此排序的典型用例涉及对很少写入的并发数据结构(路由表、配置、安全策略、防火墙规则等)的读取访问以及具有指针中介的发布者-订阅者情况,即,当生产者通过以下方式发布指针时消费者可以访问信息:无需使生产者写入内存的其他所有内容对消费者可见(这在弱有序架构上可能是一项昂贵的操作)。这种情况的一个例子是rcu_dereference 。
另请参阅std::kill_dependency和[[ carries_dependency ]]以获取细粒度的依赖链控制。目前(2/2015)没有已知的生产编译器跟踪依赖链:消耗操作被提升为获取操作。
释放-消耗顺序规范正在修订,暂时不鼓励使用memory_order_consume 。
举例
#include <atomic>
#include <cassert>
#include <string>
#include <thread>
std::atomic<std::string*> ptr;
int data;
void producer()
{
std::string* p = new std::string("Hello");
data = 42;
ptr.store(p, std::memory_order_release);
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_consume)))
;
assert(*p2 == "Hello"); // never fires: *p2 carries dependency from ptr
assert(data == 42); // may or may not fire: data does not carry dependency from ptr
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
} - 在
producer函数中,创建一个新的std::string对象,指针p指向它。 - 然后,生产者将
data设置为42。 - 最后,使用
ptr.store(p, std::memory_order_release)将指针p存储到ptr中,并且使用memory_order_release确保在此存储之前的所有写入(即data的写入)对其他线程是可见的。
- 在
consumer函数中,声明一个指针p2。 - 通过一个循环,不断尝试从
ptr中读取值,使用ptr.load(std::memory_order_consume)。这里使用memory_order_consume,意味着当前线程在读取ptr的值后,对ptr的依赖(即p2)会确保能看到在生产者中对data的修改。 - 一旦成功从
ptr读取到值,循环结束。 -
assert(*p2 == "Hello")确保p2指向的字符串是 "Hello"。由于使用了memory_order_consume,这个断言应该永远不会失败,因为读取ptr时会保证数据的依赖性。 -
assert(data == 42)确保data的值为 42。这一断言可能会失败,因为data的写入没有通过依赖关系被消费,可能会由于未定义行为导致读取不正确。
memory_order_acquire(Release-Acquire ordering)
memory_order_acquire 是一种内存顺序,用于同步线程之间的数据可见性。使用此内存顺序进行的加载操作可以确保,在当前线程中,该加载操作之前的任何读取或写入操作都不会被重排序到加载之后。也就是说,它保证当前线程在加载该变量后,可以看到其他线程通过 memory_order_release 存储的数据。
关键点解析
- 获取操作:
- 使用
memory_order_acquire的加载操作会执行一个获取操作,这意味着在这个加载完成之前,当前线程中所有的读取或写入操作(对加载的依赖性操作)不能重排到加载之前。
- 使用
- 配对的释放-获取保证:
-
memory_order_acquire通常和memory_order_release配合使用,确保线程间的数据传递。一个线程对变量执行memory_order_release的存储操作后,其他线程可以通过memory_order_acquire进行加载,从而保证数据可见性。 - 换句话说,如果线程 A 对变量进行了
memory_order_release的存储操作,那么线程 B 在执行memory_order_acquire的加载操作后,可以看到线程 A 所有的修改。
-
- 保证数据的可见性:
- 在多线程编程中,线程 A 的写入只有在其他线程通过
memory_order_acquire加载时才能看到。它确保同步的变量(即使用了memory_order_release和memory_order_acquire的变量)在不同线程中是一致且最新的。
- 在多线程编程中,线程 A 的写入只有在其他线程通过
在强顺序系统(x86、SPARC TSO、IBM 大型机等)上,大多数操作的释放-获取顺序是自动的。该同步模式不会发出额外的CPU指令;仅某些编译器优化受到影响(例如,禁止编译器将非原子存储移过原子存储释放或在原子加载获取之前执行非原子加载)。在弱有序系统(ARM、Itanium、PowerPC)上,使用特殊的 CPU 负载或内存栅栏指令。
举例
互斥锁,例如std::mutex或原子 spinlock ,是释放-获取同步的一个示例:当锁被线程 A 释放并被线程 B 获取时,临界区中发生的所有事情(释放之前)线程 A 的上下文中必须对正在执行相同临界区的线程 B 可见(在获取之后)。
下面是一个示例代码:
#include <atomic>
#include <cassert>
#include <string>
#include <thread>
std::atomic<std::string*> ptr;
int data;
void producer()
{
std::string* p = new std::string("Hello");
data = 42;
ptr.store(p, std::memory_order_release);
}
void consumer()
{
std::string* p2;
while (!(p2 = ptr.load(std::memory_order_acquire)))
;
assert(*p2 == "Hello"); // never fires
assert(data == 42); // never fires
}
int main()
{
std::thread t1(producer);
std::thread t2(consumer);
t1.join(); t2.join();
} - 在
producer函数中,首先创建一个新的std::string对象,并将其地址存储在指针p中。 - 然后,生产者将
data设置为42。 - 最后,使用
ptr.store(p, std::memory_order_release)将指针p存储到ptr中,并使用memory_order_release来确保在此存储之前的所有写入(即data的写入)对其他线程是可见的。
-
consumer函数中,首先声明一个指针p2。 - 通过一个循环,消费者不断尝试从
ptr中读取值,使用ptr.load(std::memory_order_acquire)。这个加载操作以memory_order_acquire的方式进行,确保此读取之后的所有读取(即data的读取)都是可以看到的。 - 一旦
ptr中有值(即指针不为空),循环结束。此时,p2将指向生产者创建的std::string对象。 -
assert(*p2 == "Hello")确保p2指向的字符串是 "Hello"。这个断言永远不会触发,因为在生产者函数中已经正确设置了这个值。 -
assert(data == 42)确保data的值为 42,同样这个断言也永远不会触发,因为在生产者中设置了这个值,并且由于内存顺序的保证,消费者能正确读取到。
memory_order_release(Release sequence)
memory_order_release 是 C++11 及之后的标准中用于原子操作的内存顺序之一。它主要用于多线程编程中,以确保不同线程之间的内存操作的可见性和顺序性。
理解 memory_order_release
- 释放操作:当一个线程对某个原子变量执行带有
memory_order_release的写操作时,这个操作会“释放”这个变量的状态。其他线程在读取这个原子变量之前,必须能够看到该线程在这个操作之前所做的所有写操作(即这些写操作对于当前线程来说是“可见”的)。 - 获取操作:当其他线程对相同的原子变量执行带有
memory_order_acquire的读操作时,这个操作会“获取”这个变量的状态,确保能够看到所有在对应的release操作之前的写入。 - 读-修改-写:在
memory_order_release的上下文中,读-修改-写操作可以理解为:你先读取一个值,进行修改,然后写回这个值。这个过程保证了在执行写操作之前,之前的所有写操作都不会被重排到写操作之后。
典型应用
- 信号量和锁:在多线程编程中,当一个线程释放一个锁时,使用
memory_order_release确保其他线程在获取这个锁时能看到之前该线程所做的所有操作。这种机制避免了数据竞争,并确保状态的正确性。 - 生产者-消费者模式:在这个模式中,生产者线程可能会写入数据并使用
memory_order_release来更新一个共享的标志位,表示数据已经准备好。消费者线程在读取这个标志位时,可以使用memory_order_acquire,确保能看到生产者线程写入的数据。
示例
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> data;
std::atomic<bool> ready = false;
void producer() {
data.store(42, std::memory_order_release); // 写入数据,释放
ready.store(true, std::memory_order_release); // 设置标志,释放
}
void consumer() {
while (!ready.load(std::memory_order_acquire)); // 获取标志,获取
int value = data.load(std::memory_order_acquire); // 获取数据,获取
std::cout << "Consumer read: " << value << std::endl;
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
} 在这个例子中,producer 线程写入数据并设置 ready 标志。consumer 线程在检查到 ready 标志后读取数据。这确保了消费者在读取数据之前,能够看到生产者所做的所有写操作。
如果某个原子被存储释放,其他几个线程对该原子进行读-修改-写操作,就会形成一个 "释放序列":所有对同一原子进行读-修改-写操作的线程都会与第一个线程以及其他线程同步,即使它们没有memory_order_release语义。这使得单个生产者--多个消费者的情况成为可能,而不会在单个消费者线程之间施加不必要的同步。
memory_order_acq_rel
memory_order_acq_rel 是 C++11 中的一个内存顺序,用于原子操作,表示同时执行获取(acquire)和释放(release)操作。它用于保证线程之间的同步和内存可见性。
理解 memory_order_acq_rel
- 获取和释放:
- 当一个线程对某个原子变量执行带有
memory_order_acq_rel的读-修改-写操作时,这个操作同时具有获取和释放的特性。也就是说:- 获取:当前线程在执行这个操作时,可以看到在此操作之前其他线程对相同原子变量的所有写入。
- 释放:在当前线程的这个操作完成之后,其他线程在获取相同原子变量时,能够看到当前线程在这个操作之前所做的所有写入。
- 当一个线程对某个原子变量执行带有
- 内存顺序:
- 当前线程中的所有读取或写入操作都不能在执行读-修改-写操作之前重新排序。这确保了在执行该操作之前的所有操作都不会被推迟到之后,从而保证了操作的顺序性。
- 可见性:
- 其他线程在获取相同原子变量时,可以看到在当前线程执行读-修改-写操作之前的所有写入。同时,当前线程的修改也能在其他线程的获取操作中变得可见。这提供了跨线程操作的同步。
典型应用
memory_order_acq_rel 适用于需要保证读-修改-写操作原子性和线程间可见性的场景,常见的应用包括:
- 锁的实现:在实现自旋锁或其他同步机制时,
memory_order_acq_rel可用于保证在获取锁的同时,能够正确地看到之前的状态以及确保释放锁后的操作对其他线程是可见的。 - 计数器的更新:在多个线程同时更新计数器时,使用
memory_order_acq_rel确保在一个线程对计数器的更新是原子的,同时保证所有线程都能看到更新后的值。 - 状态机:在多线程的状态机实现中,状态的读-修改-写操作需要确保原子性与可见性,以避免状态不一致的问题。
示例
以下是一个简单的示例,展示了如何使用 memory_order_acq_rel:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter = 0;
void increment() {
for (int i = 0; i < 1000; ++i) {
// 读-修改-写操作,使用 memory_order_acq_rel
int old_value = counter.load(std::memory_order_acquire);
counter.store(old_value + 1, std::memory_order_release);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final counter value: " << counter.load() << std::endl;
return 0;
} 在这个例子中,两个线程同时对 counter 进行增量操作。memory_order_acq_rel 确保了在读取和更新 counter 时的原子性和可见性,确保最终的计数结果是正确的。
小结
整体来说,memory_order_acq_rel 是一个强大的工具,用于在多线程环境中进行安全的读-修改-写操作。合理使用这个内存顺序能够帮助开发者避免数据竞争和不一致状态,从而确保程序的正确性和稳定性
具有这种内存顺序的读-修改-写操作既是获取操作又是释放操作。当前线程中的内存读取或写入不能在加载之前重新排序,也不能在存储之后重新排序。释放相同原子变量的其他线程中的所有写入在修改之前都是可见的,并且修改在获取相同原子变量的其他线程中是可见的
memory_order_seq_cst(Sequentially-consistent ordering)
memory_order_seq_cst 是 C++ 中最强的内存顺序,表示顺序一致(sequentially consistent)的内存操作。这种内存顺序确保了程序的所有线程以一种全局一致的方式观察内存中的修改。让我们逐步理解这段话的含义。
理解 memory_order_seq_cst
- 获取和释放:
- 当一个线程执行带有
memory_order_seq_cst的加载操作时,它将进行获取操作,确保能够看到其他线程之前对共享变量的所有写入。 - 当执行带有
memory_order_seq_cst的存储操作时,它将执行释放操作,确保所有之前的写入对于其他线程是可见的。 - 读取-修改-写入(如自增操作)同样具有获取和释放的特性,这确保了在对变量进行修改时,相关的操作顺序不会被重新排序。
- 当一个线程执行带有
- 总顺序:
- 使用
memory_order_seq_cst,所有线程都会看到内存操作的总顺序。这意味着,不论线程的执行顺序如何,所有线程都将观察到内存中的所有修改是按照同一顺序进行的。这种一致性使得程序的行为更加直观和易于理解。
- 使用
- 顺序一致性:
- 顺序一致性是指程序执行的效果与一个单线程执行的效果是相同的,换句话说,程序的执行顺序看起来就像是一个全局的时间线。这样,任何线程在读取变量时都能保证看到的状态是其他线程在之前的操作中已经完成的。
典型应用
memory_order_seq_cst 适用于需要最高一致性和可读性的场景,常见的应用包括:
- 简单的计数器:当多个线程需要对一个共享计数器进行更新时,使用
memory_order_seq_cst可以确保所有线程在读取计数器时看到一致的值。 - 状态标志:在需要多个线程对状态进行检查和更新时,使用这种内存顺序可以确保每个线程看到的状态是全局一致的,避免了因线程间不同步而导致的错误。
- 多线程算法:在许多并行算法中,需要确保各个线程之间的操作顺序一致,
memory_order_seq_cst提供了一种简单而安全的方式来实现这种要求。
示例
以下是一个简单的示例,展示了如何使用 memory_order_seq_cst:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter = 0;
void increment() {
for (int i = 0; i < 1000; ++i) {
// 读-修改-写操作,使用 memory_order_seq_cst
counter.fetch_add(1, std::memory_order_seq_cst);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Final counter value: " << counter.load(std::memory_order_seq_cst) << std::endl;
return 0;
} 在这个例子中,两个线程同时对 counter 进行增量操作,使用 memory_order_seq_cst 确保了操作的顺序一致性。最终输出的计数器值将是 2000,保证了在多线程环境下的数据一致性。
小结
memory_order_seq_cst 提供了最高级别的内存同步,确保所有线程以一致的方式观察内存中的修改。它是多线程编程中最简单且安全的选择,适用于对一致性和可读性要求较高的场景。尽管性能上可能不如其他更灵活的内存顺序,但在许多情况下,它的简洁性和安全性使其成为首选。
seq_cst表示顺序一致性内存模型,在这个模型约束下不仅同一个线程内的执行结果是和程序顺序一致的, 每个线程间互相看到的执行结果和程序顺序也保持顺序一致。显然,seq_cst的约束是最强的,这意味着要牺牲性能为代价。
对于多个生产者 - 多个消费者的情况,所有消费者必须观察所有生产者以相同顺序发生的动作,可能需要顺序排序。
完全顺序排序需要所有多核系统上的完整内存栅栏 CPU 指令。这可能会成为性能瓶颈,因为它会迫使受影响的内存访问传播到每个内核。
总结
| 内存顺序模型 | 描述 | 典型应用 | 限制的操作 |
memory_order_relaxed | 无同步要求,允许最大程度的重排。 | 性能关键的非同步数据结构,日志记录等。 | 不保证可见性和顺序,不限制任何操作的重排。 |
memory_order_consume | 依赖于获取操作,确保依赖于某个变量的写入在获取之前可见。 | 事件驱动编程、消息传递等(使用较少)。 | 只限制依赖于获取操作的写入,其他操作仍可重排。 |
memory_order_acquire | 获取操作,确保读取之前的所有写入对当前线程可见。 | 读取锁状态、条件变量等。 | 在获取之前的所有读取或写入不能重排到获取之后。 |
memory_order_release | 释放操作,确保当前线程的写入在释放之后对其他线程可见。 | 写入锁状态、信号量等。 | 在释放之后的所有读取或写入不能重排到释放之前。 |
memory_order_acq_rel | 同时执行获取和释放操作,保证读-修改-写操作的原子性和可见性。 | 自增操作、状态更新等。 | 不允许在获取之前或释放之后重排任何读取或写入操作。 |
memory_order_seq_cst | 顺序一致性,所有线程以全局一致的顺序观察内存中的修改。 | 多线程算法、状态标志等。 | 不允许任何读取或写入操作重排,所有操作按全局顺序执行。 |
多核心最小粒度同步-原子
锁