第 7 章 了解处理器
来自 《Rust Atomics and Locks》 的翻译文章,英文原文:https://marabos.nl/atomics/hardware.html
虽然第 2 章和第 3 章的理论是我们编写正确的并发代码所需要的全部内容,但另外,对处理器层面的实际情况有一个大致的了解也是非常有用的。在本章中,我们将探讨原子操作编译成的机器指令、不同的处理器架构有何不同、为什么存在弱版本的 compare_exchange
、内存顺序在最低级别的单个指令中意味着什么,以及缓存如何关联对这一切。
本章的目标不是了解每个处理器体系结构的每个相关细节。这需要装满许多书架的书,其中许多可能还没有写成或没有公开发行。 相反,本章的目标是对原子在处理器层面的工作原理有一个总体的概念,以便在实现和优化涉及原子的代码时能够做出更明智的决定。当然,为了满足我们对幕后发生的事情的好奇心——从所有抽象理论中解脱出来。
为了使事情尽可能具体,我们将重点关注两种特定的处理器架构:
x86-64:
大多数笔记本电脑、台式机、服务器和一些游戏机中使用的 Intel 和 AMD 处理器实现的 x86 架构的 64 位版本。虽然最初的 16 位 x86 架构及其非常流行的 32 位扩展是由 Intel 开发的,但我们现在称为 x86-64 的 64 位版本最初是由 AMD 开发的扩展,通常称为 AMD64。英特尔也开发了自己的 64 位架构 IA-64,但最终采用了 AMD 更流行的 x86 扩展(名称为 IA-32e、EM64T,以及后来的英特尔 64)。
ARM64:
几乎所有现代移动设备、高性能嵌入式系统以及最近的笔记本电脑和台式机都使用 64 位版本的 ARM 架构。它也称为 AArch64,是作为 ARMv8 的一部分引入的。 ARM 的早期(32 位)版本在很多方面都很相似,但应用范围更广。从汽车到电子 COVID 测试,各种可以想象到的嵌入式系统中的许多流行微控制器都基于 ARMv6 和 ARMv7。
处理器指令
通过仔细查看编译器的输出,处理器将执行的确切指令,我们可以大致了解处理器级别的工作原理。
汇编简介
当编译用任何编译语言(如 Rust 或 C)编写的软件时,您的代码会被翻译成机器指令,这些指令可以由最终运行您的程序的处理器执行。这些指令高度特定于您为其编译程序的处理器体系结构。
这些指令,也称为机器代码,以二进制形式编码,这对我们人类来说是非常不可读的。汇编是这些指令的人类可读表示。每条指令都由一行文本表示,通常以单个单词或首字母缩写词开头以标识指令,然后是其参数或操作数。汇编程序将文本表示形式转换为二进制表示形式,而反汇编程序则相反。
从像 Rust 这样的语言编译后,原始源代码的大部分结构都消失了。根据优化级别,函数和函数调用可能仍然是可识别的。但是,结构或枚举等类型已简化为字节和地址,循环和条件已简化为具有基本跳转或分支指令的平面结构。
下面是一个程序一小部分的汇编代码片段的示例,对于某些虚构的架构:
ldr x, 1234 // load from memory address 1234 into x
li y, 0 // set y to zero
inc x // increment x
add y, x // add x to y
mul x, 3 // multiply x by 3
cmp y, 10 // compare y to 10
jne -5 // jump five instructions back if not equal
str 1234, x // store x to memory address 1234
在此示例中, x 和 y 是寄存器的名称。寄存器是处理器的一部分,而不是主内存的一部分,通常保存一个整数或内存地址。在 64 位架构上,它们的大小通常为 64 位。寄存器的数量因架构而异,但通常非常有限。寄存器基本上用作计算中的临时暂存器,在将内容存储回内存之前保留中间结果的地方。
引用特定内存地址的常量,例如上例中的 1234 和 -5 ,通常会替换为更易于阅读的标签。在将汇编代码转换为二进制机器代码时,汇编程序会自动将它们替换为实际地址。
使用标签,前面的示例可能看起来像这样:
ldr x, SOME_VAR
li y, 0
my_loop: inc x
add y, x
mul x, 3
cmp y, 10
jne my_loop
str SOME_VAR, x
由于标签的名称只是汇编的一部分,而不是二进制机器代码的一部分,反汇编程序将不知道最初使用的是什么标签,很可能只会使用无意义的生成名称,如 label1 和 var2 。
所有不同体系结构的完整汇编课程不在本书范围内,但不是阅读本章的先决条件。一个非常笼统的理解足以理解这些例子,因为我们只会阅读汇编,而不是编写它。每个示例中的相关说明都将进行足够详细的解释,即使没有组装经验也能理解。
要查看 Rust 编译器生成的确切机器代码,我们有几种选择。我们可以照常编译我们的代码,然后使用反汇编程序(例如 objdump )将生成的二进制文件转回汇编。使用编译器在编译过程中产生的调试信息,反汇编器可以产生与 Rust 源代码的原始函数名称相对应的标签。这种方法的缺点是您需要一个支持您正在编译的特定处理器架构的反汇编程序。虽然 Rust 编译器支持多种架构,但许多反汇编程序只支持它们编译的一种架构。
一个更直接的选择是通过使用 rustc
的 --emit=asm
标志要求编译器生成程序集而不是二进制文件。这种方法的一个缺点是生成的输出包含很多不相关的行,包含我们不需要的汇编器和调试工具的信息。
有很棒的工具,例如 cargo-show-asm
与 cargo
集成,并自动执行使用正确标志编译 crate
的过程,为您感兴趣的函数找到相关程序集,并突出显示包含实际指令的相关行。
对于相对较小的片段,最简单和最推荐的方法是使用 Web 服务,例如 Matt Godbolt 的优秀 Compiler Explorer。该网站允许您使用包括 Rust 在内的多种语言编写代码,并使用选定的编译器版本直接查看相应的编译程序集。它甚至使用着色来显示 Rust 的哪些行对应于汇编的哪些行,只要优化后这种对应关系仍然存在。
由于我们想要查看不同体系结构的程序集,因此我们需要为 Rust 编译器指定一个准确的编译目标。我们将为 x86-64 使用 x86_64-unknown-linux-musl
,为 ARM64 使用 aarch64-unknown-linux-musl
。这些已在 Compiler Explorer 中得到直接支持。如果您在本地编译,例如使用 cargo-show-asm
或上面提到的其他方法,您需要确保为这些目标安装了 Rust 标准库,这通常是使用 rustup target add
完成的。
在所有情况下,要编译的目标都是使用 --target
编译器标志选择的。例如, --target=aarch64-unknown-linux-musl
。如果您没有指定任何目标,它会自动选择您当前所在的平台。 (或者,对于 Compiler Explorer,它所在的平台目前为 x86_64-unknown-linux-gnu
。)
此外,建议启用 -O
标志以启用优化(或使用 Cargo 时使用 --release
),因为这将启用优化并禁用溢出检查,这可以大大减少我们要看的小函数的生成汇编量。
为了尝试一下,让我们看一下以下函数的 x86-64 和 ARM64 程序集:
pub fn add_ten(num: &mut i32) {
*num += 10;
}
使用 -O --target=aarch64-unknown-linux-musl
作为上述任何方法的编译器标志,我们将获得类似于以下 ARM64 汇编输出的内容:
add_ten:
ldr w8, [x0]
add w8, w8, #10
str w8, [x0]
ret
x0
寄存器包含我们函数的参数, num
, i32
递增 10 的地址。首先, ldr
指令将该内存地址中的 32 位值加载到 w8
寄存器中。然后, add
指令将十加到 w8
并将结果存回 w8
。然后, str
指令将 w8
寄存器存储回相同的内存地址。最后, ret
指令标记函数结束并使处理器跳回并继续调用 add_ten
的函数。
如果我们为 x86_64-unknown-linux-musl
编译完全相同的代码,我们会得到类似这样的东西:
add_ten:
add dword ptr [rdi], 10
ret
这一次,一个名为 rdi
的寄存器用于 num
参数。更有趣的是,在 x86-64 上,单个 add
指令可以完成在 ARM64 上需要三个指令的操作:加载、递增和存储值。
这通常是复杂指令集计算机 (CISC) 体系结构(例如 x86)上的情况。这种架构上的指令通常有很多变体,例如对寄存器进行操作或直接对特定大小的内存进行操作。 (程序集中的 dword
指定 32 位操作。)
相比之下,精简指令集计算机 (RISC) 架构,如 ARM,通常具有更简单的指令集,变体很少。大多数指令只能对寄存器进行操作,加载和存储到内存需要单独的指令。这允许使用更简单的处理器,从而降低成本或有时提高性能。
这种差异与原子获取和修改指令尤其相关,我们马上就会看到。
提示
虽然编译器通常非常聪明,但它们并不总能生成最佳的程序集,尤其是在涉及原子操作时。如果您在试验时发现汇编中看似不必要的复杂性让您感到困惑,那通常只意味着编译器的未来版本有更多优化机会。
加载和存储
在深入研究更高级的内容之前,让我们先看看用于最基本原子操作的指令:加载和存储。
通过 &mut i32
的常规非原子存储在 x86-64 和 ARM64 上只需要一条指令,如下所示:
rust 源码:
pub fn a(x: &mut i32) {
*x = 0;
}
编译x86-64:
a:
mov dword ptr [rdi], 0
ret
编译ARM64:
a:
str wzr, [x0]
ret
在 x86-64 上,非常通用的 mov 指令用于将数据从一个地方复制(“移动”)到另一个地方;在这种情况下,从零常量到内存。在 ARM64 上, str
(存储寄存器)指令用于将 32 位寄存器存储到内存中。在这种情况下,使用了特殊的 wzr
寄存器,它总是包含零。
如果我们将代码更改为将宽松的原子存储改为使用 AtomicI32 ,我们将得到:
rust 源码:
pub fn a(x: &AtomicI32) {
x.store(0, Relaxed);
}
编译 x86-64:
a:
mov dword ptr [rdi], 0
ret
编译ARM64:
a:
str wzr, [x0]
ret
也许有些令人惊讶的是,程序集与非原子版本相同。事实证明, mov
和 str
指令已经是原子的了。它们要么发生了,要么根本没有发生。显然,这里 &mut i32
和 &AtomicI32
之间的任何差异仅与编译器检查和优化有关,但对处理器没有意义——至少对于这两种体系结构上的宽松存储操作而言是这样。
当我们查看宽松的加载操作时,也会发生同样的事情:
在 x86-64 上再次使用 mov
指令,这次是从内存复制到 32 位 eax
寄存器。在 ARM64 上, ldr
(加载寄存器)指令用于将内存中的值加载到 w0
寄存器中。
提示
32 位的 eax
和 w0
寄存器用于传回函数的 32 位返回值。 (对于 64 位值,使用 64 位 rax
和 x0
寄存器。)
虽然处理器显然不区分原子和非原子存储和加载,但我们不能安全地忽略 Rust 代码中的差异。如果我们使用 &mut i32
,Rust 编译器可能会假设没有其他线程可以同时访问同一个 i32 ,并且可能会决定以这样一种方式转换或优化代码,即存储操作不再导致单个对应存储指令。例如,对于非原子 32 位加载或存储,使用两个单独的 16 位指令进行是完全正确的,尽管有些不寻常。
读-修改-写操作
对于诸如加法之类的读-修改-写操作,事情变得更加有趣。正如本章前面所讨论的,非原子读-修改-写操作通常在 RISC 架构(如 ARM64)上编译为三个独立的指令(读、修改和写),但通常可以在 CISC 上用一条指令完成x86-64 等架构。这个简短的例子表明:
在我们查看相应的原子操作之前,我们可以合理地假设这次我们将看到非原子版本和原子版本之间的区别。这里的 ARM64 版本显然不是原子的,因为加载和存储是在不同的步骤中发生的。
虽然从程序集本身看并不直接明显,但 x86-64 版本不是原子的。 add
指令将由处理器在后台拆分为多个微指令,并分别执行加载值和存储结果的步骤。这在单核计算机上无关紧要,因为在线程之间切换处理器内核通常只发生在指令之间。然而,当多个核心并行执行指令时,我们不能再假设指令全部以原子方式发生而不考虑执行单个指令所涉及的多个步骤。
x86 锁前缀
为了支持多核系统,Intel 引入了一个名为 lock
的指令前缀。它用作 add
等指令的修饰符,使它们的操作原子化。
lock
前缀最初导致处理器在指令执行期间暂时阻止所有其他内核访问内存。虽然这是一种简单有效的方法,可以让某些东西对其他核心来说是原子的,但为每个原子操作停止世界可能效率很低。较新的处理器具有更高级的 lock
前缀实现,它不会阻止其他内核在不相关的内存上运行,并允许内核在等待特定内存可用时做有用的事情。
lock
前缀只能应用于非常有限的指令,包括 add
、 sub
、 and
、 not
、 or
和 xor
,这些都是非常有用的操作能够原子地做。对应于原子交换操作的 xchg
(交换)指令有一个隐含的锁前缀:不管 lock
前缀如何,它的行为都像 lock xchg
。
让我们通过将上一个示例更改为对 AtomicI32
进行操作来查看 lock add
的运行情况:
rust 源码:
pub fn a(x: &AtomicI32) {
x.fetch_add(10, Relaxed);
}
编译 x86-64:
a:
lock add dword ptr [rdi], 10
ret
正如预期的那样,与非原子版本的唯一区别是 lock
前缀。
在上面的例子中,我们忽略了 fetch_add
的返回值,即运算前 x
的值。但是,如果我们使用该值, add
指令将不再有效。 add
指令可以为下一条指令提供一些有用的信息,例如更新后的值是零还是负数,但它不提供完整的(原始或更新后的)值。相反,可以使用另一条指令: xadd
(“交换和添加”),它将最初加载的值放入寄存器。
我们可以通过对代码进行少量修改以返回 fetch_add
返回的值来查看它的实际效果:
rust 源码:
pub fn a(x: &AtomicI32) -> i32 {
x.fetch_add(10, Relaxed)
}
编译 x86-64:
a:
mov eax, 10
lock xadd dword ptr [rdi], eax
ret
现在使用包含值 10 的寄存器代替常量 10。 xadd
指令将重用该寄存器来存储旧值。
不幸的是,除了 xadd
和 xchg
之外,其他可加锁前缀的指令(如 sub
、 and
和 or
)都没有这样的变体。例如,没有 xsub
指令。对于减法,这不是问题,因为 xadd
可以与负值一起使用。然而,对于 and
和 or
,没有这样的选择。
对于仅影响单个位的 and
、 or
和 xor
操作,例如 fetch_or(1)
或 fetch_and(!1)
,可以使用 bts
(位测试和设置)、 btr
(位测试和复位)和 btc
(位测试和补码)指令。这些指令还允许使用 lock
前缀,仅更改一位,并使该一位的先前值可用于随后的指令,例如条件跳转。
当这些操作影响多个位时,它们不能由单个 x86-64 指令表示。同样, fetch_max
和 fetch_min
操作也没有对应的x86-64指令。对于这些操作,我们需要一种不同于简单的 lock
前缀的策略。
x86 比较交换指令
在第 2 章的“比较和交换操作”中,我们看到了如何将任何原子获取和修改操作实现为比较和交换循环。这正是编译器将用于无法由单个 x86-64 指令表示的操作的内容,因为此体系结构确实包含(可加锁前缀) cmpxchg
(比较和交换)指令。
我们可以通过将最后一个示例从 fetch_add
更改为 fetch_or
来看到这一点:
rust 源码:
pub fn a(x: &AtomicI32) -> i32 {
x.fetch_or(10, Relaxed)
}
编译 x86-64:
a:
mov eax, dword ptr [rdi]
.L1:
mov ecx, eax
or ecx, 10
lock cmpxchg dword ptr [rdi], ecx
jne .L1
ret
第一个 mov
指令将原子变量的值加载到 eax
寄存器中。以下 mov
和 or
指令将该值复制到 ecx
并应用二进制 or
操作,这样 eax
包含旧值而 ecx
包含新值。 cmpxchg
指令之后的行为与 Rust 中的 compare_exchange
方法完全一样。它的第一个参数是要操作的内存地址(原子变量),第二个参数( ecx
)是新值,期望值隐式取自 eax
,返回值隐式存储在 eax
中。它还设置了一个状态标志,后续指令可以使用该标志根据操作是否成功有条件地进行分支。在这种情况下, jne
(如果不相等则跳转)指令用于跳回到 .L1
标签以在失败时重试。
这是 Rust 中等效的比较和交换循环的样子,就像我们在第 2 章的“比较和交换操作”中看到的那样:
pub fn a(x: &AtomicI32) -> i32 {
let mut current = x.load(Relaxed);
loop {
let new = current | 10;
match x.compare_exchange(current, new, Relaxed, Relaxed) {
Ok(v) => return v,
Err(v) => current = v,
}
}
}
编译此代码会生成与 fetch_or
版本完全相同的程序集。这表明,至少在 x86-64 上,它们确实在各个方面都是等价的。
提示
在 x86-64 上, compare_exchange
和 compare_exchange_weak
没有区别。两者都编译为 lock cmpxchg
指令。
加载链接和存储条件指令
在 RISC 架构上最接近比较和交换循环的是加载链接/存储条件 (LL/SC) 循环。它涉及两条成对出现的特殊指令:一条加载链接指令,主要表现为常规加载指令,以及一条存储条件指令,其主要表现为常规存储指令。它们成对使用,两条指令都针对相同的内存地址。与常规加载和存储指令的主要区别在于存储是有条件的:如果自加载链接指令以来任何其他线程已覆盖该内存,它会拒绝存储到内存中。
这两条指令允许我们从内存中加载一个值,修改它,并且只有在我们加载它后没有人覆盖该值的情况下才将新值存储回来。如果失败,我们可以简单地重试。一旦成功,我们就可以安全地假装整个操作是原子的,因为它没有被打断。
使这些指令可行和高效实施的关键有两个:(1) 一次只能跟踪一个内存地址(每个内核),以及 (2) 存储条件允许有漏报,这意味着它即使没有改变那段特定的内存,也可能无法存储。
这使得在跟踪内存更改时可能不太精确,代价是通过 LL/SC 循环可能需要几个额外的周期。对内存的访问可以不是按字节跟踪,而是按 64 字节块或每千字节,甚至整个内存进行跟踪。不太准确的内存跟踪会导致 LL/SC 循环中出现更多不必要的循环,从而显着降低性能,但也降低了实现的复杂性。
将事情发挥到极致,一个基本的、假设的单核系统可以使用一种策略,它根本不跟踪对内存的写入。相反,它可以跟踪中断或上下文切换,这些事件可能导致处理器切换到另一个线程。如果在一个没有任何并行性的系统中,没有发生这样的事件,它可以安全地假设没有其他线程可以接触内存。如果发生任何此类事件,它可以假设最坏的情况,拒绝商店,并希望在循环的下一次迭代中好运。
ARM 加载独占和存储独占
在 ARM64 上,或者至少在 ARMv8 的第一个版本中,没有原子的获取和修改或比较和交换操作可以由单个指令表示。由于其 RISC 特性,加载和存储步骤与计算和比较是分开的。
ARM64 的加载链接和存储条件指令称为 ldxr
(加载专用寄存器)和 stxr
(存储专用寄存器)。此外, clrex
(清除独占)指令可用作 stxr
的替代方法,以停止跟踪对内存的写入而不存储任何内容。
要查看它们的实际效果,让我们看看在 ARM64 上执行原子加法时会发生什么:
rust 源码:
pub fn a(x: &AtomicI32) {
x.fetch_add(10, Relaxed);
}
编译ARM64:
a:
.L1:
ldxr w8, [x0]
add w9, w8, #10
stxr w10, w9, [x0]
cbnz w10, .L1
ret
我们得到的东西看起来与我们之前(在“读-修改-写操作”中)得到的非原子版本非常相似:加载指令、添加指令和存储指令。加载和存储指令已被其“独有的”LL/SC 版本所取代,并且出现了新的 cbnz
(比较和非零分支)指令。 stxr
指令如果成功则在 w10
中存储一个零,否则则存储一个 1。如果失败, cbnz
指令使用它来重新启动整个操作。
请注意,与 x86-64 上的 lock add
不同,我们不需要做任何特殊的事情来检索旧值。在上面的例子中,旧值在操作成功后仍然在寄存器 w8
中可用,所以不需要像 xadd
这样的专门指令。
这种 LL/SC 模式非常灵活:它不仅适用于一组有限的操作,如 add
和 or
,而且几乎适用于任何操作。我们可以通过将相应的指令放在 ldxr
和 stxr
指令之间来轻松实现原子 fetch_divide
或 fetch_shift_left
。但是,如果它们之间的指令太多,则中断导致额外周期的可能性越来越高。通常,编译器会尝试使 LL/SC 模式中的指令数量尽可能少,以避免 LL/SC 循环很少(甚至永远不会)成功,从而可能永远旋转。
ARMv8.1 原子指令
ARM64 的更高版本(ARMv8.1 的一部分)还包括用于常见原子操作的新 CISC 样式指令。例如,新的 ldadd
(加载和添加)指令相当于原子 fetch_add
操作,不需要 LL/SC 循环。它甚至包括像 fetch_max
这样的操作指令,这些指令在 x86-64 上是不存在的。
它还包括一条与 compare_exchange
相对应的 cas
(比较和交换)指令。使用这条指令时, compare_exchange
和 compare_exchange_weak
没有区别,就像在x86-64上一样。
虽然 LL/SC 模式非常灵活并且非常适合通用 RISC 模式,但这些新指令的性能可能更高,因为它们可以更容易地针对专用硬件进行优化。
ARM上的比较和交换
如果比较失败, compare_exchange
操作可以很好地映射到这个 LL/SC 模式,方法是使用条件分支指令跳过存储指令。让我们看看生成的汇编:
rust 源码:
pub fn a(x: &AtomicI32) {
x.compare_exchange_weak(5, 6, Relaxed, Relaxed);
}
编译ARM64:
a:
ldxr w8, [x0]
cmp w8, #5
b.ne .L1
mov w8, #6
stxr w9, w8, [x0]
ret
.L1:
clrex
ret
提示
请注意, compare_exchange_weak
操作通常是在一个循环中使用,如果比较失败,就会重复进行。然而,对于这个例子,我们只调用它一次,并忽略它的返回值,它向我们展示了相关的汇编而不会分心。
ldxr
指令加载该值,然后立即将其与 cmp
(比较)指令与预期值 5 进行比较。 b.ne
(如果不相等则转移)指令将导致跳转到 .L1
如果值不符合预期,则使用标签,此时使用 clrex
指令中止 LL/SC 模式。如果值为 5,则流程继续通过 mov
和 stxr
指令将新值 6 存储在内存中,但前提是在此期间没有任何内容覆盖 5。
请记住, stxr
允许有漏报;即使五个没有被覆盖,它也可能在这里失败。没关系,因为我们使用的是 compare_exchange_weak
,它也允许有漏报。事实上,这就是弱版本 compare_exchange
存在的原因。
如果我们用 compare_exchange
替换 compare_exchange_weak
,我们得到几乎相同的汇编,除了一个额外的分支以在失败时重新启动操作:
rust 源码:
pub fn a(x: &AtomicI32) {
x.compare_exchange(5, 6, Relaxed, Relaxed);
}
编译ARM64:
a:
mov w8, #6
.L1:
ldxr w9, [x0]
cmp w9, #5
b.ne .L2
stxr w9, w8, [x0]
cbnz w9, .L1
ret
.L2:
clrex
ret
正如预期的那样,现在有一个额外的 cbnz
(比较和非零分支)指令在失败时重新启动 LL/SC 循环。此外, mov
指令已移出循环,以使循环尽可能短。
比较和交换循环的优化
正如我们在“x86 比较和交换指令”中看到的, fetch_or
操作和等效的 compare_exchange
循环在 x86-64 上编译为完全相同的指令。人们可能希望在 ARM 上发生同样的情况,至少对于 compare_exchange_weak
,因为加载和弱比较和交换操作可以直接映射到 LL/SC 指令。
不幸的是,目前(从 Rust 1.66.0 开始)并没有发生这种情况。
虽然这在未来可能会改变,因为编译器一直在改进,但编译器很难安全地将手动编写的比较和交换循环转换为相应的 LL/SC 循环。原因之一是可以放在 stxr
和 ldxr
指令之间的指令的数量和类型有限制,这不是编译器在应用其他优化时要记住的事情。在诸如比较和交换循环之类的模式仍然可以识别的时候,表达式将编译成的确切指令尚不清楚,这使得在一般情况下实现这种优化非常棘手。
因此,至少在我们得到更智能的编译器之前,如果可能的话,建议使用专用的获取和修改方法而不是比较和交换循环。
缓存
读写内存很慢,很容易耗费执行数十或数百条指令的时间。这就是为什么所有高性能处理器都实现了缓存,以尽可能避免与相对较慢的内存交互。现代处理器中内存缓存的确切实现细节很复杂,部分专有的,最重要的是,在编写软件时与我们无关。毕竟,缓存这个名字来自于法语单词 caché,意思是隐藏。尽管如此,在优化软件性能时,了解大多数处理器如何实现缓存的基本原理还是非常有用的。(当然,这并不是说我们需要一个借口来进一步了解一个有趣的话题)。
除了非常小的微控制器,几乎所有现代处理器都使用缓存。这样的处理器从不直接与主内存交互,而是通过其缓存路由每个单独的读写请求。如果指令需要从内存中读取某些内容,处理器将向其缓存请求该数据。如果已经缓存,缓存会快速响应缓存数据,避免与主存交互。否则,它将不得不走慢速路径,缓存可能不得不向主内存请求相关数据的副本。一旦主存响应,缓存不仅会最终响应原来的读取请求,还会记住数据,以便下次请求该数据时能够更快地响应。如果缓存已满,它会通过删除一些它认为最不可能有用的旧数据来腾出空间。
当指令想要将某些内容写入内存时,缓存可以决定保留修改后的数据而不将其写入主内存。随后对同一内存地址的任何读取请求都将获得修改后数据的副本,而忽略主内存中的过时数据。只有当修改后的数据需要从缓存中删除以腾出空间时,它才会真正将数据写回主内存。
在大多数处理器架构中,高速缓存以 64 字节为单位读取和写入内存,即使只请求了一个字节也是如此。这些块通常称为高速缓存行。通过缓存请求字节周围的整个 64 字节块,任何需要访问该块中任何其他字节的后续指令都不必等待主内存。
缓存一致性
在现代处理器中,通常有不止一层的缓存。第一个缓存或一级 (L1) 缓存是最小和最快的。它不是与主内存对话,而是与二级 (L2) 高速缓存对话,二级缓存更大,但速度更慢。 L2 缓存可能是与主内存通信的缓存,或者可能还有另一个更大、更慢的 L3 缓存——甚至可能是 L4 缓存。
添加额外的层并不会改变它们的工作方式;每一层都可以独立运作。然而,事情变得有趣的地方是当有多个处理器内核时,每个处理器内核都有自己的缓存。在多核系统中,每个处理器内核通常都有自己的 L1 缓存,而 L2 或 L3 缓存通常与部分或全部其他内核共享。
在这些情况下,天真的缓存实现会崩溃,因为缓存不能再假设它控制与下一层的所有交互。如果一个缓存接受写入并将某些缓存行标记为已修改而不通知其他缓存,则缓存的状态可能会变得不一致。在缓存将数据写入下一级之前,修改后的数据不仅对其他内核不可用,而且最终可能会与缓存在其他缓存中的不同修改发生冲突。
为了解决这个问题,使用了缓存一致性协议。这样的协议定义了缓存如何精确地操作和相互通信以保持所有内容处于一致状态。使用的确切协议因体系结构、处理器模型甚至缓存级别而异。
我们将讨论两种基本的缓存一致性协议。现代处理器使用这些的许多变体。
直写协议
在实现直写缓存一致性协议的缓存中,写入不会被缓存,而是立即发送到下一层。其他缓存通过相同的共享通信通道连接到下一层,这意味着它们可以观察到其他缓存与下一层的通信。当缓存观察到对其当前已缓存的地址的写入时,它会立即删除或更新自己的缓存行以保持一切一致。
使用此协议,缓存永远不会包含任何处于修改状态的缓存行。虽然这大大简化了事情,但它抵消了写入缓存的任何好处。当仅为阅读而优化时,这可能是一个不错的选择。
MESI协议
MESI 缓存一致性协议以其为缓存行定义的四种可能状态命名:已修改、独占、共享和无效。已修改 (M) 用于包含已修改但尚未写入内存(或下一级缓存)的数据的缓存行。独占 (E) 用于包含未缓存在任何其他缓存(同一级别)中的未修改数据的缓存行。共享 (S) 用于未修改的缓存行,这些缓存行也可能出现在一个或多个其他(同一级别)缓存中。无效 (I) 用于未使用(空的或丢弃的)缓存行,其中不包含任何有用的数据。
使用此协议的缓存与同一级别的所有其他缓存进行通信。他们互相发送更新和请求,使彼此保持一致成为可能。
当缓存收到对它尚未缓存的地址的请求时(也称为缓存未命中),它不会立即从下一层请求它。相反,它首先询问其他缓存(在同一级别)是否有可用的缓存行。如果它们都没有,缓存将继续向(较慢的)下一层请求地址,并将生成的新缓存行标记为独占(E)。当此缓存行随后被写操作修改时,缓存可以将状态更改为已修改 (M) 而无需通知其他缓存,因为它知道其他缓存中没有相同的缓存行。
当请求任何其他缓存中已经可用的缓存行时,结果是共享 (S) 缓存行,直接从其他缓存中获取。如果缓存行处于修改 (M) 状态,它将首先被写入(或刷新)到下一层,然后将其更改为共享(S)并共享它。如果它处于独占(E)状态,它将立即更改为共享(S)。
如果缓存想要独占而不是共享访问(例如,因为它会立即修改数据),其他缓存将不会将缓存行保持在共享(S)状态,而是完全删除它将其更改为无效(I)。在这种情况下,结果是独占 (E) 缓存行。
如果缓存需要独占访问它在共享 (S) 状态下已经可用的缓存行,它只是告诉其他人在将其升级为独占 (E) 之前删除缓存行。
该协议有多种变体。例如,MOESI 协议添加了一个额外的状态来允许共享修改后的数据,而无需立即将其写入下一层,而 MESIF 协议使用一个额外的状态来决定哪个缓存响应对多个可用的共享缓存行的请求缓存。现代处理器通常使用更精细和专有的缓存一致性协议。
对性能的影响
虽然缓存大部分对我们是隐藏的,但缓存行为会对我们的原子操作的性能产生重大影响。让我们尝试衡量其中一些影响。
测量单个原子操作的速度非常棘手,因为它们非常快。为了能够得到一些有用的数字,我们必须重复一个操作,比如十亿次,并测量总共需要多长时间。例如,我们可以尝试测量十亿次加载操作所花费的时间,如下所示:
static A: AtomicU64 = AtomicU64::new(0);
fn main() {
let start = Instant::now();
for _ in 0..1_000_000_000 {
A.load(Relaxed);
}
println!("{:?}", start.elapsed());
}
不幸的是,这不能按预期工作。
在启用优化(例如,使用 cargo run --release
或 rustc -O
)的情况下运行它时,我们会看到一个不合理的低测量时间。发生的事情是编译器足够聪明,可以理解我们没有使用加载的值,所以它决定完全优化掉“不必要的”循环。
为避免这种情况,我们可以使用特殊的 std::hint::black_box
函数。这个函数接受任何类型的参数,它只是返回而不做任何事情。这个函数的特别之处在于,编译器会尽量不对函数的作用做任何假设;它把它当作一个可以做任何事情的“黑匣子”。
我们可以使用它来避免某些会使基准测试无用的优化。在这种情况下,我们可以将加载操作的结果传递给 black_box()
,以停止任何假设我们实际上不需要加载值的优化。但这还不够,因为编译器仍然可以假定 A
始终为零,从而使加载操作变得不必要。为避免这种情况,我们可以在开始时将对 A
的引用传递给 black_box()
,这样编译器可能不再假设只有一个线程访问 A
。毕竟,它必须假设 black_box(&A)
可能已经生成了一个与 A
交互的额外线程。
让我们试试看:
use std::hint::black_box;
static A: AtomicU64 = AtomicU64::new(0);
fn main() {
black_box(&A); // New!
let start = Instant::now();
for _ in 0..1_000_000_000 {
black_box(A.load(Relaxed)); // New!
}
println!("{:?}", start.elapsed());
}
多次运行时输出会有点波动,但在不太新的 x86-64 计算机上,它似乎给出了大约 300 毫秒的结果。
要查看任何缓存效果,我们将生成一个与原子变量交互的后台线程。这样,我们就可以看到它是否影响了主线程的加载操作。
首先,让我们尝试只在后台线程上进行加载操作,如下所示:
static A: AtomicU64 = AtomicU64::new(0);
fn main() {
black_box(&A);
thread::spawn(|| { // New!
loop {
black_box(A.load(Relaxed));
}
});
let start = Instant::now();
for _ in 0..1_000_000_000 {
black_box(A.load(Relaxed));
}
println!("{:?}", start.elapsed());
}
请注意,我们不是在测量后台线程上操作的性能。我们仍然只测量主线程执行十亿次加载操作需要多长时间。
运行此程序会产生与之前类似的测量结果:在同一台 x86-64 计算机上进行测试时,它会在 300 毫秒左右波动一点。后台线程对主线程没有太大影响。它们可能各自运行在一个单独的处理器内核上,但两个内核的缓存都包含 A
的副本,允许非常快速的访问。
现在让我们更改后台线程来执行存储操作:
static A: AtomicU64 = AtomicU64::new(0);
fn main() {
black_box(&A);
thread::spawn(|| {
loop {
A.store(0, Relaxed); // New!
}
});
let start = Instant::now();
for _ in 0..1_000_000_000 {
black_box(A.load(Relaxed));
}
println!("{:?}", start.elapsed());
}
这一次,我们确实看到了显着差异。在同一台 x86-64 机器上运行这个程序现在会导致输出波动大约整整三秒,几乎是以前的十倍。较新的计算机将显示出不那么显着但仍然非常可测量的差异。例如,在最近的 Apple M1 处理器上,它从 350 毫秒变成了 500 毫秒,在最近的 x86-64 AMD 处理器上,它从 250 毫秒变成了 650 毫秒。
这种行为符合我们对缓存一致性协议的理解:存储操作需要独占访问缓存行,这会减慢不再共享缓存行的其他内核上的后续加载操作。
失败的比较和交换操作
有趣的是,在大多数处理器架构上,当后台线程只执行比较和交换操作时,我们在存储操作中看到的相同效果也会发生,即使它们都失败了。
为了尝试这一点,我们可以将(后台线程的)存储操作替换为对永远不会成功的 compare_exchange
的调用:
…
loop {
// Never succeeds, because A is never 10.
black_box(A.compare_exchange(10, 20, Relaxed, Relaxed).is_ok());
}
…
因为 A 始终为零,所以此 compare_exchange
操作永远不会成功。它会加载 A
的当前值,但永远不会将其更新为新值。
人们可能会合理地期望它的行为与加载操作相同,因为它不修改原子变量。然而,在大多数处理器架构上, compare_exchange
的指令将要求独占访问相关缓存行,而不管比较是否成功。
这意味着在自旋循环中不使用 compare_exchange
(或 swap
)可能是有益的,就像我们在第 4 章中对 SpinLock
所做的那样,而是先使用 load
操作来检查锁是否有被解锁。这样,我们就可以避免不必要地声明对相关缓存行的独占访问权。
由于缓存是按缓存行进行的,而不是按单个字节或变量进行的,因此我们应该能够看到使用相邻变量而不是相同变量的相同效果。为了尝试这个,让我们使用三个原子变量而不是一个,让主线程只使用中间变量,让后台线程只使用另外两个,如下所示:
static A: [AtomicU64; 3] = [
AtomicU64::new(0),
AtomicU64::new(0),
AtomicU64::new(0),
];
fn main() {
black_box(&A);
thread::spawn(|| {
loop {
A[0].store(0, Relaxed);
A[2].store(0, Relaxed);
}
});
let start = Instant::now();
for _ in 0..1_000_000_000 {
black_box(A[1].load(Relaxed));
}
println!("{:?}", start.elapsed());
}
运行它会产生与以前类似的结果:在同一台 x86-64 计算机上需要几秒钟。即使 A[0] 、 A[1] 和 A[2] 各只被一个线程使用,我们仍然看到相同的效果,就好像我们在两个线程上使用相同的变量一样。原因是 A[1] 与其中一个或两个共享缓存行。运行后台线程的处理器核心反复要求独占访问包含 A[0] 和 A[2] 的缓存行,其中还包含 A[1] ,从而减慢 A[1] 上的“不相关”操作。这种效应称为 错误共享(false sharing)。
我们可以通过将原子变量间隔得更远来避免这种情况,这样它们每个都有自己的缓存行。如前所述,64 字节是缓存行大小的合理猜测,所以让我们尝试将我们的原子包装在 64 字节对齐的结构中,如下所示:
#[repr(align(64))] // This struct must be 64-byte aligned.
struct Aligned(AtomicU64);
static A: [Aligned; 3] = [
Aligned(AtomicU64::new(0)),
Aligned(AtomicU64::new(0)),
Aligned(AtomicU64::new(0)),
];
fn main() {
black_box(&A);
thread::spawn(|| {
loop {
A[0].0.store(1, Relaxed);
A[2].0.store(1, Relaxed);
}
});
let start = Instant::now();
for _ in 0..1_000_000_000 {
black_box(A[1].0.load(Relaxed));
}
println!("{:?}", start.elapsed());
}
#[repr(align)]
属性使我们能够以字节为单位告诉编译器我们类型的(最小)对齐方式。由于 AtomicU64
只有 8
个字节,这将为我们的 Aligned
结构添加 56
个字节的填充。
运行此程序不再产生缓慢的结果。相反,我们得到了与根本没有后台线程时相同的结果:在与之前相同的 x86-64 计算机上运行时大约需要 300 毫秒。
提示
根据您尝试使用的处理器类型,您可能需要使用 128 字节对齐才能看到相同的效果。
上面的实验表明,最好不要将不相关的原子变量彼此靠近放置。例如,一个密集的小互斥锁阵列可能并不总是像使互斥锁保持更远距离的替代结构那样执行。
另一方面,当多个(原子)变量相关并且经常快速连续访问时,最好将它们放在一起。例如,第 4 章中的 SpinLock<T>
将 T
存储在 AtomicBool
的旁边,这意味着包含 AtomicBool
的缓存行很可能也将包含 T
,这样一个声明 (独占)访问一个也包括另一个。这是否有益完全取决于具体情况。
重新排序
一致性缓存,例如通过我们在本章前面探讨的 MESI 协议,通常不会影响程序的正确性,即使涉及多个线程也是如此。一致性缓存引起的唯一可观察到的差异归结为时间差异。然而,现代处理器实现了更多的优化,这些优化可能会对正确性产生重大影响,至少在涉及多线程时是这样。
在第 3 章的开头,我们简要讨论了指令重排序,即编译器和处理器如何改变指令的顺序。仅关注处理器,以下是指令或其效果可能发生乱序的各种方式的一些示例:
存储缓冲区(Store buffers)
由于写入速度可能很慢,即使有缓存,处理器内核通常也包含一个存储缓冲区。对内存的写操作可以存储在这个存储缓冲区中,速度非常快,以允许处理器立即继续执行后面的指令。然后,在后台,通过写入(L1)缓存来完成写入操作,这会明显变慢。这样,处理器不必等待高速缓存一致性协议立即采取行动以获得对相关高速缓存行的独占访问权。
只要特别注意处理来自同一内存地址的后续读取操作,这对于在同一处理器内核上作为同一线程的一部分运行的指令来说是完全不可见的。但是,在短时间内,写入操作对其他内核尚不可见,导致不同内核上运行的不同线程对内存的看法不一致。
失效队列(Invalidation queues)
不管具体的一致性协议是什么,并行运行的缓存都需要处理无效请求:因为某个特定的缓存行即将被修改而变得无效,所以需要放弃该缓存行的指令。作为一种性能优化,这种请求通常不会被立即处理,而是排队等待(稍后)处理。当这样的失效队列被使用时,缓存不再总是一致的,因为缓存行在被丢弃之前可能会短暂地过期。然而,这对单线程程序没有影响,只是加快了它的速度。唯一的影响是其他内核的写操作的可见性,现在可能会出现(非常轻微的)延迟。
流水线(Pipelining)
另一个非常常见的能够显著提高性能的处理器特性是流水线:如果可能的话,以并行方式执行连续的指令。在一条指令执行完毕之前,处理器可能已经开始执行下一条指令。现代处理器通常可以在第一条指令还在执行的时候就开始执行相当多的串行指令。
如果每条指令都对前一条指令的结果进行操作,这并没有多大帮助;他们每个人仍然需要等待之前的结果。但是当一条指令可以独立于前一条指令执行时,它甚至可能先完成。例如,一条仅递增寄存器的指令可能很快完成,而先前启动的指令可能仍在等待从内存中读取某些内容,或其他一些缓慢的操作。
虽然这不会影响单线程程序(速度除外),但当对内存进行操作的指令在前一个指令完成执行之前完成时,与其他内核的交互可能会发生乱序。
有许多方法可以使现代处理器最终以与预期完全不同的顺序执行指令。其中涉及许多专有技术,其中一些只有在发现可能被恶意软件利用的细微错误时才会公开。然而,当它们按预期工作时,它们都有一个共同点:它们不会影响单线程程序,除了时序,但会导致与其他内核的交互似乎以不一致的顺序发生。
允许重新排序内存操作的处理器架构也提供了一种通过特殊指令来防止这种情况发生的方法。例如,这些指令可能会强制处理器刷新其存储缓冲区,或在继续之前完成任何流水线指令。有时,这些指令只会阻止某种类型的重新排序。例如,可能有一条指令可以防止存储操作相互之间重新排序,而仍然允许加载操作重新排序。哪些类型的重新排序可能发生,以及如何防止它们,取决于处理器的结构。
内存顺序
当使用 Rust 或 C 等语言执行任何原子操作时,我们指定内存顺序以通知编译器我们的顺序要求。编译器将为处理器生成正确的指令,以防止处理器以违反规则的方式重新排序指令。
允许哪种类型的指令重新排序取决于内存操作的类型。对于非原子和宽松的原子操作,任何类型的重新排序都是可以接受的。在另一个极端,顺序一致的原子操作根本不允许任何类型的重新排序。
获取操作可能不会与任何后续的内存操作一起重新排序,而释放操作可能不会与它之前的任何内存操作一起重新排序。否则,某些受互斥锁保护的数据可能会在获取其互斥锁之前或释放其互斥锁之后被访问,从而导致数据竞争。
其他-多副本原子性
内存操作顺序在某些处理器体系结构上受到影响的方式,例如在图形卡中可能会发现的处理器体系结构,并不总是可以通过指令重新排序来解释。一个内核上的两个连续存储操作的效果可能在第二个内核上以相同的顺序可见,但在第三个内核上以相反的顺序显示。例如,由于不一致的缓存或共享存储缓冲区,可能会发生这种情况。这种影响不能用重新排序的第一个核心上的指令来解释,因为这不能解释第二个和第三个核心的观察结果之间的不一致。
我们在第 3 章中讨论的理论内存模型为此类处理器架构留出了空间,因为除了顺序一致的原子操作外,不需要全局一致的顺序。
我们在本章中关注的架构 x86-64 和 ARM64 是其他多副本原子的,这意味着写操作一旦对任何内核可见,就会同时对所有内核可见。对于其他多副本原子架构,内存排序只是指令重新排序的问题。
一些架构,例如 ARM64,被称为弱排序,因为它们允许处理器自由地重新排序任何内存操作。另一方面,强序架构(例如 x86-64)对哪些内存操作可以重新排序有非常严格的限制。
x86-64:严格有序
在 x86-64 处理器上,加载操作似乎永远不会发生在随后的内存操作之后。类似地,这种架构不允许存储操作看起来像是在之前的内存操作之前发生的。您可能在 x86-64 上看到的唯一一种重新排序是存储操作被延迟到稍后的加载操作之后。
提示
由于 x86-64 架构的重新排序限制,它通常被描述为强有序架构,尽管有些人更愿意将此术语保留,用于保留所有内存操作顺序的架构。
这些限制满足获取加载(因为加载永远不会用后面的操作重新排序)和释放存储(因为存储永远不会用前面的操作重新排序)的所有需要。这意味着在 x86-64 上,我们“免费”获得释放和获取语义:释放和获取操作与宽松操作相同。
当我们将 Relaxed
更改为 Release
、 Acquire
或 AcqRel
时,我们可以通过查看“加载和存储”和“x86 锁定前缀”中的一些片段会发生什么来验证这一点:
正如预期的那样,汇编是相同的,即使我们指定了更强的内存排序。
我们可以得出结论,在 x86-64 上,忽略潜在的编译器优化,获取和释放操作与宽松操作一样便宜。或者,也许更准确地说,宽松操作与获取和释放操作一样昂贵。
让我们看看 SeqCst
发生了什么:
load
和 fetch_add
操作仍然产生与以前相同的汇编,但 store
的汇编完全改变了。 xor
指令看起来有点不合时宜,但它只是通过将 xor
与自身进行 xor
运算将 eax
寄存器设置为零的常用方法,结果总是为零。 mov eax, 0
指令也可以,但会占用更多空间。
有趣的部分是 xchg
指令,它通常用于交换操作:也检索旧值的存储操作。
像以前一样的常规 mov
指令对于 SeqCst
存储来说是不够的,因为它会允许在以后的加载操作中对其重新排序,从而破坏全局一致的顺序。通过将其更改为也执行加载的操作,即使我们不关心它加载的值,我们也可以额外保证我们的指令不会被以后的内存操作重新排序,从而解决了这个问题。
提示
SeqCst
加载操作仍然可以是常规的 mov
,正是因为 SeqCst
存储已升级到了 xchg
。 SeqCst
操作只保证与其他 SeqCst
操作的全局一致顺序。来自 SeqCst
加载的 mov
可能仍会使用早期非 SeqCst
存储操作的 mov
重新排序,但这完全没问题。
在 x86-64 上,存储操作是唯一在 SeqCst
和较弱的内存排序之间存在差异的原子操作。换句话说,除了存储之外的 x86-64 SeqCst
操作与 Release
、 Acquire
、 AcqRel
甚至 Relaxed
操作一样便宜。或者,如果您愿意,x86-64 使存储以外的 Relaxed
操作与 SeqCst
操作一样昂贵。
ARM64:弱有序
在 ARM64 等弱排序架构上,所有内存操作都可能相互重新排序。这意味着与 x86-64 不同,获取和释放操作与宽松操作不同。
让我们看一下 Release
、 Acquire
和 AcqRel
在 ARM64 上发生了什么:
与我们之前看到的 Relaxed
版本相比,这些变化非常微妙:
1)str
(存储寄存器)现在是 stlr
(存储释放寄存器)。
2)ldr
(加载寄存器)现在是 ldar
(加载获取寄存器)。
3)ldxr
(加载独占寄存器)现在是 ldaxr
(加载获取独占寄存器)。
4)stxr
(存储独占寄存器)现在是 stlxr
(存储发布独占寄存器)。
如图所示,ARM64 具有用于获取和释放排序的特殊版本的加载和存储指令。与 ldr
或 ldxr
指令不同, ldar
或 ldxar
指令永远不会在以后的任何内存操作中重新排序。类似地,与 str
或 stxr
指令不同, stlr
或 stxlr
指令永远不会与任何较早的内存操作一起重新排序。
提示
仅使用 Release
或 Acquire
顺序而不是 AcqRel
的获取和修改操作, 分别只使用 stlxr
和 ldxar
指令之一,与常规 ldxr
或 stxr
指令配对。
除了释放和获取语义所需的限制之外,没有任何特殊的获取和释放指令与这些特殊指令中的任何其他指令重新排序,使它们也适用于 SeqCst
。
如下所示,升级到 SeqCst
会产生与以前完全相同的汇编:
这意味着在 ARM64 上,顺序一致的操作与获取和释放操作一样便宜。或者,更确切地说,ARM64 Acquire
、 Release
和 AcqRel
操作与 SeqCst
一样昂贵。然而,与 x86-64 不同的是, Relaxed
操作相对便宜,因为它们不会导致不必要的更强的排序保证。
ARMv8.1 原子释放和获取指令
如“ARMv8.1 原子指令”中所述,ARM64 的 ARMv8.1 版本包括用于原子操作的 CISC 样式指令,例如 ldadd
(加载和添加)作为 ldxr
/ stxr
循环的替代。
就像加载和存储操作有带有获取和释放语义的特殊版本一样,这些指令也有更强的内存排序的变体。因为这些指令同时涉及加载和存储,所以它们各自具有三种额外的变体:一种用于释放 ( -l
),一种用于获取 ( -a
),以及一种用于组合释放和获取 ( -al
) 语义。
例如,对于 ldadd
,还有 ldaddl
、 ldadda
和 ldaddal
。同样, cas
指令带有 casl
、 casa
和 casal
变体。
就像加载和存储指令一样,组合的释放和获取 ( -al
) 变体也适用于 SeqCst
操作。
一个实验
强有序架构流行的一个不幸后果是某些类别的内存排序错误很容易被发现。在需要 Acquire
或 Release
的地方使用 Relaxed
是不正确的,但在 x86-64 的实践中可能会意外地正常工作,假设编译器不会重新排序您的原子操作。
提示
请记住,不仅仅是处理器会导致事情发生故障。编译器也可以重新排序它生成的指令,只要它考虑了内存排序约束。
在实践中,编译器往往对涉及原子操作的优化非常保守,但这在未来很可能会改变。
这意味着人们很容易编写错误的并发代码,这些代码(意外地)在 x86-64 上运行得很好,但在为 ARM64 处理器编译和运行时可能会崩溃。
让我们试着做到这一点。
我们将创建一个自旋锁保护计数器,但将所有内存顺序更改为 Relaxed
。让我们不必为创建自定义类型或不安全代码而烦恼。相反,让我们只使用 AtomicBool
作为锁,使用 AtomicUsize
作为计数器。
为确保编译器不会重新排序我们的操作,我们将使用 std::sync::compiler_fence()
函数通知编译器应该是 Acquire
或 Release
的操作,而不告诉处理器。
我们将使四个线程重复锁定、递增计数器和解锁——每个线程一百万次。将所有这些放在一起,我们最终得到以下代码:
fn main() {
let locked = AtomicBool::new(false);
let counter = AtomicUsize::new(0);
thread::scope(|s| {
// Spawn four threads, that each iterate a million times.
for _ in 0..4 {
s.spawn(|| for _ in 0..1_000_000 {
// Acquire the lock, using the wrong memory ordering.
while locked.swap(true, Relaxed) {}
compiler_fence(Acquire);
// Non-atomically increment the counter, while holding the lock.
let old = counter.load(Relaxed);
let new = old + 1;
counter.store(new, Relaxed);
// Release the lock, using the wrong memory ordering.
compiler_fence(Release);
locked.store(false, Relaxed);
});
}
});
println!("{}", counter.into_inner());
}
如果锁正常工作,我们预计计数器的最终值正好是 400 万。请注意如何以非原子方式增加计数器,使用单独的 load
和 store
而不是单个 fetch_add
,以确保自旋锁的任何问题都可能导致错过增量,从而降低计数器的总值。
在具有 x86-64 处理器的计算机上运行此程序几次会得到:
4000000
4000000
4000000
正如预期的那样,我们“免费”获得了释放和获取语义,我们的错误不会导致任何问题。
在 2021 年的 Android 手机和 Raspberry Pi 3 B 型(均使用 ARM64 处理器)上尝试此操作会产生相同的输出:
4000000
4000000
4000000
这表明并非所有 ARM64 处理器都使用所有形式的指令重新排序,尽管我们不能根据这个实验做出太多假设。
在包含基于 ARM64 的 Apple M1 处理器的 2021 Apple iMac 上进行尝试时,我们得到了一些不同的东西:
3988255
3982153
3984205
我们之前隐藏的错误突然变成了一个实际问题——一个只有在弱有序系统上才能看到的问题。计数器仅偏离约 0.4%,显示此类问题可能有多么微妙。在现实生活中,这样的问题可能会在很长一段时间内未被发现。
提示
尝试复制上述结果时,不要忘记启用优化(使用 cargo run --release
或 rustc -O
)。如果不进行优化,相同的代码通常会产生更多的指令,这会隐藏指令重新排序的微妙影响。
内存栅栏
我们还没有看到一种与内存排序相关的指令:内存栅栏。内存栅栏或内存屏障指令用于表示 std::sync::atomic::fence
,我们在第 3 章的“栅栏”中讨论过。
正如我们之前所见,x86-64 和 ARM64 上的内存排序都是关于指令重新排序的。栅栏指令可以防止某些类型的指令被重新排序超过它。
获取栅栏必须防止前面的加载操作与后面的任何内存操作一起重新排序。类似地,释放栅栏必须防止后续存储操作与任何先前的内存操作一起重新排序。顺序一致的栅栏必须防止在它之前的所有内存操作被栅栏之后的内存操作重新排序。
在 x86-64 上,基本的内存排序语义已经满足获取和释放栅栏的需要。无论如何,这种架构不允许这些栅栏阻止的重新排序类型。
让我们深入了解四种不同的栅栏在 x86-64 和 ARM64 上编译成什么指令:
毫不奇怪,在 x86-64 上释放和获取栅栏不会产生任何指令。我们在此架构上“免费”获得发布和获取语义。只有 SeqCst
栅栏才会产生 mfence
(内存栅栏)指令。这条指令确保在它之前的所有内存操作都已完成,然后再继续。
在 ARM64 上,等效指令是 dmb ish
(数据内存屏障,内部共享域)。与 x86-64 不同,它也用于 Release
和 AcqRel
,因为该架构不隐式提供获取和释放语义。对于 Acquire
,使用了一个影响较小的变体: dmb ishld
。此变体仅等待加载操作完成,但可以自由地允许之前的存储操作通过它重新排序。
与我们之前在原子操作中看到的类似,我们看到 x86-64 为我们提供了“免费”的释放和获取栅栏,而在 ARM64 上,顺序一致的栅栏与释放栅栏的成本相同。
总结
在 x86-64 和 ARM64 上,宽松的加载和存储操作与其非原子等效项相同。
x86-64(以及自 ARMv8.1 以来的 ARM64)上常见的原子获取和修改,以及比较和交换操作都有自己的指令。
在 x86-64 上,没有等效指令的原子操作会编译为比较和交换循环。
在 ARM64 上,任何原子操作都可以由加载链接/存储条件循环表示:如果尝试的内存操作被中断,该循环会自动重新启动。
缓存在缓存行上运行,缓存行的大小通常为 64 字节。
缓存与缓存一致性协议保持一致,例如直写或 MESI。
填充,例如通过
#[repr(align(64)]
,可以通过防止错误共享来提高性能。加载操作可能比失败的比较和交换操作便宜得多,部分原因是后者通常需要独占访问缓存行。
指令重新排序在单线程程序中是不可见的。
在大多数架构上,包括 x86-64 和 ARM64,内存排序是为了防止某些类型的指令重新排序。
在 x86-64 上,每个内存操作都有获取和释放语义,使其与轻松操作一样便宜或昂贵。除了
stores
和fences
之外的所有东西都具有顺序一致的语义,无需额外成本。在 ARM64 上,获取和释放语义不如宽松操作便宜,但确实包含顺序一致的语义,无需额外成本。
我们在本章中看到的汇编指令的摘要可以在图 7-1 中找到。