Android 3.0 及更高版本的平台经过优化,可支持多处理器架构。本文介绍了在使用 C、C++ 和 Java 编程语言(为简便起见,以下简称“Java”)为对称多处理器系统编写多线程代码时可能会出现的问题。本文只是面向 Android 应用开发者的入门指南,并没有对该主题进行全面讨论。
简介
SMP 的全称是“Symmetric Multi-Processor”(对称多处理器)。这种设计的特点是两个或多个相同的 CPU 核心共同访问主内存。几年前,所有 Android 设备都还是采用单处理器 (UP)。
大多数(即便不是所有)Android 设备总是有多个 CPU,但是过去只会有一个 CPU 用于运行应用,其他 CPU 则用于管理各种设备硬件(例如无线装置)。CPU 可能具有不同的架构,在其上运行的程序无法使用主内存相互通信。
目前市面上的大多数 Android 设备都是采用 SMP 设计构建的,这使得软件开发者的工作变得有些复杂。多线程程序中的竞态条件可能不会在单处理器上引发明显问题,但如果两个或多个线程同时在不同核心上运行,就可能会经常出现故障。此外,在不同的处理器架构上运行,甚至是在同一架构的不同实现上运行时,代码或多或少都可能会出现故障。在 x86 上经过全面测试的代码可能会在 ARM 上出现严重的崩溃。使用更现代的编译器重新编译后,代码也可能会开始出现故障。
下面将会解释原因,并告知您需要执行哪些操作以确保代码正常运行。
内存一致性模型:为什么 SMP 会略有不同
这里,我们对一个复杂的主题进行了简明扼要的阐述。有些方面的内容不够完善,但不会存在误导性或错误。正如下一部分所述,这里的细节通常并不重要。
如需全面了解该主题,请参阅文末的深入阅读。
内存一致性模型(通常简称为“内存模型”)表明了编程语言或硬件架构对于内存访问的保证。例如,如果您向地址 A 写入一个值,然后向地址 B 写入一个值,则内存模型可以保证每个 CPU 核心都知晓这些操作及其发生顺序。
大多数程序员习惯使用的模型是“顺序一致性”模型,相关说明如下所示(Adve 和 Gharachorloo):
- 所有内存操作似乎都是一次执行一个。
- 单个线程中的所有操作似乎都是按相应处理器的程序说明的顺序执行。
暂时假设我们有一个非常简单的不会引发任何意外的编译器或解释器:它会转换源代码中的赋值,以便完全按照相应的顺序加载和存储指令(每访问一次即加载和存储一条指令)。为简单起见,我们还假设每个线程均在各自的处理器上执行。
如果您观察一小段代码,并发现这段代码执行了一些内存的读取和写入操作,那么在顺序一致的 CPU 架构上,您便会知道这段代码会按照预期顺序执行这类读取和写入操作。CPU 实际上可能会对指令进行重新排序并延迟读取和写入操作,但在设备上运行的代码无法知道 CPU 并非仅仅是在按原样执行指令(我们会忽略内存映射设备驱动程序 I/O)。
为了说明这些方面,可以考虑使用较小的代码段(通常称为“石蕊测试”)。
下面是一个简单的示例,其中代码在两个线程上运行:
线程 1 | 线程 2 |
---|---|
A = 3 |
reg0 = B |
在此石蕊示例以及将来的石蕊示例中,内存位置由大写字母(A、B、C)表示,CPU 寄存器以“reg”开头。所有内存最初均为零。指令按照从上到下的顺序执行。在本例中,线程 1 会将值“3”存储在位置 A,然后将值“5”存储在位置 B。线程 2 会将位置 B 的值加载到 reg0 中,然后将位置 A 的值加载到 reg1 中(请注意,我们的写入顺序与读取顺序不同)。
假设线程 1 和线程 2 在不同的 CPU 核心上执行。在考虑多线程代码时,您应始终做出这样的假设。
顺序一致性可保证寄存器在完成两个线程的执行后处于以下状态之一:
寄存器 | 状态 |
---|---|
reg0=5, reg1=3 | possible(线程 1 先运行) |
reg0=0, reg1=0 | possible(线程 2 先运行) |
reg0=0, reg1=3 | possible(并发执行) |
reg0=5, reg1=0 | never |
如果要在看到存储到 A 的值之前看到 B=5,读取或写入就不能按原来的顺序执行。在顺序一致的设备上,不可能发生这种情况。
单处理器(包括 x86 和 ARM)通常在顺序上是一致的。线程似乎以交错方式执行,因为操作系统内核会在线程之间切换。大多数 SMP 系统(包括 x86 和 ARM)在顺序上都是不一致的。例如,硬件一般会在存储内容传输到内存途中对其进行缓冲,这样存储内容就不会立即传输到内存并对其他核心可见。
具体细节差别很大。例如,尽管 x86 在顺序上不一致,但它仍可保证 reg0 = 5 和 reg1 = 0 不可能出现。存储内容会进行缓冲,但其顺序保持不变。ARM 则不然。存储内容无法在缓冲后保持原来的顺序,且可能不会同时到达所有其他核心。这些差异对汇编程序员来说很重要。然而,如下所述,C、C++ 或 Java 程序员可以且应该以隐藏此类架构差异的方式进行编程。
目前为止,我们一直在不切实际地假设只有硬件会对指令进行重新排序。实际上,编译器也会对指令进行重新排序以提高性能。在我们的示例中,编译器可能会决定线程 2 中的某些后续代码先需要 reg1 的值,然后需要 reg0 的值,因此会先加载 reg1。或者之前的一些代码可能已加载位置 A,而编译器决定再次使用相应值,而非再次加载位置 A。无论是哪种情况,对 reg0 和 reg1 的加载都可能会重新排序。
不论是在硬件中,还是在编译器中,都允许对不同内存位置的访问进行重新排序,因为这不会影响单个线程的执行,而且可以显著提高性能。正如我们将看到的,只要稍加注意,我们还可以防止它影响多线程程序的结果。
由于编译器也可以对内存访问进行重新排序,因此这个问题对于 SMP 来说并不是第一次出现。即使在单处理器上,编译器也可以重新排序对示例中的 reg0 和 reg1 的加载,并且可以在重新排序后的指令之间调度线程 1。但是,如果编译器碰巧没有进行重新排序,我们可能永远不会发现这一问题。在大多数 ARM SMP 中,即使编译器不进行重新排序,我们也可能会在成功执行大量指令后看到重新排序。除非您使用汇编语言进行编程,否则 SMP 通常只是让您更有可能看到一直存在的问题。
无数据争用的编程
幸运的是,通常有一种简单的方法可以避免考虑这些细节。如果您遵循一些简单的规则,则除“顺序一致性”部分之外,通常可以放心忽略上一节中的所有内容。不幸的是,如果不小心违反了这些规则,则可能会出现其他并发问题。
现代编程语言提倡“无数据争用”的编程风格。只要您承诺不引入“数据争用”,并避免一些构造向编译器传达与此矛盾的信息,编译器和硬件就会保证提供顺序一致的结果。这并不意味着它们可以避免内存访问重新排序。而是,如果遵循这些规则,您将无法得知内存访问正在重新排序。这很像告诉您香肠是一种美味可口的食物,前提是您要保证不去参观香肠工厂。数据争用暴露了内存重新排序的丑陋事实。
什么是数据争用?
当至少两个线程同时访问相同的普通数据,并且其中至少一个线程对其进行修改时,就会发生数据争用。“普通数据”指的是并非专用于线程通信的同步对象的数据。互斥量、条件变量、Java volatile 或 C++ 原子对象都不是普通数据,对它们的访问允许竞争。事实上,它们可用于阻止其他对象上出现数据争用。
为了确定两个线程是否同时访问相同的内存位置,我们可以忽略上述有关内存重新排序的讨论,并假设具有顺序一致性。如果 A
和 B
是初始值为 false 的普通布尔变量,则以下程序不存在数据争用:
线程 1 | 线程 2 |
---|---|
if (A) B = true |
if (B) A = true |
由于没有对操作进行重新排序,因此这两个条件都会计算为 false,并且两个变量都不会更新。这样一来,就不可能发生数据争用。没有必要考虑线程 1 中从 A
进行加载以及存储到 B
的操作以某种方式进行重新排序时会出现什么情况。不允许编译器通过将线程 1 重新编写为“B = true; if (!A) B = false
”对该线程进行重新排序。这就像大白天在市中心做香肠一样。
数据争用的正式定义针对的是基本的内置类型(例如整数以及引用或指针)。在分配给 int
的同时在另一个线程中对其进行读取显然属于数据争用。但是,C++ 标准库和 Java 集合库的编写方式使您能够在库级别推断数据争用情况。它们承诺不会引入数据争用,除非存在对相同容器的并发访问,并且其中至少一个访问对其进行更新。在一个线程中更新 set<T>
的同时在另一个线程中对其进行读取可让相应的库引入数据争用,因此可非正式地将其视为“库级数据争用”。相反,在一个线程中更新 set<T>
,同时在另一个线程中读取另一个对象,则不会导致数据争用,因为相应的库会保证不在这种情况下引入(低级别)数据争用。
正常情况下,并发访问数据结构中的不同字段不会引入数据争用。然而,这条规则有一个重要的例外情况:C 或 C++ 中连续序列的位字段会被视为单个“内存位置”。在确定是否存在数据争用时,访问这种序列中的任何位字段都会被视为访问所有位字段。这反映了常见硬件无法在不读取和重写邻位的情况下更新各个位。Java 程序员不会遇到类似的问题。
避免数据争用
现代编程语言提供了很多同步机制以避免出现数据争用。最基本的工具包括:
- 锁或互斥量
- 互斥量(C++11
std::mutex
或pthread_mutex_t
)或 Java 中的synchronized
块可用于确保某些代码段不会与访问相同数据的其他代码段同时运行。我们将这些工具及其他类似工具统称为“锁”。在访问共享数据结构之前始终获取特定锁并在之后将其释放,可防止在访问数据结构时出现数据争用。这还可以确保更新和访问是原子性的,即无法在中间运行数据结构的其他更新。这是目前为止最常用的防止数据争用的工具。使用 Javasynchronized
块、C++lock_guard
或unique_lock
可确保在发生异常时正常释放锁。 - Volatile/atomic 变量
- Java 提供
volatile
字段,这些字段支持并发访问,而不会引入数据争用。自 2011 年以来,C 和 C++ 就支持具有类似语义的atomic
变量和字段。这些变量和字段通常比锁更难使用,因为它们只确保对单个变量的单独访问是原子性的(在 C++ 中,这通常会扩展到简单的读取-修改-写入操作,例如增量操作。Java 需要特殊的方法调用才能实现此类操作)。与锁不同的是,volatile
或atomic
变量不能直接用于阻止其他线程干扰较长的代码序列。
请务必注意,volatile
在 C++ 和 Java 中的意义大不相同。在 C++ 中,volatile
不会阻止数据争用,但由于缺少 atomic
对象,较旧的代码通常会将其用作解决方法。如今已不再建议这样做,在 C++ 中,对于可供多个线程并发访问的变量,请使用 atomic<T>
。C++ volatile
旨在用于设备寄存器等。
C/C++ atomic
变量或 Java volatile
变量可用于阻止在其他变量上出现数据争用的情况。如果声明 flag
具有类型 atomic<bool>
、atomic_bool
(C/C++) 或 volatile boolean
(Java),且初始值为 false,则以下代码段不存在数据争用的情况:
线程 1 | 线程 2 |
---|---|
A = ...
|
while (!flag) {}
|
线程 2 需要等待 flag
设置完成,因此必须在线程 1 中为 A
赋值之后才能在线程 2 中访问 A
,而不能并发进行。因此,A
上不存在数据争用的情况。对 flag
的争用不属于数据争用,因为 volatile/atomic 访问不属于“普通的内存访问”。
实现方法需要充分阻止或隐藏内存重新排序,使代码(如之前的石蕊测试)的行为与预期相符。这通常会使 volatile/atomic 内存访问的开销明显高于普通访问。
尽管上述示例不存在数据争用的情况,结合使用锁与 Java 中的 Object.wait()
或 C/C++ 中的条件变量通常可更好地解决问题,这样做就不会在无尽的等待中耗尽电池电量。
当内存重新排序可见时
“无数据争用”编程通常可以让我们免去必须明确处理内存访问重新排序问题的麻烦。然而,在某些情况下,重新排序确实会变得可见:- 如果您的程序存在导致意外数据争用的错误,则编译器和硬件转换会变得可见,并且程序的行为可能会出现意外状况。例如,如果我们在前面的示例中忘记将
flag
声明为 volatile,则线程 2 可能会看到未初始化的A
。或者编译器可能会确定在线程 2 的循环过程中不可能更改相应标记,并将该程序转换为线程 1 线程 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflag
为 true。 - 即使不存在争用情况,C++ 也会提供用于明确放宽顺序一致性限制的工具。原子操作可以采用显式的
memory_order_
... 参数。同样,java.util.concurrent.atomic
包会提供一组限制更严格的类似工具(尤其是lazySet()
)。Java 程序员偶尔也会有意利用数据争用达到类似的效果。这些都是以增加编程复杂性为巨大代价实现性能提升的。我们将在下文简要讨论这些问题。 - 某些 C 和 C++ 代码采用较旧的样式编写,不完全符合当前的语言标准,其中使用了
volatile
变量而不是atomic
变量,并且通过插入所谓的栅栏或屏障明确禁止内存排序。这需要对访问重新排序进行显式推理并了解硬件内存模型。这几行代码所采用的编码样式依然会用在 Linux 内核中。请勿在新的 Android 应用中使用这种编码样式,这里我们也不会对此进行进一步的讨论。
练习
对内存一致性问题进行调试非常棘手。如果因缺少锁、atomic
或 volatile
声明而导致某些代码读取陈旧数据,您可能无法通过使用调试程序检查内存转储找出原因。当您可以发出调试程序查询时,CPU 核心可能已观察了全部访问,并且内存和 CPU 寄存器的内容将显示为“不可能”状态。
有关 C 语言的禁止事项
我们在这里提供了一些错误代码的示例,以及修复这些代码的简单方法。在此之前,我们需要讨论基本语言功能的使用。
C/C++ 和“volatile”
C 和 C++ volatile
声明是用途非常特殊的工具,可阻止编译器对 volatile 访问进行重新排序或将其移除。这对于访问硬件设备寄存器的代码、映射到多个位置的内存或与 setjmp
连接很有帮助。但与 Java volatile
不同的是,C 和 C++ volatile
不是专为线程通信而设计的。
在 C 和 C++ 中,对 volatile
数据的访问可以通过访问非 volatile 数据进行重新排序,且没有原子性保证。因此,volatile
不能用于在可移植代码中的线程之间共享数据,即使在单处理器上也是如此。C volatile
通常不会阻止硬件对访问进行重新排序,因此其本身在多线程 SMP 环境中更不实用。这就是 C11 和 C++11 支持 atomic
对象的原因。您应该改为使用这两项。
很多旧的 C 和 C++ 代码仍然会滥用 volatile
进行线程通信。对于计算机寄存器中的数据,如果它与显式栅栏结合使用或在内存排序不重要的情况下使用,那么这种做法一般来说是正确的,但这种做法并不能保证它可与未来的编译器一起正常工作。
示例
在大多数情况下,最好使用锁(例如 pthread_mutex_t
或 C++11 std::mutex
)而非原子操作,但我们将通过后者说明如何它们在实际情况中的使用方式。
MyThing* gGlobalThing = NULL; // Wrong! See below. void initGlobalThing() // runs in Thread 1 { MyStruct* thing = malloc(sizeof(*thing)); memset(thing, 0, sizeof(*thing)); thing->x = 5; thing->y = 10; /* initialization complete, publish */ gGlobalThing = thing; } void useGlobalThing() // runs in Thread 2 { if (gGlobalThing != NULL) { int i = gGlobalThing->x; // could be 5, 0, or uninitialized data ... } }
我们的想法是分配一个结构,然后对其字段进行初始化,最后通过将其存储在全局变量中进行“发布”。此时,任何其他线程都可以看到它,不过没关系,因为它已完全初始化,对吧?
问题是,在对字段进行初始化之前就可能观察到存储到 gGlobalThing
的操作,这通常是因为编译器或处理器对存储到 gGlobalThing
的操作和 thing->x
进行了重新排序。从 thing->x
读取的另一个线程可能看到 5、0 甚至是未初始化的数据。
这里的核心问题是 gGlobalThing
上存在数据争用情况。如果线程 1 调用 initGlobalThing()
,而线程 2 调用 useGlobalThing()
,则可以在写入 gGlobalThing
的同时对其进行读取。
可以通过将 gGlobalThing
声明为 atomic,解决此问题。在 C++11 中:
atomic<MyThing*> gGlobalThing(NULL);
这可以确保写入操作以适当的顺序对其他线程可见。此外,还有助于确保防止出现以其他方式允许但不太可能出现在实际 Android 硬件上的一些其他故障模式。例如,这可确保我们看不到仅部分写入的 gGlobalThing
指针。
有关 Java 的禁止事项
我们尚未讨论一些相关的 Java 语言功能,现在我们先简单了解一下。
Java 的技术不要求代码是“不存在数据争用”的代码。此外,少量经过精心编写的 Java 代码可在存在数据争用的情况下正常运行。但是,编写这样的代码极其棘手,我们只在下面简要讨论一下。更糟糕的是,指定此类代码含义的专家不再相信相应规范是正确的(该规范适用于不存在数据争用的代码)。
现在,我们将采用无数据争用模型,Java 在这方面能够提供与 C 和 C++ 相同的保证。同样,该语言也会提供一些明确放宽顺序一致性限制的原语,特别是 java.util.concurrent.atomic
中的 lazySet()
和 weakCompareAndSet()
调用。与 C 和 C++ 一样,我们将暂时忽略这些内容。
Java 的“synchronized”和“volatile”关键字
“synchronized”关键字提供了 Java 语言内置的锁定机制。每个对象都有可用于提供互斥访问的关联“监视器”。如果两个线程尝试在同一个对象上“同步”,则其中一个需要等待另一个完成才能执行操作。
如上所述,Java 的 volatile T
类似于 C++11 的 atomic<T>
。允许并发访问 volatile
字段,且不会导致数据争用。忽略 lazySet()
等及数据争用,Java 虚拟机的职责是确保结果仍然保持顺序一致性。
特别是,如果线程 1 写入一个 volatile
字段,而线程 2 随后从该字段读取数据并查看新写入的值,那么线程 2 也一定查看线程 1 之前执行的所有写入操作。在记忆效应方面,向 volatile 写入数据类似于监视器释放,从 volatile 读取数据则类似于监视器获取。
这与 C++ 的 atomic
有一个显著的区别:如果我们在 Java 中写入 volatile int x;
,则 x++
与 x = x + 1
等效;它会执行原子加载操作,递增结果,然后执行原子存储。与 C++ 不同的是,整体增量不是原子的。原子增量操作是由 java.util.concurrent.atomic
提供的。
示例
下面是一个简单的错误单调计数器实现:(Java 理论与实践:管理易失性)。
class Counter { private int mValue; public int get() { return mValue; } public void incr() { mValue++; } }
假设从多个线程调用 get()
和 incr()
,我们希望确保在调用 get()
时每个线程都能看到当前计数。最明显的问题是,mValue++
实际上包含三个操作。
reg = mValue
reg = reg + 1
mValue = reg
如果两个线程同时在 incr()
中执行,则其中一个更新可能会丢失。为使增量原子化,我们需要将 incr()
声明为“synchronized”。
不过,它仍然会损坏,特别是在 SMP 上。仍然存在数据争用的情况,因为 get()
可以与 incr()
并发访问 mValue
。根据 Java 规则,相对于其他代码,get()
调用可能看起来已经过重新排序。例如,如果我们连续读取两个计数器,结果可能不一致,因为我们通过硬件或编译器对 get()
调用进行了重新排序。我们可以通过将 get()
声明为“synchronized”纠正此问题。更改后,代码显然是正确的。
遗憾的是,我们引入了锁争用的可能性,而这可能会影响性能。我们可以使用“volatile”声明 mValue
,而不是将 get()
声明为“synchronized”。(注意,incr()
必须仍然使用 synchronize
,因为 mValue++
不是单个原子操作。)这也避免了各种数据争用情况,因此保留了顺序一致性。incr()
稍慢一些,因为它会引起监视器进入/退出开销以及与 volatile 存储相关的开销,但是 get()
比较快,因此,即使不存在争用的情况,如果读取次数远远超过写入次数,它也会胜出。(另请参阅 AtomicInteger
了解如何完全移除同步块。)
下面是另一个示例,其形式类似于前面的 C 语言示例:
class MyGoodies { public int x, y; } class MyClass { static MyGoodies sGoodies; void initGoodies() { // runs in thread 1 MyGoodies goods = new MyGoodies(); goods.x = 5; goods.y = 10; sGoodies = goods; } void useGoodies() { // runs in thread 2 if (sGoodies != null) { int i = sGoodies.x; // could be 5 or 0 .... } } }
这与 C 代码有相同的问题,即 sGoodies
上存在数据争用。因此,可以在初始化 goods
中的字段之前观察到 sGoodies = goods
赋值。如果使用 volatile
关键字声明 sGoodies
,则会恢复顺序一致性,代码也会按预期运行。
请注意,只有 sGoodies
引用本身是 volatile。对其内部字段的访问不是。一旦 sGoodies
为 volatile
,并且正确保留了内存排序,就无法并发访问这些字段。语句 z =
sGoodies.x
会执行 MyClass.sGoodies
的 volatile 加载,然后是 sGoodies.x
的非 volatile 加载。如果您创建一个本地引用 MyGoodies localGoods = sGoodies
,则后续的 z =
localGoods.x
将不会执行任何 volatile 加载。
Java 编程中更常见的习惯是令人厌烦的“双重检查锁定”:
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
我们的想法是将 Helper
对象的单个实例与 MyClass
的实例关联。我们只能创建一次,因此通过专用的 getHelper()
函数创建并返回。为了避免在两个线程创建实例时出现争用情况,我们需要同步创建对象。但是,我们不想在每次调用时为同步块支付开销,因此我们只在 helper
当前为 null 时执行该部分。
这会导致 helper
字段上存在数据争用。可将其与另一个线程中的 helper == null
同时设置。
如需了解是如何出现故障的,可以稍微重写相同的代码,就像将其编译到类 C 语言中一样(这里添加了几个整数字段以表示 Helper’s
构造函数 Activity):
if (helper == null) { synchronized() { if (helper == null) { newHelper = malloc(sizeof(Helper)); newHelper->x = 5; newHelper->y = 10; helper = newHelper; } } return helper; }
没有什么可以阻止硬件或编译器对存储到 helper
的操作与存储到 x
/y
字段的操作进行重新排序。另一个线程可发现 helper
非 null,但其字段尚未设置,也不可使用。如需了解详情及更多故障模式,请参阅附录中的“‘双重检查锁定已损坏’声明”链接,或参见 Josh Bloch 的《Effective Java 第 2 版》中的第 71 条(“明智地使用延迟初始化”)。
解决此问题的方法有两种:
- 执行简单的操作并删除外部检查。这可确保我们永远不需要检查同步块之外
helper
的值。 - 将
helper
声明为 volatile。进行了这项小小的改动之后,示例 J-3 中的代码将在 Java 1.5 及更高版本上正常运行(您可能需要花点时间说服自己)。
下面是 volatile
行为的另一个示例:
class MyClass { int data1, data2; volatile int vol1, vol2; void setValues() { // runs in Thread 1 data1 = 1; vol1 = 2; data2 = 3; } void useValues() { // runs in Thread 2 if (vol1 == 2) { int l1 = data1; // okay int l2 = data2; // wrong } } }
查看 useValues()
,如果线程 2 尚未观察到对 vol1
的更新,则无法知道是否已设置 data1
或 data2
。一旦它看到对 vol1
的更新,就会知道可以安全访问并正确读取 data1
,且不会引入数据争用。但是,它不能对 data2
做任何假设,因为这项存储操作是在 volatile 存储之后执行的。
请注意,volatile
不能用于阻止对相互争用的其他内存访问进行重新排序。它不一定能生成计算机内存栅栏指令。只有当另一个线程满足某种条件时,才可以通过执行代码阻止数据争用。
具体方法
在 C/C++ 中,首选 C++11 同步类,例如 std::mutex
。如果没有该类,请使用相应的 pthread
操作。其中包括合适的内存栅栏,在所有 Android 平台版本上提供正确(顺序一致,除非另有说明)、高效的行为。请务必正确使用。例如,请注意,条件变量等待可能会在没有收到信号的情况下不合逻辑地返回,因此应该出现在循环中。
最好避免直接使用原子函数,除非您实现的数据结构极其简单,例如计数器。锁定和解锁 pthread 互斥量均需要单个原子操作,如果并不存在争用情况,所需费用通常会低于单个缓存未命中,因此将互斥量调用替换为原子操作节省不了太多费用。用于重要数据结构的无锁设计需更加谨慎,以确保对数据结构执行的更高级别操作看起来是原子的(整体而言,不仅仅是显式原子片段)。
如果您使用原子操作,则使用 memory_order
... 或 lazySet()
放宽排序限制可能会具有性能优势,但需要具有比目前为止所讲内容更深入的理解。使用它们的大部分现有代码都是在事后出现错误。请尽可能避免。如果您的使用情况与下节中的情况不完全相符,请确保您是专家或已经咨询过专家。
请避免在 C/C++ 中使用 volatile
进行线程通信。
在 Java 中,对于并发问题,通常可以通过使用 java.util.concurrent
包中的适当实用程序类予以妥善解决。代码经过精心编写,并且在 SMP 上测试后反响良好。
也许最安全的做法就是让您的对象不可变。Java 的字符串和整数等类的对象包含一旦创建对象便无法更改的数据,从而避免了这些对象出现数据争用的所有可能性。《Effective Java 第 2 版》一书中的“第 15 条:使可变性最小化”中做了具体说明。特别要注意将 Java 字段声明为“不可更改”(Bloch) 的重要性。
即使某个对象是不可变的,也请注意,在没有任何一种同步的情况下将其传递给另一个线程也属于数据争用。这在 Java 中偶尔是可以接受的(见下文),但需要非常小心,这可能会使代码很脆弱。如果不是非常重要的性能,请添加一个 volatile
声明。在 C++ 中,在没有经过适当同步的情况下将指针或引用传递给不可变对象(就像任何数据争用一样)是错误的做法。在这种情况下,很有可能会导致间歇性崩溃,例如,接收线程可能会由于存储重新排序而看到未初始化的方法表指针。
如果现有库类和不可变类都不合适,则应使用 Java synchronized
语句或 C++ lock_guard
/unique_lock
保护对可由多个线程访问的任何字段的访问。如果互斥量不适合您的情况,则应声明共享字段 volatile
或 atomic
,但您必须非常谨慎地了解线程之间的交互。这些声明不会使您避免常见的并发编程错误,但是可以帮助您避免与优化编译器和 SMP 故障相关的神秘故障。
您应该避免在构造函数中“发布”对对象的引用,即使其可供其他线程访问。在 C++ 中,或者如果您坚持遵循 Java 中的“无数据争用”建议,这一点并不太重要。但是,如果您的 Java 代码在 Java 安全模型很重要的其他情境中运行,且不受信任的代码可能会通过访问“已泄露”对象引用引入数据争用,这就是个很好的建议,而且至关重要。如果您选择忽略我们的警告并使用下一节中的一些技巧,这一点也很重要。如需了解详情,请参阅(Java 中的安全构建技巧)。
有关弱内存顺序的更多信息
C++11 及更高版本提供了有关针对无数据争用的程序放宽顺序一致性保证限制的明确机制。原子操作的显式 memory_order_relaxed
、memory_order_acquire
(仅加载)和 memory_order_release
(仅存储)参数提供的保证均比默认的 memory_order_seq_cst
(通常为隐式)参数弱。memory_order_acq_rel
为原子读取-修改-写入操作同时提供 memory_order_acquire
和 memory_order_release
保证。尚未针对实用性很好地指定或实现 memory_order_consume
,目前应忽略。
Java.util.concurrent.atomic
中的 lazySet
方法类似于 C++ memory_order_release
存储。Java 的普通变量有时用于替代 memory_order_relaxed
访问权限,尽管它们实际上更弱。与 C++ 不同的是,没有针对声明为 volatile
的变量的无序访问的真正机制。
除非出于迫切的性能原因,否则一般应避免使用。在 ARM 这样弱排序的机器架构上,使用它们通常可为每个原子操作节省大约几十个机器周期。在 x86 上,性能获胜仅限于存储,并且可能不太明显。有些与直觉相反的是,优势可能会随着核心数量的增加而下降,因为内存系统更像是限制因素。
弱排序原子的完整语义比较复杂。通常,需要精确了解语言规则,我们在这里不做深入讨论。例如:
- 编译器或硬件可以将
memory_order_relaxed
访问权限移入(而非移出)锁定获取和释放绑定的临界区。这意味着两个memory_order_relaxed
存储操作可能次序混乱,即使它们由临界区分开也是如此。 - 将普通的 Java 变量滥用为共享计数器时,它可能对另一个线程显示减少的情况,即使它仅由单个其他线程递增也是如此。但是对于 C++ 原子
memory_order_relaxed
来说并非如此。
以此作为警告,我们在这里提供了一些习惯用法,其中涵盖了弱排序原子的很多使用情况。其中很多仅适用于 C++。
非争用访问
变量是原子性这一点很普遍,因为有时读取与写入并发进行,但并非所有访问都有此问题。例如,变量是在临界区之外读取的,因此可能需要是原子性的,但所有更新都受锁保护。在这种情况下,恰好也受相同锁保护的读取不能争用,因为无法并发写入。在此类情况下,可以使用 memory_order_relaxed
对非争用访问(在本例中为加载)进行注释,而 C++ 代码的准确性不受任何影响。锁实现已经强制执行与其他线程访问相关的必要内存排序,memory_order_relaxed
则指定基本上不需要为原子访问强制执行其他排序限制。
Java 中没有类似的对象。
结果的正确性不可靠
当我们仅将争用加载用于生成提示时,通常也可以不强制执行任何内存排序以供加载。如果值不可靠,我们也无法可靠地使用结果推断其他变量的任何信息。因此,如果不能保证内存排序,但为加载提供了 memory_order_relaxed
参数,则没有问题。
一个常见的示例是使用 C++ compare_exchange
以原子方式将 x
替换为 f(x)
。用于计算 f(x)
的 x
的初始加载不需要是可靠的。如果出现错误,compare_exchange
会失败,我们将重试。初始加载 x
时可以使用 memory_order_relaxed
参数,实际 compare_exchange
的内存排序才是重要的。
以原子方式修改但未读取的数据
有时数据会由多个线程并行修改,但只在并行计算完成之后才会进行检查。一个很好的示例是由多个线程并行以原子方式递增(例如,在 C++ 中使用 fetch_add()
或在 C 中使用 atomic_fetch_add_explicit()
)的计数器,但这些调用的结果总是被忽略。完成所有更新后,才会读取结果值。
在这种情况下,无法判断对此数据的访问是否经过重新排序,因此 C++ 代码可以使用 memory_order_relaxed
参数。
简单的事件计数器就是一个常见的例子。由于这种情况很常见,因此值得对此进行一些研究:
- 使用
memory_order_relaxed
可以提高性能,但可能无法解决最重要的性能问题:每次更新都需要对保存计数器的缓存行进行独占访问。每次有新线程访问计数器时都会导致缓存未命中。如果更新频繁且在线程之间交替进行,则每次避免更新共享计数器要快得多,例如,通过使用线程局部计数器并在最后对它们求和。 - 这项技术可与上一部分结合使用:如果所有操作均使用
memory_order_relaxed
,则可以在更新时并发读取近似值和不可靠值。但一定要将结果值视为完全不可靠。计数似乎已增加一次并不意味着另一个线程可被视为已经达到已执行增量的点。相反,增量可能已经通过较早的代码进行重新排序。(对于前面提到的类似情况,C++ 的确可以保证二次加载此类计数器不会返回一个小于同一线程中早期加载的值。当然,除非计数器溢出。) - 代码通常通过执行各个原子(或非原子)读取和写入计算近似的计数器值,但不会将增量作为整体原子操作。普遍的观点是,这对性能计数器等来说已“足够接近”或相近。通常并非如此。当更新足够频繁(您可能需要注意这种情况)时,大部分计数通常会丢失。在四核设备上,通常会丢失一半以上的计数。(简单练习:构建一个双线程场景,其中计数器更新一百万次,但最终计数器值为 1。)
简单的标记通信
memory_order_release
存储(或读取-修改-写入操作)可确保,如果后续有 memory_order_acquire
加载(或读取-修改-写入操作)读取写入的值,它还会观察 memory_order_release
存储之前的所有存储(普通或原子性)。反之,memory_order_release
之前的任何加载都不会观察到 memory_order_acquire
加载之后的任何存储。与 memory_order_relaxed
不同,这允许使用此类原子操作将一个线程的进度传递给另一个线程。
例如,我们可以在 C++ 中重写上面的双重检查锁定示例,如下所示:
class MyClass { private: atomic<Helper*> helper {nullptr}; mutex mtx; public: Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper == nullptr) { lock_guard<mutex> lg(mtx); myHelper = helper.load(memory_order_relaxed); if (myHelper == nullptr) { myHelper = new Helper(); helper.store(myHelper, memory_order_release); } } return myHelper; } };
获取加载和释放存储可确保我们在看到非 null 的 helper
时也会看到其字段得到正确初始化。我们还结合了之前的观察结果,即非争用加载可以使用 memory_order_relaxed
。
Java 程序员可能会将 helper
表示为 java.util.concurrent.atomic.AtomicReference<Helper>
并将 lazySet()
用作释放存储。加载操作会继续使用普通的 get()
调用。
在这两种情况下,性能调整都集中在初始化路径上,这不太可能是性能关键型路径。更可读的折衷方案可能是:
Helper* getHelper() { Helper* myHelper = helper.load(memory_order_acquire); if (myHelper != nullptr) { return myHelper; } lock_guard<mutex> lg(mtx); if (helper == nullptr) { helper = new Helper(); } return helper; }
这提供了相同的快速路径,但在非性能关键型慢速路径上采用默认的顺序一致操作。
即使在这里,helper.load(memory_order_acquire)
也可能在当前支持 Android 的架构上生成相同的代码,作为对 helper
的简单(顺序一致)引用。实际上,最有利的优化可能是引入 myHelper
以避免第二次加载,尽管未来的编译器可能会自动执行此操作。
获取/释放排序不会阻止存储明显延迟,也不会确保存储以一致的顺序对其他线程可见。因此,它不支持一种棘手但相当常见的编码模式,例如 Dekker 的互斥算法:所有线程首先设置一个标记,以表明其要执行某项操作;如果线程 t 之后发现没有其他线程尝试执行操作,就可以放心继续操作,因为知道不会受到任何干扰。其他线程均无法继续操作,因为 t 的标记仍然处于设置状态。如果使用获取/释放排序访问该标记,则会失败,因为这不会阻止在错误地继续之后使线程的标记延迟对其他线程可见。默认的 memory_order_seq_cst
会进行阻止。
不可变字段
如果某个对象字段在首次使用时初始化后从未更改,则可以使用弱排序访问对其进行初始化并随后进行读取。在 C++ 中,可将其声明为 atomic
并使用 memory_order_relaxed
进行访问,或者在 Java 中,可在没有 volatile
的情况下对其进行声明并且无需特殊措施即可访问。这就要求具备以下所有条件:
- 应可通过字段本身的值判断其是否已经过初始化。如需访问该字段,快速路径测试-返回值应仅读取该字段一次。在 Java 中,后者必不可少。即使字段测试已初始化,第二次加载也可能会读取之前未初始化的值。在 C++ 中,“一次读取”规则仅仅是一种还不错的做法。
- 初始化和后续加载都必须是原子性的,其中部分更新应不可见。在 Java 中,该字段不应是
long
或double
。在 C++ 中,需要进行原子赋值,就地构建不起作用,因为atomic
的构建不是原子性的。 - 重复初始化必须是安全的,因为多个线程可能会并发读取未初始化的值。在 C++ 中,这通常遵循对所有原子类型强加的“可平凡复制”要求;具有嵌套拥有指针的类型需要在复制构造函数中取消分配,并且不会可轻易复制。在 Java 中,某些引用类型是可以接受的:
- Java 引用仅限于只包含最终字段的不可变类型。不可变类型的构造函数不应发布对该对象的引用。在这种情况下,Java 最终字段规则可确保读取器在看到引用时也会看到初始化的最终字段。C++ 中没有相似规则,并且由于这个原因,指向所属对象的指针也是不可接受的(除了违反“可平凡复制”要求之外)。
结语
虽然本文并非浅尝辄止,但也并非极其详尽。这是一个极其广泛而深刻的话题。下面列出了可进一步探讨的几个方面:
- 实际的 Java 和 C++ 内存模型以 happens-before 关系表示,该关系指定保证何时以特定顺序执行两项操作。我们在定义数据争用时通俗地讨论了“同时”发生的两次内存访问。正式来说,其定义为其中一个不会发生在另一个之前。了解 happens-before 和 synchronizes-with 在 Java 或 C++ 内存模型中的实际定义具有指导意义。虽然“同时”的直观概念一般来说已经足够好,但这些定义具有指导意义,特别是当您考虑在 C++ 中使用弱排序的原子操作时。(当前的 Java 规范仅非正式地定义了
lazySet()
。) - 了解编译器在对代码进行重新排序时可以以及不可执行的操作。(JSR-133 规范有一些导致意外结果的合理转换示例。)
- 了解如何使用 Java 和 C++ 编写不可变类。(不仅仅是“构建后不改变任何内容”。)
- 内化《Effective Java 第 2 版》中“并发”部分的建议。(例如,您应避免调用在同步块内会被替换的方法。)
- 仔细阅读
java.util.concurrent
和java.util.concurrent.atomic
API,查看可用的内容。考虑使用@ThreadSafe
和@GuardedBy
(来自 net.jcip.annotations)等并发注释。
附录中的深入阅读部分提供了相关文档和网站的链接,这些资源详细地阐明了这些主题。
附录
实现同步存储
(这不是大多数程序员要实现的内容,但是对这些内容进行讨论可带来一些启发。)
对于 int
这样的小型内置类型和 Android 支持的硬件,普通的加载和存储指令可确保存储对加载同一位置的其他处理器完全可见或根本不可见。因此,一些“原子性”的基本概念是免费提供的。
正如我们之前看到的,这还不够。为确保实现顺序一致性,我们还需要防止对操作进行重新排序,并确保内存操作以一致的顺序对其他进程可见。事实证明,如果我们在强制执行前者方面做出明智的选择,后者在支持 Android 的硬件上是自动进行的,所以我们在这里基本上会忽略这一点。
通过防止编译器和硬件进行重新排序,保留内存操作的顺序。我们在这里重点介绍后者。
ARMv7、x86 和 MIPS 上的内存排序是使用“栅栏”指令强制执行的,这些指令大体上可以阻止栅栏之后的指令在栅栏之前的指令之前变得可见。(这些通常又称“屏障”指令,但这可能会与 pthread_barrier
风格的屏障混淆,后者的作用更多。)栅栏指令的确切含义是一个相当复杂的主题,必须解决由多种不同类型的栅栏提供的保证相互作用的方式,以及它们与通常由硬件提供的其他排序保证结合的方式。这是概要说明,因此我们将忽略其中的细节。
最基本的排序保证是由 C++ memory_order_acquire
和 memory_order_release
原子操作提供的:释放存储之前的内存操作应该在获取加载后可见。在 ARMv7 上,这是通过以下方式强制执行的:
- 在存储指令之前使用合适的栅栏指令。这可以防止使用存储指令对所有之前的内存访问进行重新排序。(它还会不必要地阻止使用之后的存储指令进行重新排序。)
- 在加载指令之后使用合适的栅栏指令,防止在后续访问中对加载进行重新排序。(再次提醒,除非至少通过之前的加载进行不必要的排序。)
这些足以进行 C++ 获取/释放排序。对于 Java volatile
或 C++ 顺序一致的 atomic
,这些是必要的,但不够充分。
如需了解我们还需要什么,请考虑我们在前面简要提到的 Dekker 算法的片段。flag1
和 flag2
是 C++ atomic
或 Java volatile
变量,初始值均为 false。
线程 1 | 线程 2 |
---|---|
flag1 = true |
flag2 = true |
“顺序一致性”意味着必须先执行对 flag
n 的一个赋值,并且该赋值可在另一个线程中通过测试看到。因此,我们永远不会看到这些线程同时执行“关键操作”。
但是,获取-释放排序所需的栅栏仅在每个线程的开头和末尾添加栅栏,这在这里并没有帮助。如果 volatile
/atomic
存储后面是 volatile
/atomic
加载,我们还需要确保它们不会被重新排序。这通常是通过在顺序一致的存储前后添加栅栏来强制执行的。(这再次比所要求的强得多,因为此栅栏通常会针对所有后续的内存访问对所有之前的内存访问进行排序。)
相反,我们可以将额外的栅栏与顺序一致的加载关联。由于存储的频率较低,因此我们介绍的惯例在 Android 上更加常见和常用。
如前所述,我们需要在两个操作之间插入存储/加载屏障。在虚拟机中为 volatile 访问执行的代码将如下所示:
volatile 加载 | volatile 存储 |
---|---|
reg = A |
fence for "release" (2) |
实际的机器架构通常提供多种类型的栅栏,这些栅栏对不同类型的访问进行排序且费用也各不相同。二者之间的选择是微妙的,且受到以下需求的影响:确保存储以一致的顺序对其他核心可见,并且由多个栅栏的组合强加的内存排序组成正确。如需了解详情,请参阅剑桥大学页面上的原子到实际处理器的映射集。
在某些架构(特别是 x86)上,“获取”和“释放”屏障是不必要的,因为硬件总是隐式强制执行足够的排序。因此,在 x86 上,其实只生成最后一个栅栏 (3)。同样,在 x86 上,原子读取-修改-写入操作隐式包含强栅栏。因此,这些从不需要任何栅栏。在 ARMv7 上,我们前面讨论的所有栅栏都是必需的。
ARMv8 提供 LDAR 和 STLR 指令,这些指令直接强制执行 Java volatile 或 C++ 顺序一致的加载和存储要求。这就避免了我们前面提到的不必要重新排序约束条件。ARM 上的 64 位 Android 代码使用这些条件,我们选择专注于放置 ARMv7 栅栏,因为它可以更好地满足实际要求。
深入阅读
下面是一些更具深度或广度的网页和文档资源。越普遍实用的文章越靠近列表顶部。
- 《共享内存一致性模型教程》(Shared Memory Consistency Models: A Tutorial)
- 该教程由 Adve 和 Gharachorloo 撰写于 1995 年;如果您想要更深入地了解内存一致性模型,可通过本教程入手。
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - 《内存屏障》(Memory Barriers)
- 一篇不错的小文章,总结了相关问题。
https://en.wikipedia.org/wiki/Memory_barrier - 《线程基础知识》(Threads Basics)
- Hans Boehm 介绍了 C++ 和 Java 中的多线程编程,并讨论了数据争用和基本的同步方法。
http://www.hboehm.info/c++mm/threadsintro.html - 《Java 并发编程实战》(Java Concurrency In Practice)
- 该书于 2006 年出版,详细介绍了各种主题。强烈推荐使用 Java 编写多线程代码的人阅读。
http://www.javaconcurrencyinpractice.com - JSR-133(Java 内存模型)常见问题解答
- 简要介绍 Java 内存模型,其中说明了同步、volatile 变量和构建最终字段的相关内容。(有点过时,尤其在讨论其他语言时。)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - 《Java 内存模型中程序转换的有效性》(Validity of Program Transformations in the Java Memory Model)
- 对 Java 内存模型遗留问题的偏技术性解释。这些问题不适用于无数据争用的程序。
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - 《java.util.concurrent 包概览》(Overview of package java.util.concurrent)
- 介绍
java.util.concurrent
包的文档。靠近页面底部的小节题为“内存一致性属性”,其中解释了各种类做出的保证。java.util.concurrent
包摘要 - 《Java 理论与实践:Java 中的安全构建技巧》(Java Theory and Practice: Safe Construction Techniques in Java)
- 本文详细探讨了在对象构建期间引用转义的危险,并提供了有关线程安全构造函数的指南。
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - 《Java 理论与实践:管理易失性》(Java Theory and Practice: Managing Volatility)
- 一篇实用的文章,介绍了使用 Java 中的 volatile 字段可以以及不可执行的操作。
http://www.ibm.com/developerworks/java/library/j-jtp06197.html - “双重检查锁定已损坏”声明
- Bill Pugh 详细介绍了双重检查锁定在没有
volatile
或atomic
情况下被破坏的各种方法,包括 C/C ++和 Java。
http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html - [ARM]《屏障石蕊测试和实战宝典》(Barrier Litmus Tests and Cookbook)
- 讨论了 ARM SMP 问题,借助简短的 ARM 代码的代码段进行阐述。如果您发现此页面上的示例过于具体,或者想要阅读 DMB 指令的正式说明,请阅读本文。该文还介绍了用于可执行代码上的内存屏障的指令(如果您要实时生成代码,这可能会很有用)。请注意,这早于 ARMv8,它还支持额外的内存排序指令,并转移到更强大的内存模型。(如需了解详情,请参阅《适用于 ARMv8-A 架构配置文件的 ARM® 架构参考手册 ARMv8》(ARM® Architecture Reference Manual ARMv8, for ARMv8-A architecture profile)。)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - 《Linux 内核内存屏障》(Linux Kernel Memory Barriers)
- 有关 Linux 内核内存屏障的文档,其中包括一些有用的示例和 ASCII 字符图形。
http://www.kernel.org/doc/Documentation/memory-barriers.txt - ISO/IEC JTC1 SC22 WG21(C++ 标准)14882(C++ 编程语言)第 1.10 节和第 29 条(“原子操作库”)
- C++ 原子操作功能的标准草案。此版本接近 C++14 标准,其中包括 C++11 中此方面的微小更改。
http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4527.pdf
(intro: http://www.hpl.hp.com/techreports/2008/HPL-2008-56.pdf) - ISO/IEC JTC1 SC22 WG14(C 标准)9899(C 编程语言)第 7.16 章(“原子 <stdatomic.h>”)
- ISO/IEC 9899-201x C 原子操作功能的标准草案。如需了解详情,请查看后续的缺陷报告。
http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - C/C++11 到处理器的映射(剑桥大学)
- Jaroslav Sevcik 和 Peter Sewell 将 C++ 原子翻译为各种常见处理器指令集的集合。
http://www.cl.cam.ac.uk/~pes20/cpp/cpp0xmappings.html - 《Dekker 的算法》(Dekker’s algorithm)
- “并发编程中互斥问题的第一个已知正解”。这篇维基百科文章提供了完整的算法,并讨论了如何更新以将其与现代优化编译器和 SMP 硬件结合使用。
https://en.wikipedia.org/wiki/Dekker's_algorithm - 有关 ARM vs. Alpha 和地址依赖关系的评论
- 来自 Catalin Marinas 的 arm-kernel 邮寄名单上的一封电子邮件。这封电子邮件很好地概述了地址和控件的依赖关系。
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - 《每个程序员都应该了解的内存知识》(What Every Programmer Should Know About Memory)
- Ulrich Drepper 编写的长篇文章,详细介绍了不同类型的内存(尤其是 CPU 缓存)。
http://www.akkadia.org/drepper/cpumemory.pdf - 《关于 ARM 弱一致内存模型的推理》(Reasoning about the ARM weakly consistent memory model)
- 这篇论文的作者是 ARM, Ltd 的 Chong 和 Ishtiaq。该论文尝试以严谨但易于接受的方式介绍 ARM SMP 内存模型。本文中的“可观察性”就来自这篇论文。同样,这早于 ARMv8。
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - 《JSR-133 编译器编写者的实战宝典》(The JSR-133 Cookbook for Compiler Writers)
- Doug Lea 与朋友合写了这篇文章来补充 JSR-133(Java 内存模型)文档。其中包含了很多编译器编写者使用的 Java 内存模型的初始实现指南集,并且仍然被广泛引用且可提供见解。遗憾的是,这里讨论的四种栅栏种类并不适合支持 Android 的架构,而上述 C++11 映射目前是更好的精确配方来源,即使对于 Java 也是如此。
http://g.oswego.edu/dl/jmm/cookbook.html - 《x86-TSO:适用于 x86 多处理器的严密且可用的程序员模型》(x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors)
- 精确描述了 x86 内存模型。遗憾的是,对 ARM 内存模型进行精确描述要复杂得多。
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf