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、B、C)表示,CPU 寄存器以“reg”开头。所有内存最初都为零。指令从上到下执行。在这里,线程 1 在位置 A 存储值 3,然后在位置 B 存储值 5。线程 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,通常是顺序一致的。随着 OS 内核在它们之间切换,线程似乎以交错方式执行。大多数 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++ atomic 对象不是普通数据,并且它们的访问是允许竞争的。事实上,它们被用来防止其他对象上的数据竞争。
为了确定两个线程是否同时访问同一个内存位置,我们可以忽略上面的内存重新排序讨论,并假设顺序一致性。如果 A
和 B
是最初为 false 的普通布尔变量,则以下程序没有数据竞争
线程 1 | 线程 2 |
---|---|
|
|
由于操作没有被重新排序,这两个条件都将评估为 false,并且任一变量都不会被更新。因此不可能发生数据竞争。无需考虑如果在线程 1 中对 A
的加载和对 B
的存储以某种方式被重新排序会发生什么。编译器不允许通过将其重写为“B = true; if (!A) B = false
”来重新排序线程 1。那就像在光天化日之下在镇中心制造香肠一样。
数据竞争在官方上定义在基本内置类型上,例如整数和引用或指针。在另一个线程同时读取 int
的同时向其赋值显然是数据竞争。但是 C++ 标准库和 Java Collections 库都编写为允许您在库级别推理数据竞争。它们承诺不引入数据竞争,除非对同一个容器进行并发访问,并且其中至少一个访问对其进行更新。在一个线程中更新 set<T>
的同时,在另一个线程中同时读取它,允许库引入数据竞争,因此可以非正式地将其视为“库级别数据竞争”。相反,在一个线程中更新一个 set<T>
,而在另一个线程中读取另一个不同的容器,则不会导致数据竞争,因为在这种情况下,库承诺不引入(低级别)数据竞争。
通常,对数据结构中不同字段的并发访问不会引入数据竞争。但是,此规则有一个重要的例外:C 或 C++ 中连续的位字段序列被视为一个“内存位置”。出于确定是否存在数据竞争的目的,访问此类序列中的任何位字段都被视为访问它们全部。这反映了常见硬件无法在不读取和重新写入相邻位的情况下更新单个位。Java 程序员没有类似的担忧。
避免数据竞争
现代编程语言提供了许多同步机制来避免数据竞争。最基本的工具是
- 锁或互斥锁
- 互斥锁(C++11 的
std::mutex
或pthread_mutex_t
)或 Java 中的synchronized
块可用于确保某些代码段不会与访问相同数据的其他代码段同时运行。我们将这些以及其他类似设施统称为“锁”。在访问共享数据结构之前始终获取特定锁并在之后释放,可以防止访问数据结构时发生数据竞争。它还确保更新和访问是原子性的,即没有其他对数据结构的更新可以在中间运行。这是迄今为止防止数据竞争最常用且理所当然的工具。使用 Javasynchronized
块或 C++lock_guard
或unique_lock
可确保在发生异常时正确释放锁。 - Volatile/原子变量
- 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 |
---|---|
|
|
由于线程 2 等待 flag
被设置,线程 2 对 A
的访问必须发生在线程 1 对 A
的赋值之后,而不是与之同时发生。因此,在 A
上没有数据竞争。对 flag
的竞争不计为数据竞争,因为 volatile/atomic 访问不是“普通内存访问”。
实现需要充分阻止或隐藏内存重新排序,以使像前面的并发测试这样的代码行为如预期。这通常会使 volatile/atomic 内存访问比普通访问昂贵得多。
尽管前面的示例是无数据竞争的,但锁以及 Java 中的 Object.wait()
或 C/C++ 中的条件变量通常提供了更好的解决方案,而无需在循环中等待并消耗电池电量。
何时内存重新排序变得可见
无数据竞争编程通常使我们无需明确处理内存访问重新排序问题。但是,在以下几种情况下,重新排序会变得可见- 如果您的程序存在导致无意中发生数据竞争的错误,编译器和硬件转换可能会变得可见,并且您的程序行为可能令人惊讶。例如,如果在前面的示例中我们忘记将
flag
声明为 volatile,线程 2 可能会看到未初始化的A
。或者编译器可能会决定在线程 2 的循环期间 flag 不可能改变,并将程序转换为线程 1 线程 2 A = ...
flag = true
reg0 = flag; while (!reg0) {}
... = A
flag
为 true。 - C++ 提供了在没有竞争的情况下明确放宽顺序一致性保证的设施。原子操作可以接受显式的
memory_order_
... 参数。类似地,java.util.concurrent.atomic
包提供了一组更受限制的类似设施,特别是lazySet()
。Java 程序员偶尔也会使用有意识的数据竞争来实现类似的效果。所有这些都以编程复杂性的大幅增加为代价提供了性能改进。我们只在下文简要讨论它们。 - 一些 C 和 C++ 代码是以一种较旧的风格编写的,与当前的语言标准不完全一致,其中使用
volatile
变量代替atomic
变量,并且通过插入所谓的 fences 或 barriers 来明确禁止内存排序。这需要显式地推理访问重新排序并理解硬件内存模型。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
进行线程通信。对于适合机器寄存器的数据,如果与显式 fences 一起使用或在内存排序不重要的情况下使用,这通常可以正确工作。但它不保证在未来的编译器中正确工作。
示例
在大多数情况下,使用锁(例如 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 语言的内置锁定机制。每个对象都有一个关联的“监视器”,可用于提供互斥访问。如果两个线程尝试在同一个对象上“synchronized”,其中一个将等待直到另一个完成。
正如我们上面提到的,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()
会稍微慢一些,因为它既有监视器进入/退出开销,又有与 volatile 存储相关的开销,但 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
引用本身是 volatile 的。对其内部字段的访问不是。一旦 sGoodies
是 volatile
,并且内存排序得到正确维护,字段就不能被并发访问。语句 z = sGoodies.x
将执行对 MyClass.sGoodies
的 volatile 加载,然后是对 sGoodies.x
的非 volatile 加载。如果您创建本地引用 MyGoodies localGoods = sGoodies
,那么随后的 z = localGoods.x
将不执行任何 volatile 加载。
Java 编程中一种更常见的惯用法是臭名昭著的“双重检查锁定”(double-checked locking)
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
非 null,但其字段尚未设置并准备使用。有关更多详细信息和更多失败模式,请参阅附录中的“‘双重检查锁定已失效’声明”链接,或 Josh Bloch 的《Effective Java,第 2 版》中第 71 项(“明智地使用延迟初始化”)。
有两种方法可以解决这个问题
- 做简单的事情,删除外部检查。这确保了我们永远不会在 synchronized 块之外检查
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
不能用于阻止其他相互竞争的内存访问的重新排序。它不保证生成机器内存 fence 指令。它可以用于通过仅在另一个线程满足特定条件时执行代码来防止数据竞争。
应采取的措施
在 C/C++ 中,首选 C++11 同步类,例如 std::mutex
。如果不能,请使用相应的 pthread
操作。这些操作包含正确的内存 fence,可在所有 Android 平台版本上提供正确(除非另有说明,否则为顺序一致)且高效的行为。务必正确使用它们。例如,请记住条件变量等待可能会在未发出信号的情况下虚假返回,因此应出现在循环中。
最好避免直接使用原子函数,除非您实现的 数据结构非常简单,例如计数器。锁定和解锁 pthread 互斥锁各需要一个原子操作,如果没有竞争,其成本通常低于一次缓存未命中,因此通过用原子操作替换互斥锁调用不会节省太多。对于非简单数据结构的无锁设计,需要更加谨慎,以确保数据结构上的更高级别操作显示为原子性的(作为一个整体,而不仅仅是其明确的原子部分)。
如果您确实使用原子操作,使用 memory_order
... 或 lazySet()
放松排序可能会提供性能优势,但这需要比我们目前所传达的更深入的理解。事后发现,大量使用这些的代码都存在错误。如果可能,请避免使用这些。如果您的用例与下一节中的用例不完全匹配,请确保您要么是专家,要么已咨询过专家。
在 C/C++ 中,避免使用 volatile
进行线程通信。
在 Java 中,并发问题通常最好通过使用 java.util.concurrent
包中的适当实用类来解决。该代码在 SMP 上经过精心编写和充分测试。
也许您可以做的最安全的事情是使您的对象不可变。像 Java 的 String 和 Integer 等类的对象持有的数据一旦对象创建后就无法更改,从而避免了这些对象上数据竞争的所有可能性。书籍《Effective Java,第 2 版》在“第 15 项:最小化可变性”中提供了具体说明。特别要注意将 Java 字段声明为“final”的重要性 (Bloch)。
即使对象是不可变的,也要记住,在没有任何同步的情况下将其传递给另一个线程是一种数据竞争。这在 Java 中偶尔是可以接受的(参见下文),但这需要非常谨慎,并且很可能导致脆弱的代码。如果不是对性能至关重要的场景,请添加 volatile
声明。在 C++ 中,在没有适当同步的情况下传递指向不可变对象的指针或引用,就像任何数据竞争一样,是一个 bug。在这种情况下,由于存储重新排序,接收线程可能会看到未初始化的方法表指针,这很可能导致间歇性崩溃。
如果现有的库类或不可变类都不适合,应使用 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++ 确实保证对这种计数器的第二次加载不会返回小于同一线程中先前加载的值。当然,除非计数器溢出。) - 常见的情况是发现试图通过执行单独的原子(或非原子)读写操作来计算近似计数器值的代码,但没有使整个增量操作成为原子性的。通常的论点是,这对于性能计数器或类似用途来说“足够接近”。通常不是这样。当更新足够频繁时(您可能关心的情况),通常会丢失大部分计数。在四核设备上,通常可能会丢失一半以上的计数。(简单练习:构建一个双线程场景,其中计数器更新一百万次,但最终计数器值为一。)
简单的标志通信
一个 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; } };
acquire 加载和 release 存储确保,如果我们看到一个非 null 的 helper
,那么我们也将看到其字段被正确初始化。我们还结合了先前的观察,即非竞争加载可以使用 memory_order_relaxed
。
Java 程序员可以想象将 helper
表示为 java.util.concurrent.atomic.AtomicReference<Helper>
,并使用 lazySet()
作为 release 存储。加载操作将继续使用普通的 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
来消除第二次加载,尽管未来的编译器可能会自动完成这一点。
Acquire/release 排序不会阻止存储明显延迟,也不能确保存储以一致的顺序对其他线程可见。因此,它不支持由 Dekker 互斥算法示例的一个棘手但相当常见的编码模式:所有线程首先设置一个标志表明它们想做某事;如果线程 t 随后注意到没有其他线程正在尝试做某事,它可以安全地继续进行,因为它知道不会有干扰。其他线程将无法继续进行,因为 t 的标志仍然设置。如果使用 acquire/release 排序访问该标志,则该模式会失败,因为这不能阻止在其他线程错误地继续进行后,延迟地将其标志对其他线程可见。默认的 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++ 内存模型是用 先行发生 (happens-before) 关系来表达的,该关系指定了何时保证两个操作按照特定顺序发生。当我们定义数据竞争时,我们非正式地谈论两个内存访问“同时”发生。官方将其定义为两者之间没有发生顺序关系。学习 Java 或 C++ 内存模型中 先行发生 (happens-before) 和 同步于 (synchronizes-with) 的实际定义具有启发性。尽管“同时”的直观概念通常足够好,但这些定义具有指导意义,特别是如果您正在考虑在 C++ 中使用弱有序原子操作时。(当前的 Java 规范仅非常非正式地定义了
lazySet()
。) - 探索编译器在重新排序代码时允许和不允许做的事情。(JSR-133 规范有一些很好的示例,说明了导致意外结果的合法转换。)
- 了解如何在 Java 和 C++ 中编写不可变类。(这不仅仅是“构造后不改变任何东西”。)
- 深入理解《Effective Java,第 2 版》的并发部分中的建议。(例如,在 synchronized 块内部,您应避免调用那些旨在被覆盖的方法。)
- 通读
java.util.concurrent
和java.util.concurrent.atomic
API 文档,了解可用内容。考虑使用并发注解,例如@ThreadSafe
和@GuardedBy
(来自 net.jcip.annotations)。
附录中的延伸阅读部分包含指向能更好地阐明这些主题的文档和网站的链接。
附录
实现同步存储
(这并不是大多数程序员会亲自实现的内容,但讨论具有启发性。)
对于像 int
这样的小型内置类型,以及 Android 支持的硬件,普通的加载和存储指令确保存储将对加载相同位置的另一个处理器可见,要么完全可见,要么完全不可见。因此,一些基本的“原子性”概念是免费提供的。
正如我们之前看到的,这还不够。为了确保顺序一致性,我们还需要阻止操作的重新排序,并确保内存操作以一致的顺序对其他进程可见。事实证明,只要我们明智地选择强制执行前者,后者在 Android 支持的硬件上是自动的,因此我们在此基本上忽略它。
内存操作的顺序通过阻止编译器重新排序和阻止硬件重新排序来维护。这里我们重点讨论后者。
ARMv7、x86 和 MIPS 上的内存排序通过“fence”指令强制执行,这些指令大致阻止 fence 之后的指令在 fence 之前的指令可见之前变得可见。(这些指令通常也称为“barrier”指令,但这可能会与 pthread_barrier
风格的 barrier 混淆,后者的功能远不止于此。)fence 指令的精确含义是一个相当复杂的主题,需要处理多种不同类型的 fence 提供的保证如何相互作用,以及这些保证如何与硬件通常提供的其他排序保证结合。这是一篇高层概述,因此我们将忽略这些细节。
最基本的排序保证是 C++ memory_order_acquire
和 memory_order_release
原子操作提供的保证:在 release 存储之前的内存操作应该在 acquire 加载之后可见。在 ARMv7 上,这通过以下方式实现
- 在存储指令之前加上适当的 fence 指令。这可以防止所有先前的内存访问与存储指令重新排序。(它也并非必要地阻止了与后续存储指令的重新排序。)
- 在加载指令之后加上适当的 fence 指令,防止加载与后续访问重新排序。(并且再次提供与至少先前加载的不必要的排序。)
这些共同足以实现 C++ 的 acquire/release 排序。对于 Java 的 volatile
或 C++ 的顺序一致 atomic
,它们是必要的,但不是充分的。
为了看看我们还需要什么,考虑我们前面简要提到的 Dekker 算法片段。flag1
和 flag2
是 C++ 的 atomic
或 Java 的 volatile
变量,最初都为 false。
线程 1 | 线程 2 |
---|---|
|
|
顺序一致性意味着必须首先执行对 flag
n 的赋值之一,并被另一个线程中的测试看到。因此,我们永远不会看到这些线程同时执行“critical-stuff”。
但是 acquire-release 排序所需的 fencing 只在每个线程的开头和结尾添加 fence,这在这里没有帮助。我们还需要确保如果一个 volatile
/atomic
存储后面跟着一个 volatile
/atomic
加载,两者不会被重新排序。这通常通过不仅在顺序一致存储之前,而且在之后添加一个 fence 来强制执行。(这再次比所需的强得多,因为这个 fence 通常会相对于所有后续内存访问来排序所有先前内存访问。)
我们可以改为将额外的 fence 与顺序一致加载关联起来。由于存储不太频繁,我们描述的约定更常见,并且在 Android 上使用。
正如我们在前面一节中看到的,我们需要在两个操作之间插入一个 store/load barrier。在 VM 中为 volatile 访问执行的代码看起来像这样
volatile 加载 | volatile 存储 |
---|---|
|
用于“release”的 fence (2) |
真实的机器架构通常提供多种类型的 fence,它们对不同类型的访问进行排序,并且可能具有不同的成本。这些 fence 之间的选择很微妙,并且受到需要确保存储以一致的顺序对其他核心可见,以及多个 fence 组合施加的内存排序正确组合的影响。有关更多详细信息,请参阅剑桥大学页面,其中包含原子操作到实际处理器的映射集合。
在某些架构上,特别是 x86,“acquire”和“release” barrier 是不必要的,因为硬件始终隐式强制执行足够的排序。因此在 x86 上,实际上只生成了最后一个 fence (3)。类似地,在 x86 上,原子读-修改-写操作隐式包含一个强的 fence。因此,这些操作从不需要任何 fence。在 ARMv7 上,我们上面讨论的所有 fence 都是必需的。
ARMv8 提供了 LDAR 和 STLR 指令,它们直接强制执行 Java volatile 或 C++ 顺序一致加载和存储的要求。这些指令避免了我们上面提到的不必要的重新排序约束。ARM 上的 64 位 Android 代码使用这些指令;我们选择在这里重点关注 ARMv7 的 fence 放置,因为它更能阐明实际的要求。
延伸阅读
提供更深入或更广泛的网页和文档。通常更有用的文章排在列表顶部。
- 共享内存一致性模型:教程
- 由 Adve & Gharachorloo 于 1995 年撰写,如果您想更深入地研究内存一致性模型,这是一个很好的起点。
http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf - 内存 Barrier
- 总结这些问题的一篇不错的短文。
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 内存模型)FAQ
- Java 内存模型的温和介绍,包括同步、volatile 变量和 final 字段构造的解释。(有点过时,尤其是在讨论其他语言时。)
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 中使用 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 并发测试与手册
- 讨论 ARM SMP 问题,并辅以简短的 ARM 代码片段。如果您觉得本页的示例不够具体,或者想阅读 DMB 指令的正式描述,请阅读本文。还描述了用于可执行代码内存 barrier 的指令(如果您正在即时生成代码,可能有用)。请注意,这早于 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 内核内存 Barrier
- Linux 内核内存 barrier 的文档。包含一些有用的示例和 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 - 每个程序员都应该了解的内存知识
- Ulrich Drepper 撰写的一篇非常长且详细的文章,关于不同类型的内存,特别是 CPU 缓存。
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 内存模型的初步实现指南,并且至今仍被广泛引用,很可能提供深刻见解。遗憾的是,此处讨论的四种 fence 类型与 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