Android 3.0 及更高版本的平台版本经过优化以支持多处理器架构。本文档介绍了在使用 C、C++ 和 Java 编程语言(为简洁起见,以下简称“Java”)为对称多处理器系统编写多线程代码时可能出现的各种问题。它旨在作为 Android 应用开发人员的入门指南,而不是对该主题的完整讨论。
简介
SMP 是“对称多处理器”的首字母缩写。它描述了一种设计,其中两个或多个相同的 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 | 可能(线程 1 首先运行) |
reg0=0, reg1=0 | 可能(线程 2 首先运行) |
reg0=0, reg1=3 | 可能(并发执行) |
reg0=5, reg1=0 | 不可能 |
如果我们在看到存储到 A 的操作之前就看到了 B=5,那么读取或写入操作就必须发生乱序。在顺序一致的机器上,这是不可能发生的。
单处理器,包括 x86 和 ARM,通常是顺序一致的。线程似乎以交错的方式执行,因为操作系统内核在它们之间切换。大多数 SMP 系统,包括 x86 和 ARM,都不是顺序一致的。例如,硬件通常会缓冲存储到内存中的数据,这样它们就不会立即到达内存并对其他核心可见。
细节差异很大。例如,x86 虽然不是顺序一致的,但仍然保证 reg0 = 5 和 reg1 = 0 这种情况是不可能的。存储是缓冲的,但它们的顺序保持不变。另一方面,ARM 则不然。缓冲存储的顺序没有保持,并且存储可能不会同时到达所有其他核心。这些差异对汇编程序员来说非常重要。但是,正如我们将在下面看到的,C、C++ 或 Java 程序员可以也应该以一种隐藏这些架构差异的方式进行编程。
到目前为止,我们不切实际地假设只有硬件会重新排序指令。实际上,编译器也会重新排序指令以提高性能。在我们的示例中,编译器可能会决定线程 2 中的一些后续代码在需要 reg0 之前需要 reg1 的值,因此先加载 reg1。或者某些之前的代码可能已经加载了 A,并且编译器可能会决定重用该值而不是再次加载 A。在这两种情况下,对 reg0 和 reg1 的加载都可能被重新排序。
重新排序对不同内存位置的访问,无论是在硬件中还是在编译器中,都是允许的,因为它不会影响单个线程的执行,并且可以显着提高性能。正如我们将看到的,只要稍加注意,我们也可以防止它影响多线程程序的结果。
由于编译器也可以重新排序内存访问,因此这个问题实际上并不是 SMP 特有的。即使在单处理器上,编译器也可能重新排序我们示例中对 reg0 和 reg1 的加载,并且线程 1 可以在重新排序的指令之间被调度。但是,如果我们的编译器碰巧没有重新排序,我们可能永远不会观察到这个问题。在大多数 ARM SMP 上,即使没有编译器重新排序,也可能会看到重新排序,可能是在大量成功执行之后。除非您使用汇编语言编程,否则 SMP 通常只会使您更有可能看到一直存在的问题。
无数据竞争编程
幸运的是,通常有一种简单的方法可以避免考虑所有这些细节。如果您遵循一些简单的规则,通常可以忘记前面部分的所有内容,除了“顺序一致性”部分。不幸的是,如果您意外违反了这些规则,其他复杂情况可能会变得可见。
现代编程语言鼓励所谓的“无数据竞争”编程风格。只要您承诺不引入“数据竞争”,并避免少数告诉编译器其他内容的结构,编译器和硬件就会承诺提供顺序一致的结果。这并不真正意味着它们避免内存访问重新排序。这确实意味着,如果您遵循规则,您将无法分辨内存访问是否正在被重新排序。这很像告诉你香肠是一种美味可口的食物,只要你承诺不去参观香肠工厂。数据竞争揭示了有关内存重新排序的丑陋真相。
什么是“数据竞争”?
当至少两个线程同时访问同一普通数据,并且至少其中一个线程修改它时,就会发生数据竞争。所谓“普通数据”,是指并非专门用于线程通信的同步对象。互斥量、条件变量、Java volatile 或 C++ 原子对象不是普通数据,并且允许它们的访问发生竞争。事实上,它们用于防止其他对象上的数据竞争。
为了确定两个线程是否同时访问同一内存位置,我们可以忽略上面关于内存重新排序的讨论,并假设顺序一致性。如果A
和B
是最初为假的普通布尔变量,则以下程序没有数据竞争
线程 1 | 线程 2 |
---|---|
if (A) B = true |
if (B) A = true |
由于操作不会重新排序,因此这两个条件都将计算为假,并且任何变量都不会被更新。因此不可能发生数据竞争。无需考虑如果线程 1 中来自A
的加载和对B
的存储以某种方式重新排序会发生什么。编译器不允许通过将其重写为“B = true; if (!A) B = false
”来重新排序线程 1。这就像在光天化日之下在城镇中央制作香肠一样。
数据竞争在基本内置类型(如整数和引用或指针)上正式定义。在一个线程中分配一个int
,同时在另一个线程中读取它,显然是一个数据竞争。但是,C++ 标准库和 Java 集合库都编写为允许您在库级别上推理数据竞争。它们承诺不会引入数据竞争,除非存在对同一容器的并发访问,并且至少其中一个更新了它。在一个线程中更新一个set<T>
,同时在另一个线程中读取它,允许库引入数据竞争,因此可以非正式地将其视为“库级数据竞争”。相反,在一个线程中更新一个set<T>
,同时在另一个线程中读取另一个set<T>
,不会导致数据竞争,因为库承诺在这种情况下不会引入(低级)数据竞争。
通常,对数据结构中不同字段的并发访问不会引入数据竞争。但是,此规则有一个重要的例外:C 或 C++ 中连续的位字段序列被视为单个“内存位置”。访问此类序列中的任何位字段都被视为出于确定数据竞争是否存在的目的而访问所有位字段。这反映了普通硬件无法更新单个位而无需同时读取和重写相邻位的能力。Java 程序员没有类似的顾虑。
避免数据竞争
现代编程语言提供了一些同步机制来避免数据竞争。最基本的工具是
- 锁或互斥量
- 互斥量(C++11
std::mutex
或pthread_mutex_t
)或 Java 中的synchronized
块可用于确保某些代码段不会与访问相同数据的其他代码段并发运行。我们将这些以及其他类似的工具统称为“锁”。在访问共享数据结构之前始终获取特定锁并在之后释放它,可以防止在访问数据结构时发生数据竞争。它还确保更新和访问是原子的,即没有其他对数据结构的更新可以在中间运行。这理所当然地成为迄今为止防止数据竞争最常用的工具。使用 Javasynchronized
块或 C++lock_guard
或unique_lock
可确保在发生异常时正确释放锁。 - 易变/原子变量
- 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),并且最初为假,则以下代码段是无数据竞争的
线程 1 | 线程 2 |
---|---|
A = ...
|
while (!flag) {}
|
由于线程 2 等待flag
被设置,因此对线程 2 中A
的访问必须发生在对线程 1 中A
的赋值之后,并且不会与之并发。因此,A
上没有数据竞争。flag
上的竞争不算作数据竞争,因为 volatile/atomic 访问不是“普通内存访问”。
实现需要防止或隐藏足够的内存重新排序,以使像前面的试金石测试这样的代码按预期运行。这通常使 volatile/atomic 内存访问比普通访问贵得多。
尽管前面的示例是无数据竞争的,但锁与 Java 中的Object.wait()
或 C/C++ 中的条件变量通常提供更好的解决方案,该解决方案不涉及在循环中等待以耗尽电池电量。
内存重新排序何时变得可见
无数据竞争编程通常使我们不必显式处理内存访问重新排序问题。但是,在某些情况下,重新排序确实变得可见- 如果您的程序存在导致意外数据竞争的错误,编译器和硬件转换可能会变得可见,并且程序的行为可能会令人惊讶。例如,如果我们在前面的示例中忘记声明
flag
volatile,则线程 2 可能会看到未初始化的A
。或者编译器可能会决定flag
在线程 2 的循环期间不可能发生变化,并将程序转换为线程 1 线程 2 A = ...
flag = truereg0 = flag; while (!reg0) {}
... = Aflag
为真。 - C++ 提供了在即使没有竞争条件的情况下显式放宽顺序一致性的机制。原子操作可以接受显式的
memory_order_
... 参数。类似地,java.util.concurrent.atomic
包提供了一组更受限制的类似功能,特别是lazySet()
。Java 程序员偶尔也会有意使用数据竞争来达到类似的效果。所有这些都可以在编程复杂度大幅增加的情况下提升性能。我们将在 下面 简要讨论它们。 - 一些 C 和 C++ 代码采用较旧的风格编写,与当前的语言标准并不完全一致,其中使用
volatile
变量代替atomic
变量,并通过插入所谓的fence(栅栏)或barrier(屏障)来显式禁止内存排序。这需要对访问重新排序和硬件内存模型进行显式推理。Linux 内核仍然使用这种编码风格。新的 Android 应用程序不应使用这种风格,并且本文也不会进一步讨论它。
实践
调试内存一致性问题可能非常困难。如果缺少锁、atomic
或 volatile
声明导致某些代码读取陈旧数据,您可能无法通过使用调试器检查内存转储来找出原因。当您发出调试器查询时,CPU 内核可能已经观察到所有访问,并且内存和 CPU 寄存器的内容看起来处于“不可能”的状态。
在 C 中不要做什么
这里我们将提供一些错误代码示例以及简单的修复方法。在此之前,我们需要讨论基本语言特性。
C/C++ 和 "volatile"
C 和 C++ 的 volatile
声明是一种非常特殊的工具。它们阻止编译器重新排序或删除volatile 访问。这对于访问硬件设备寄存器、映射到多个位置的内存或与 setjmp
相关的代码可能会有所帮助。但 C 和 C++ 的 volatile
与 Java 的 volatile
不同,它并非为线程通信而设计。
在 C 和 C++ 中,对 volatile
数据的访问可能会与对非 volatile
数据的访问重新排序,并且没有原子性保证。因此,即使在单处理器上,volatile
也不能用于在可移植代码中共享线程间的数据。C 的 volatile
通常不会阻止硬件重新排序访问,因此它本身在多线程 SMP 环境中更加无用。这就是 C11 和 C++11 支持 atomic
对象的原因。您应该使用它们代替。
许多较旧的 C 和 C++ 代码仍然滥用 volatile
进行线程通信。对于适合机器寄存器的的数据,这通常可以正常工作,前提是它与显式 fence 一起使用或在内存排序不重要的场景中使用。但不能保证它在未来的编译器中也能正常工作。
示例
在大多数情况下,与原子操作相比,使用锁(如 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
声明为原子来修复此问题。在 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 VM 的工作是确保结果仍然看起来顺序一致。
特别是,如果线程 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 来解决此问题。通过此更改,代码显然是正确的。
不幸的是,我们引入了锁争用的可能性,这可能会影响性能。与其将 get()
声明为 synchronized,不如将 mValue
声明为“volatile”。(请注意,incr()
仍然必须使用 synchronize
,因为否则 mValue++
不是单个原子操作。)这也能避免所有数据竞争,因此顺序一致性得以保留。incr()
会稍微慢一些,因为它会产生监视器进入/退出开销以及与易失性存储相关的开销,但 get()
会更快,因此即使在没有争用的情况下,如果读取次数远大于写入次数,这也是一个胜利。(另请参阅 AtomicInteger
,了解如何完全删除 synchronized 块。)
这是另一个示例,其形式与之前的 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
存在数据竞争。因此,赋值 sGoodies = goods
可能会在初始化 goods
中的字段之前被观察到。如果使用 volatile
关键字声明 sGoodies
,则顺序一致性将得到恢复,并且一切将按预期工作。
请注意,只有 sGoodies
引用本身是易失性的。对其中字段的访问不是。一旦 sGoodies
为 volatile
,并且内存排序正确保留,则无法并发访问字段。语句 z = sGoodies.x
将执行 MyClass.sGoodies
的易失性加载,然后执行 sGoodies.x
的非易失性加载。如果创建一个局部引用 MyGoodies localGoods = sGoodies
,则随后的 z = localGoods.x
将不会执行任何易失性加载。
Java 编程中更常见的习惯用法是臭名昭著的“双重检查锁定”
class MyClass { private Helper helper = null; public Helper getHelper() { if (helper == null) { synchronized (this) { if (helper == null) { helper = new Helper(); } } } return helper; } }
我们的想法是,我们希望为 MyClass
的实例关联一个 Helper
对象的单个实例。我们必须只创建它一次,因此我们通过专用的 getHelper()
函数创建并返回它。为了避免两个线程创建实例的竞争,我们需要同步对象创建。但是,我们不想为每次调用都支付“synchronized”块的开销,因此我们只在 helper
当前为 null 时执行该部分。
这段代码在 helper
字段上存在数据竞争。它可能在另一个线程中 helper == null
的同时被并发设置。
为了了解这种错误是如何发生的,可以考虑将相同的代码稍微重写一下,就像它被编译成类似 C 语言一样(我添加了几个整数字段来表示 Helper
的构造活动)
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
不为空,但其字段尚未设置并准备好使用。有关更多详细信息和更多故障模式,请参阅附录中“‘双重检查锁定已失效’声明”链接,或 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 mutex 分别只需要一个原子操作,并且如果不存在竞争,通常成本低于一次缓存未命中,因此您不会通过用原子操作替换 mutex 调用来节省太多。非平凡数据结构的无锁设计需要更多注意,以确保数据结构上的高级操作看起来是原子的(作为一个整体,而不仅仅是它们明确的原子部分)。
如果您确实使用了原子操作,使用 memory_order
... 或 lazySet()
放松排序可能会提供性能优势,但这需要比我们目前传达的更深入的理解。很大一部分使用这些操作的现有代码在事后被发现存在错误。如果可能,请避免使用这些操作。如果您的用例不完全符合下一节中的用例之一,请确保您要么是专家,要么已咨询过专家。
避免在 C/C++ 中使用 volatile
进行线程通信。
在 Java 中,并发问题通常最好通过使用 java.util.concurrent
包中的适当实用程序类来解决。该代码在 SMP 上编写良好且经过充分测试。
也许您可以做的最安全的事情是使您的对象不可变。来自 Java 的 String 和 Integer 等类的对象包含创建对象后无法更改的数据,从而避免了对这些对象的所有潜在数据竞争。书籍《Effective Java,第 2 版》在“第 15 条:最大限度地减少可变性”中提供了具体说明。尤其要注意声明 Java 字段为“final”的重要性 (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)
。初始加载 x
以计算 f(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
加载(或读-修改-写操作)读取写入的值,那么它也将观察到在 A 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; } };
获取加载和释放存储确保如果我们看到一个非空的 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 引用仅限于仅包含 final 字段的不可变类型。不可变类型的构造函数不应发布对该对象的引用。在这种情况下,Java final 字段规则确保如果读取器看到引用,它也将看到已初始化的 final 字段。C++ 没有类似的规则,出于同样的原因,指向拥有对象的指针也是不可接受的(此外还违反了“平凡可复制”的要求)。
结束语
虽然本文不仅仅是蜻蜓点水,但它也只做到浅尝辄止。这是一个非常广泛而深入的话题。一些进一步探索的领域
- 实际的 Java 和 C++ 内存模型是用先行发生关系表示的,该关系指定两个动作何时保证以特定顺序发生。当我们定义数据竞争时,我们非正式地谈论了两个内存访问“同时”发生。正式地,这被定义为两者都不先行发生。学习 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
变量,最初都为假。
线程 1 | 线程 2 |
---|---|
flag1 = true |
flag2 = true |
顺序一致性意味着必须首先执行对 flag
n 的赋值之一,并且另一个线程中的测试可以观察到它。因此,我们永远不会看到这两个线程同时执行“临界区代码”。
但是,获取/释放排序所需的栅栏只在每个线程的开始和结束处添加栅栏,在这里没有帮助。我们还需要确保,如果 volatile
/atomic
存储操作后紧跟着 volatile
/atomic
加载操作,这两个操作不会被重新排序。这通常通过添加一个栅栏来强制执行,该栅栏不仅在顺序一致的存储操作之前,而且还在之后。 (这再次比需要的要强得多,因为此栅栏通常会将所有较早的内存访问与所有较后的内存访问排序。)
我们也可以将额外的栅栏与顺序一致的加载操作相关联。由于存储操作不太频繁,因此我们描述的约定更常见,并在 Android 上使用。
正如我们在前面章节中看到的,我们需要在两个操作之间插入一个存储/加载屏障。虚拟机为易失性访问执行的代码将如下所示
易失性加载 | 易失性存储 |
---|---|
reg = A |
用于“释放”的栅栏 (2) |
真实的机器架构通常提供多种类型的栅栏,这些栅栏对不同类型的访问进行排序,并且可能具有不同的成本。在这些栅栏之间进行选择非常微妙,并且受需要确保以一致的顺序使存储操作对其他内核可见以及由多个栅栏的组合强加的内存排序正确组合的需求的影响。有关更多详细信息,请参阅剑桥大学页面,其中包含 原子操作到实际处理器的映射集合。
在某些架构(尤其是 x86)上,“获取”和“释放”屏障是不必要的,因为硬件始终隐式地强制执行足够的排序。因此,在 x86 上,实际上只生成最后一个栅栏 (3)。类似地,在 x86 上,原子读-修改-写操作隐式地包含一个强栅栏。因此,这些操作永远不需要任何栅栏。在 ARMv7 上,我们上面讨论的所有栅栏都是必需的。
ARMv8 提供 LDAR 和 STLR 指令,这些指令直接强制执行 Java 易失性或 C++ 顺序一致加载和存储的要求。这些指令避免了我们上面提到的不必要的重新排序约束。ARM 上的 64 位 Android 代码使用这些指令;我们选择在这里重点关注 ARMv7 栅栏放置,因为它更能说明实际需求。
进一步阅读
提供更多深度或广度的网页和文档。更有用的文章在列表的顶部。
- 共享内存一致性模型:教程
- 这篇由 Adve & Gharachorloo 于 1995 年撰写的文章,如果您想更深入地了解内存一致性模型,这是一个不错的起点。
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - 内存屏障
- 一篇很好地总结了相关问题的文章。
https://en.wikipedia.org/wiki/Memory_barrier - 线程基础
- Hans Boehm 撰写的一篇关于 C++ 和 Java 中多线程编程的入门文章。讨论了数据竞争和基本同步方法。
http://www.hboehm.info/c++mm/threadsintro.html - Java 并发实践
- 这本书出版于 2006 年,详细介绍了广泛的主题。强烈推荐给任何使用 Java 编写多线程代码的人。
http://www.javaconcurrencyinpractice.com - JSR-133(Java 内存模型)常见问题解答
- 对 Java 内存模型的简单介绍,包括对同步、易失性变量和最终字段构造的解释。(有点过时,尤其是在讨论其他语言时。)
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html - Java 内存模型中程序转换的有效性
- 对 Java 内存模型中剩余问题的相当技术性的解释。这些问题不适用于无数据竞争的程序。
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.112.1790&rep=rep1&type=pdf - java.util.concurrent 包概述
java.util.concurrent
包的文档。在页面底部附近有一个名为“内存一致性属性”的部分,解释了各种类提供的保证。java.util.concurrent
包摘要- Java 理论与实践:Java 中的安全构造技术
- 本文详细探讨了对象构造期间引用逃逸的危险,并提供了线程安全构造函数的指导原则。
http://www.ibm.com/developerworks/java/library/j-jtp0618.html - Java 理论与实践:管理易失性
- 一篇很好的文章,描述了您在 Java 中可以使用易失性字段完成什么以及无法完成什么。
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] 屏障 Litmus 测试和参考指南
- 关于 ARM SMP 问题的讨论,用简短的 ARM 代码片段进行说明。如果您发现此页面上的示例过于笼统,或者想阅读 DMB 指令的形式描述,请阅读本文。还描述了用于内存屏障的可执行代码的指令(如果您正在动态生成代码,则可能很有用)。请注意,这早于 ARMv8,ARMv8 还支持其他内存排序指令并转向了稍微更强的内存模型。(有关详细信息,请参阅“ARM® 架构参考手册 ARMv8,适用于 ARMv8-A 架构配置文件”。)
http://infocenter.arm.com/help/topic/com.arm.doc.genc007826/Barrier_Litmus_Tests_and_Cookbook_A08.pdf - Linux 内核内存屏障
- Linux 内核内存屏障的文档。包含一些有用的示例和 ASCII 艺术。
https://linuxkernel.org.cn/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
(简介: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 算法
- “并发编程中互斥问题第一个已知的正确解决方案”。维基百科文章包含完整的算法,并讨论了如何更新它以使其与现代优化编译器和 SMP 硬件一起使用。
https://en.wikipedia.org/wiki/Dekker's_algorithm - 关于 ARM 与 Alpha 以及地址依赖性的评论
- Catalin Marinas 在 arm-kernel 邮件列表中发送的一封电子邮件。包括地址和控制依赖性的不错总结。
http://linux.derkeiler.com/Mailing-Lists/Kernel/2009-05/msg11811.html - 每个程序员都应该了解的内存知识
- 一篇关于不同类型内存(尤其是 CPU 缓存)的非常长且详细的文章,由 Ulrich Drepper 撰写。
http://www.akkadia.org/drepper/cpumemory.pdf - 关于 ARM 弱一致性内存模型的推理
- 本文由 ARM 有限公司的 Chong & Ishtiaq 撰写。它试图以严谨但易于理解的方式描述 ARM SMP 内存模型。此处使用的“可观察性”定义来自本文。同样,这早于 ARMv8。
http://portal.acm.org/ft_gateway.cfm?id=1353528&type=pdf&coll=&dl=&CFID=96099715&CFTOKEN=57505711 - 面向编译器编写者的 JSR-133 参考指南
- Doug Lea 将其作为 JSR-133(Java 内存模型)文档的配套资料编写。它包含最初的 Java 内存模型实现指南集,这些指南被许多编译器编写者使用,并且至今仍被广泛引用,并且可能会提供见解。不幸的是,此处讨论的四种栅栏类型与 Android 支持的架构不匹配,并且上面的 C++11 映射现在是更精确的配方来源,即使对于 Java 也是如此。
http://g.oswego.edu/dl/jmm/cookbook.html - x86-TSO:x86 多处理器的严格且可用的程序员模型
- 对 x86 内存模型的精确描述。不幸的是,ARM 内存模型的精确描述要复杂得多。
http://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf