Rust原子和锁——理解处理器

262 阅读58分钟

虽然第 2 章和第 3 章的理论足以让我们编写正确的并发代码,但在处理器层面上,发展对实际发生情况的近似理解也非常有用。在本章中,我们将探索原子操作编译为机器指令的过程,不同处理器架构之间的差异,为什么存在比较交换(compare_exchange)的弱版本,内存排序在单条指令的最低层次上意味着什么,以及缓存与这些操作的关系。

本章的目标不是理解每种处理器架构的每个相关细节。那需要很多书架来装满相关书籍,其中很多可能还没有被写出来,或者没有公开出版。相反,本章的目标是对原子操作在处理器层面的工作方式形成一个大致的理解,从而在实现和优化涉及原子操作的代码时能做出更明智的决策。当然,这也是为了满足我们对幕后发生情况的好奇心——暂时从所有抽象理论中得到一些放松。

为了尽可能地具体化,我们将关注两种特定的处理器架构:

  • x86-64: 这是由英特尔和 AMD 处理器实现的 x86 架构的 64 位版本,被广泛用于大多数笔记本电脑、台式机、服务器和一些游戏主机。最初的 16 位 x86 架构及其非常流行的 32 位扩展由英特尔开发,而我们现在称之为 x86-64 的 64 位版本最初是由 AMD 开发的扩展,通常称为 AMD64。英特尔也开发了自己的 64 位架构 IA-64,但最终采用了 AMD 的更受欢迎的 x86 扩展(以 IA-32e、EM64T 和后来称为 Intel 64 的名称推出)。
  • ARM64: 这是 ARM 架构的 64 位版本,几乎用于所有现代移动设备、高性能嵌入式系统,并且越来越多地用于最新的笔记本电脑和台式机。它也被称为 AArch64,并作为 ARMv8 的一部分推出。早期的(32 位)ARM 版本在许多方面相似,应用范围更广。许多流行的微控制器,广泛用于各种嵌入式系统,从汽车到电子 COVID 测试设备,都基于 ARMv6 和 ARMv7。

这两种架构在许多方面有所不同。最重要的是,它们对原子操作采取了不同的方法。理解原子操作在这两种架构上的工作方式,可以为我们提供更为广泛的理解,这种理解可以迁移到许多其他架构上。

处理器指令

通过仔细查看编译器的输出——即处理器将执行的确切指令——我们可以大致理解处理器层面的工作原理。

汇编简介

编译用 Rust 或 C 等编译语言编写的软件时,代码会被转换为可由处理器执行的机器指令。这些指令是与您为其编译程序的处理器架构紧密相关的。

这些指令,也称为机器码,以二进制形式编码,对人类来说几乎无法阅读。汇编是这些指令的人类可读表示形式。每条指令都由一行文本表示,通常以一个单词或缩写开头来标识指令,后面跟着它的参数或操作数。汇编器将文本表示转换为二进制表示,反汇编器则反其道而行之。

在从 Rust 等语言编译后,大部分原始源代码的结构都消失了。根据优化级别,函数和函数调用可能仍然可识别。然而,诸如结构体或枚举之类的类型已简化为字节和地址,而循环和条件语句则简化为带有基本跳转或分支指令的扁平结构。

以下是一个示例,展示了某个假想架构中程序的一小部分的汇编代码片段:

ldr x, 1234 // 从内存地址 1234 加载到 x
li y, 0     // 将 y 设置为 0
inc x       // 自增 x
add y, x    // 将 x 加到 y
mul x, 3    // 将 x 乘以 3
cmp y, 10   // 将 y 与 10 进行比较
jne -5      // 如果不相等,跳转到上面五条指令
str 1234, x // 将 x 存储到内存地址 1234

在此示例中,xy 是寄存器的名称。寄存器是处理器的一部分,而不是主内存,通常包含一个整数或内存地址。在 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

由于标签的名称仅是汇编代码的一部分,而不是二进制机器码的一部分,反汇编器将不知道最初使用了哪些标签,很可能只使用毫无意义的生成名称,例如 label1var2

关于所有不同架构的汇编的完整课程超出了本书的范围,但这不是阅读本章的前提。一个非常通用的理解就足够理解示例了,因为我们只会阅读汇编,而不是编写它。在每个示例中,相关的指令将被详细解释,以便即使没有汇编经验也能理解。

要查看 Rust 编译器生成的确切机器代码,我们有几种选择。我们可以像往常一样编译代码,然后使用反汇编器(如 objdump)将生成的二进制文件转换回汇编代码。使用编译过程中生成的调试信息,反汇编器可以生成与 Rust 源代码中的原始函数名称对应的标签。此方法的缺点是,您需要一个支持您所编译的特定处理器架构的反汇编器。虽然 Rust 编译器支持许多架构,但许多反汇编器只支持它们为之编译的架构。

一种更直接的方法是使用 rustc--emit=asm 标志来让编译器生成汇编代码而不是二进制文件。此方法的缺点是生成的输出包含许多不相关的行,包含我们不需要的汇编器和调试工具信息。

有一些很好的工具,如 cargo-show-asm,它们可以与 Cargo 集成,自动化编译库时使用正确的标志,找到您感兴趣的函数的相关汇编代码,并突出显示包含实际指令的相关行。

对于相对较小的代码片段,最简单且最推荐的方法是使用类似 Matt Godbolt 的出色的 Compiler Explorer 这样的网络服务。该网站允许您编写多种语言的代码(包括 Rust),并直接查看使用选定编译器版本生成的对应汇编代码。它甚至使用颜色来显示 Rust 的哪一行对应于汇编的哪一行,尽管在优化之后这种对应关系可能不再存在。

由于我们希望查看不同架构的汇编代码,因此需要指定 Rust 编译器要编译到的确切目标。我们将使用 x86_64-unknown-linux-musl 作为 x86-64 的目标,使用 aarch64-unknown-linux-musl 作为 ARM64 的目标。这些目标已经在 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,即需要增加 10 的 i32 的地址。首先,ldr 指令将 32 位值从该内存地址加载到 w8 寄存器中。然后,add 指令将 w8 加上 10 并将结果存回 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 源代码编译后的 x86-64编译后的 ARM64
pub fn a(x: &mut i32) {a:a:
*x = 0;mov dword ptr [rdi], 0str wzr, [x0]
}retret

在 x86-64 上,功能非常强大的 mov 指令用于将数据从一个地方复制(“移动”)到另一个地方;在这种情况下,是从一个零常量复制到内存。在 ARM64 上,str(存储寄存器)指令用于将 32 位寄存器存储到内存中。在这里,使用了特殊的 wzr 寄存器,它总是包含零。

如果我们将代码更改为对 AtomicI32 进行松弛的原子存储,我们会得到:

Rust 源代码编译后的 x86-64编译后的 ARM64
pub fn a(x: &AtomicI32) {a:a:
x.store(0, Relaxed);mov dword ptr [rdi], 0str wzr, [x0]
}retret

可能有些令人惊讶,汇编代码与非原子版本完全相同。事实证明,movstr 指令本身已经是原子的。它们要么发生,要么完全不发生。显然,在这里 &mut i32&AtomicI32 之间的任何差异对于编译器的检查和优化来说是相关的,但对于处理器来说是无意义的——至少对于这两种架构上的松弛存储操作来说。

当我们查看松弛加载操作时,情况也是如此:

Rust 源代码编译后的 x86-64编译后的 ARM64
pub fn a(x: &i32) -> i32 {a:a:
*xmov eax, dword ptr [rdi]ldr w0, [x0]
}retret
pub fn a(x: &AtomicI32) -> i32 {a:a:
x.load(Relaxed)mov eax, dword ptr [rdi]ldr w0, [x0]
}retret

在 x86-64 上,mov 指令再次被使用,这次是从内存中复制到 32 位的 eax 寄存器。在 ARM64 上,ldr(加载寄存器)指令用于将值从内存中加载到 w0 寄存器。

注意
32 位的 eaxw0 寄存器用于传递函数的 32 位返回值。(对于 64 位的值,则使用 64 位的 raxx0 寄存器。)

尽管处理器显然不区分原子与非原子的存储和加载,但在我们的 Rust 代码中,我们不能安全地忽略这种区别。如果我们使用 &mut i32,Rust 编译器可能会假定没有其他线程可以并发访问相同的 i32,并可能决定以某种方式转换或优化代码,使得存储操作不再导致单个对应的存储指令。例如,对于一个非原子的 32 位加载或存储,以两个独立的 16 位指令来完成也是完全正确的,尽管有些不寻常。

读-修改-写操作

对于加法等读-修改-写操作,情况变得更加有趣。如本章前面讨论的,在 RISC 架构(如 ARM64)上,非原子的读-修改-写操作通常会被编译为三个独立的指令(读取、修改和写入),但在 CISC 架构(如 x86-64)上,通常可以通过一条指令完成。以下简短的示例展示了这一点:

Rust 源代码编译后的 x86-64编译后的 ARM64
pub fn a(x: &mut i32) {a:a:
*x += 10;add dword ptr [rdi], 10ldr w8, [x0]
}retadd w8, w8, #10
str w8, [x0]
ret

在我们查看相应的原子操作之前,我们可以合理地假设这次我们会看到原子和非原子版本之间的差异。这里的 ARM64 版本显然不是原子的,因为加载和存储是分开的步骤。

虽然从汇编本身来看并不直接明显,但 x86-64 版本也不是原子的。add 指令会在后台被处理器分解为多个微指令,包括独立的加载值和存储结果的步骤。在单核计算机上,这无关紧要,因为线程之间切换处理器核心通常只发生在指令之间。然而,当多个核心并行执行指令时,我们不能再假设所有指令都是原子发生的,而不考虑执行单条指令所涉及的多个步骤。

x86 的 lock 前缀

为了支持多核系统,英特尔引入了一种称为 lock 的指令前缀。它被用作诸如 add 等指令的修饰符,以使其操作成为原子的。

lock 前缀最初使处理器在指令执行期间暂时阻止所有其他核心访问内存。虽然这是一种简单而有效的方法,可以使其他核心认为某些操作是原子的,但每次原子操作都停止整个世界可能非常低效。较新的处理器对 lock 前缀有更高级的实现,不会阻止其他核心对不相关内存的操作,并允许核心在等待某个内存段可用时执行有用的操作。

lock 前缀只能应用于非常有限数量的指令,包括 addsubandnotorxor,这些指令都是非常有用的原子操作指令。xchg(交换)指令对应于原子交换操作,具有隐式的 lock 前缀:无论是否有 lock 前缀,它的行为都像 lock xchg

让我们通过更改上一个示例以对 AtomicI32 进行操作,看看 lock add 的实际效果:

Rust 源代码编译后的 x86-64
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, Relaxed);lock add dword ptr [rdi], 10
}ret

正如预期的,与非原子版本的唯一不同之处是 lock 前缀。

在上面的示例中,我们忽略了 fetch_add 的返回值,即操作前的 x 的值。然而,如果我们使用这个值,那么 add 指令就不够用了。add 指令可以为后续指令提供一些有用的信息,例如更新后的值是否为零或负数,但它不提供完整的(原始或更新的)值。相反,可以使用另一条指令:xadd(“交换并加”),它会将最初加载的值存入寄存器。

通过对代码进行小的修改以返回 fetch_add 返回的值,我们可以看到它的实际效果:

Rust 源代码编译后的 x86-64
pub fn a(x: &AtomicI32) -> i32 {a:
x.fetch_add(10, Relaxed)mov eax, 10
}lock xadd dword ptr [rdi], eax
ret

现在,寄存器包含了值 10 而不是常数 10。xadd 指令将重用该寄存器来存储旧值。

不幸的是,除了 xaddxchg 之外,没有其他 lock 前缀的指令(如 subandor)有类似的变体。例如,没有 xsub 指令。对于减法,这不是问题,因为可以使用 xadd 搭配负值。而对于 andor,则没有这样的替代。

对于只影响单个位的 andorxor 操作,例如 fetch_or(1)fetch_and(!1),可以使用 bts(位测试并设置)、btr(位测试并重置)和 btc(位测试并补)指令。这些指令也允许 lock 前缀,只改变一个位,并且使那个位的先前值对于后续指令(如条件跳转)可用。

当这些操作影响多个位时,它们无法由单条 x86-64 指令表示。同样,fetch_maxfetch_min 操作也没有对应的 x86-64 指令。对于这些操作,我们需要一种不同于简单 lock 前缀的策略。

x86 的比较并交换指令

在“比较并交换操作”中,我们看到任何原子获取并修改操作都可以实现为一个比较并交换循环。对于那些无法用单条 x86-64 指令表示的操作,这正是编译器将使用的方式,因为这种架构确实包含一个可以加 lock 前缀的 cmpxchg(比较并交换)指令。

我们可以通过将上一个示例从 fetch_add 更改为 fetch_or 来看到这一点:

Rust 源代码编译后的 x86-64
pub fn a(x: &AtomicI32) -> i32 {a:
x.fetch_or(10, Relaxed)mov eax, dword ptr [rdi]
}.L1:
mov ecx, eax
or ecx, 10
lock cmpxchg dword ptr [rdi], ecx
jne .L1
ret

第一个 mov 指令将原子变量的值加载到 eax 寄存器中。接下来的 movor 指令将该值复制到 ecx 并应用按位或操作,使得 eax 中包含旧值,而 ecx 中包含新值。之后的 cmpxchg 指令的行为与 Rust 中的 compare_exchange 方法完全相同。它的第一个参数是要操作的内存地址(即原子变量),第二个参数(ecx)是新值,预期值隐式地取自 eax,返回值也隐式地存储在 eax 中。它还设置了一个状态标志,以便后续指令可以根据操作是否成功有条件地进行分支。在这种情况下,jne(如果不相等则跳转)指令用于跳转回 .L1 标签,以在失败时重试。

以下是 Rust 中等效的比较并交换循环,就像我们在“比较并交换操作”中看到的一样:

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_exchangecompare_exchange_weak 之间没有区别。两者都会编译为一个带有 lock 前缀的 cmpxchg 指令。

负载链接与条件存储指令

在 RISC 架构上,与比较并交换循环最接近的东西是负载链接/条件存储(LL/SC)循环。它涉及两个成对使用的特殊指令:负载链接指令(load-linked),其行为大多类似于常规的加载指令;以及条件存储指令(store-conditional),其行为大多类似于常规的存储指令。它们成对使用,两个指令都针对相同的内存地址。与常规加载和存储指令的关键区别在于,存储是有条件的:如果在负载链接指令之后有其他线程覆盖了该内存,条件存储就拒绝将数据写入内存。

这两个指令使我们能够从内存中加载一个值,对其进行修改,然后仅当自加载以来没有人覆盖该值时才将新值存回内存。如果失败,我们可以简单地重试。一旦成功,我们可以安全地假装整个操作是原子的,因为它没有被中断。

使这些指令可行且高效实现的关键有两点:(1)每次只能跟踪一个内存地址(每个核心),(2)条件存储允许有假阴性,即即使特定内存未更改,也可能无法存储。

这样做可以降低跟踪内存更改的精确度,代价是可能多出一些 LL/SC 循环。对内存的访问可以不是逐字节跟踪,而是按 64 字节块、每千字节,甚至是整个内存进行跟踪。较低精度的内存跟踪会导致更多不必要的 LL/SC 循环,从而显著降低性能,但也降低了实现的复杂性。

将事情推向极端,一个基本的、假设的单核系统可以使用一种策略,即完全不跟踪对内存的写入。相反,它可以跟踪中断或上下文切换,即可能导致处理器切换到另一个线程的事件。如果在没有任何并行性的系统中没有发生这样的事件,则可以安全地假设没有其他线程接触该内存。如果发生了这样的事件,它可以假设最坏情况,拒绝存储,并希望下一次循环的运气更好。

ARM 负载独占和存储独占

在 ARM64(至少在 ARMv8 的第一个版本中),没有任何原子获取并修改或比较并交换操作可以由单条指令表示。符合其 RISC 本质,加载和存储步骤与计算和比较是分开的。

ARM64 的负载链接和条件存储指令分别称为 ldxr(加载独占寄存器)和 stxr(存储独占寄存器)。此外,clrex(清除独占)指令可以用作 stxr 的替代,用于停止跟踪内存写入而不存储任何东西。

让我们看看在 ARM64 上执行原子加法时会发生什么:

Rust 源代码编译后的 ARM64
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, Relaxed);.L1:
}ldxr w8, [x0]
add w9, w8, #10
stxr w10, w9, [x0]
cbnz w10, .L1
ret

我们得到的汇编代码看起来与之前的非原子版本(在“读-修改-写操作”中)非常相似:一个加载指令,一个加法指令,一个存储指令。加载和存储指令被替换为它们的“独占” LL/SC 版本,新增的 cbnz(比较并在非零时分支)指令也出现了。stxr 指令在成功时将零存储到 w10 中,失败时存储一。cbnz 指令利用这个值,如果失败则重新启动整个操作。

请注意,与 x86-64 上的 lock add 不同,我们不需要做任何特别的操作来检索旧值。在上面的示例中,操作成功后旧值仍会保存在寄存器 w8 中,因此不需要像 xadd 这样的专用指令。

这种 LL/SC 模式非常灵活:它不仅适用于像 addor 这样有限的操作集,而且适用于几乎所有操作。只需将对应的指令放在 ldxrstxr 指令之间,我们就可以轻松实现原子的 fetch_dividefetch_shift_left。但是,如果它们之间的指令过多,则中断的可能性会增加,导致额外的循环。通常,编译器会尽量减少 LL/SC 模式中的指令数量,以避免 LL/SC 循环罕见成功甚至永远无法成功的情况。

ARMv8.1 原子指令

ARMv8.1 的后续版本引入了 ARM64 的新 CISC 风格指令,用于常见的原子操作。例如,新的 ldadd(加载并加)指令相当于原子的 fetch_add 操作,不需要 LL/SC 循环。它甚至包含了类似 fetch_max 的操作指令,而这些在 x86-64 上是不存在的。

它还包含了一个对应于 compare_exchangecas(比较并交换)指令。在使用该指令时,compare_exchangecompare_exchange_weak 之间没有区别,就像在 x86-64 上一样。

虽然 LL/SC 模式非常灵活并且很好地符合 RISC 模式,但这些新指令可能性能更高,因为它们更容易通过专用硬件进行优化。

ARM 上的比较并交换

compare_exchange 操作通过使用条件分支指令来跳过存储指令(如果比较失败),很好地映射到 LL/SC 模式。让我们看看生成的汇编代码:

Rust 源代码编译后的 ARM64
pub fn a(x: &AtomicI32) {a:
x.compare_exchange_weak(5, 6, Relaxed, Relaxed);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,则流程继续通过 movstxr 指令,将新值 6 存储到内存中,但前提是此期间没有其他人覆盖 5。

请记住,stxr 允许有假阴性;即使 5 没有被覆盖,它也可能在这里失败。这没关系,因为我们使用的是 compare_exchange_weak,它也允许有假阴性。实际上,这就是存在 compare_exchange 弱版本的原因。

如果我们将 compare_exchange_weak 替换为 compare_exchange,我们会得到几乎相同的汇编代码,除了在失败时有一个额外的分支以重新启动操作:

Rust 源代码编译后的 ARM64
pub fn a(x: &AtomicI32) {a:
x.compare_exchange(5, 6, Relaxed, Relaxed);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 循环。原因之一是,在 stxrldxr 指令之间可以放置的指令数量和类型是有限的,这不是编译器在应用其他优化时设计要考虑的事情。当比较并交换循环之类的模式仍然可识别时,表达式将编译为的确切指令还不确定,这使得这种优化在一般情况下非常棘手。

因此,至少在我们获得更智能的编译器之前,如果可能的话,建议使用专用的获取并修改方法,而不是比较并交换循环。

缓存

读取和写入内存非常慢,可能花费相当于执行数十甚至数百条指令的时间。这就是为什么所有高性能处理器都实现了缓存,以尽量减少与相对较慢的内存的交互。现代处理器中内存缓存的确切实现细节非常复杂,部分是专有的,最重要的是,在编写软件时它们对我们来说大多不相关。毕竟,缓存(cache)这个词来自法语的“caché”,意思是“隐藏的”。尽管如此,了解大多数处理器实现缓存的基本原理在为性能优化软件时非常有用。(当然,我们不需要找借口来学习更多有趣的话题。)

除了非常小的微控制器,几乎所有现代处理器都使用缓存。这类处理器从不直接与主内存交互,而是通过其缓存路由每个读取和写入请求。如果一条指令需要从内存中读取内容,处理器将向其缓存请求数据。如果数据已缓存,缓存将快速响应缓存的数据,避免与主内存交互。否则,它将不得不走慢速路径,缓存可能需要向主内存请求相关数据的副本。一旦主内存响应,缓存不仅会最终响应原始的读取请求,还会记住这些数据,以便下次请求这些数据时能够更快地响应。如果缓存变满了,它会通过删除一些它认为最不可能有用的旧数据来腾出空间。

当一条指令想要向内存写入数据时,缓存可以选择保留修改后的数据,而不将其写入主内存。任何后续对相同内存地址的读取请求将获得修改后的数据副本,忽略主内存中过时的数据。只有在缓存需要丢弃修改后的数据以腾出空间时,才会将数据实际写回主内存。

在大多数处理器架构中,缓存以 64 字节的块读取和写入内存,即使只请求一个字节。这些块通常称为缓存行。通过缓存包含请求字节的整个 64 字节块,任何后续需要访问该块中其他字节的指令都不必等待主内存。

缓存一致性

在现代处理器中,通常有不止一层缓存。第一级缓存(L1 缓存)是最小且最快的。它不直接与主内存通信,而是与二级缓存(L2 缓存)通信,L2 缓存更大但速度较慢。L2 缓存可能是与主内存通信的缓存,或者可能还有更大、更慢的三级缓存(L3 缓存)——甚至可能有四级缓存(L4 缓存)。

添加额外的缓存层并没有改变它们的工作方式;每层缓存可以独立运作。然而,真正有趣的是当存在多个处理器核心且每个核心都有自己的缓存时。在多核系统中,每个处理器核心通常有自己的 L1 缓存,而 L2 或 L3 缓存则常常与部分或所有其他核心共享。

在这些条件下,天真的缓存实现会崩溃,因为缓存无法再假设它控制与下一层的所有交互。如果一个缓存接受写入并将某些缓存行标记为已修改而不通知其他缓存,则缓存的状态可能变得不一致。不仅修改后的数据在缓存将数据写入下一层之前对其他核心不可用,而且它可能与其他缓存中的不同修改相冲突。

为了解决这个问题,使用了缓存一致性协议。这样的协议定义了缓存如何精确地操作和相互通信以保持一致的状态。所使用的确切协议因架构、处理器型号,甚至缓存级别而异。

我们将讨论两种基本的缓存一致性协议。现代处理器使用了这些协议的许多变体。

写直达协议

在实现写直达(write-through)缓存一致性协议的缓存中,写入操作不会被缓存,而是立即传递到下一层。其他缓存通过同一个共享通信通道连接到下一层,这意味着它们可以观察到其他缓存与下一层的通信。当缓存观察到某个地址的写入操作,而这个地址当前也被缓存时,它会立即删除或更新自己的缓存行,以保持一致性。

使用此协议时,缓存从不包含处于已修改状态的缓存行。虽然这大大简化了事情,但也使写操作的缓存优势失效。在仅优化读取的情况下,这是一个不错的选择。

MESI 协议

MESI 缓存一致性协议的名称来自其定义的缓存行的四种可能状态:修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。修改(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 --releaserustc -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); // 新增!
    let start = Instant::now();
    for _ in 0..1_000_000_000 {
        black_box(A.load(Relaxed)); // 新增!
    }
    println!("{:?}", start.elapsed());
}

多次运行此程序,输出会有一些波动,但在一台较老的 x86-64 计算机上,它似乎大约需要 300 毫秒。

为了观察缓存的影响,我们将生成一个后台线程与原子变量交互。这样,我们可以看到它是否影响主线程的加载操作。

首先,我们让后台线程仅执行加载操作,如下所示:

static A: AtomicU64 = AtomicU64::new(0);

fn main() {
    black_box(&A);

    thread::spawn(|| { // 新增!
        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); // 新增!
        }
    });
    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 {
            // 永远不会成功,因为 A 永远不是 10。
            black_box(A.compare_exchange(10, 20, Relaxed, Relaxed).is_ok());
        }
    …

因为 A 总是零,所以此 compare_exchange 操作永远不会成功。它会加载 A 的当前值,但从不将其更新为新值。

人们可能合理地认为这种行为与加载操作相同,因为它不修改原子变量。然而,在大多数处理器架构上,compare_exchange 指令(无论比较是否成功)都会声明对相关缓存行的独占访问。

这意味着在自旋循环中使用 compare_exchange(或 swap)可能不是最佳选择,例如我们在第 4 章的 SpinLock 中所做的那样,而是应该先使用加载操作检查锁是否已解锁。这样可以避免不必要地对相关缓存行声明独占访问。

由于缓存是以缓存行为单位进行操作,而不是以单个字节或变量为单位,因此我们应该能够通过使用相邻变量而不是同一个变量,看到相同的效果。为了验证这一点,我们使用三个原子变量而不是一个,让主线程只使用中间变量,而让后台线程只使用其他两个,如下所示:

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] 的“无关”操作。这种现象被称为假共享

我们可以通过将原子变量间隔得更远,以使它们各自占用自己的缓存行来避免这种情况。如前所述,64 字节是缓存行大小的合理猜测,所以让我们尝试将原子变量包装在一个 64 字节对齐的结构体中,如下所示:

#[repr(align(64))] // 这个结构体必须 64 字节对齐。
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 章的开头,我们简要讨论了指令重排序,以及编译器和处理器如何更改指令顺序。专注于处理器,这里有一些指令或其效果可能会乱序发生的各种示例:

存储缓冲区

由于写入操作非常慢,即使有缓存,处理器核心通常也包括一个存储缓冲区。写入内存的操作可以存储在这个非常快的存储缓冲区中,允许处理器立即继续执行后续的指令。然后,在后台,通过写入(L1)缓存来完成写入操作,这个过程可能会显著更慢。这样,处理器在缓存一致性协议开始获取相关缓存行的独占访问时,不需要等待。

只要特别注意处理来自相同内存地址的后续读取操作,这对于在同一线程中、在同一处理器核心上运行的指令来说是完全不可见的。然而,在这一瞬间,写入操作对其他核心来说尚不可见,导致在不同核心上运行的不同线程中对内存的视图不一致。

无效队列

无论确切的缓存一致性协议如何,平行工作的缓存需要处理无效请求:丢弃即将被修改并变得无效的特定缓存行的指令。作为一种性能优化,这些请求通常不会立即被处理,而是排队等待(稍微)稍后的处理。当使用这些无效队列时,缓存不再总是一致的,因为缓存行可能会在丢弃之前短暂过时。然而,这除了加速之外,对单线程程序没有影响。唯一的影响是来自其他核心的写入操作的可见性,这些写入操作可能会显得(稍微)延迟。

流水线

另一种显著提高性能的常见处理器功能是流水线:如果可能,连续指令并行执行。在一条指令完成执行之前,处理器可能已经开始执行下一条指令。现代处理器通常可以在第一条指令仍在执行时,开始执行许多系列指令。

如果每条指令都依赖于前一条指令的结果,这并没有太大帮助;它们仍然需要等待前一条指令的结果。但是当某条指令可以独立于前一条指令执行时,它可能会先完成。例如,仅递增寄存器的指令可能非常快地完成,而之前启动的指令可能仍在等待从内存中读取某些数据,或者其他慢速操作。

虽然这对单线程程序没有影响(除了速度),但当一条操作内存的指令在前一条指令之前完成时,与其他核心的交互可能会乱序发生。

现代处理器可能以与预期完全不同的顺序执行指令的方式有很多。有许多专有的技术,其中一些只有在发现可以被恶意软件利用的微妙错误时才公开。然而,当它们按预期工作时,它们都有一个共同点:它们不会影响单线程程序的正确性,只会影响执行时间,但可能导致与其他核心的交互看起来发生在不一致的顺序。

允许内存操作重排序的处理器架构也提供了一种通过特殊指令来防止这种情况的方法。这些指令可能会强制处理器刷新其存储缓冲区,或在继续之前完成任何流水线中的指令。有时,这些指令只防止某种类型的重排序。例如,可能有一条指令可以防止存储操作之间的重排序,但仍然允许加载操作的重排序。可能发生的重排序类型以及如何防止它们,取决于处理器的架构。

内存排序

在 Rust 或 C 等语言中执行任何原子操作时,我们指定内存排序以告知编译器我们的排序要求。编译器将生成适合处理器的指令,以防止指令重排序从而违反这些规则。

允许的指令重排序类型取决于内存操作的类型。对于非原子操作和松弛的原子操作,任何类型的重排序都是可以接受的。而在另一极,顺序一致的原子操作不允许进行任何类型的重排序。

获取(acquire)操作不得与其后的任何内存操作重排序,而释放(release)操作不得与其前面的任何内存操作重排序。否则,某些互斥锁保护的数据可能会在获取互斥锁之前或在释放互斥锁之后被访问,导致数据竞争。

多副本原子性(Other-Multi-Copy Atomicity)

在某些处理器架构上,例如图形卡中可能会找到的架构,内存操作顺序的影响并不总是可以通过指令重排序来解释。在一个核心上执行的两个连续存储操作的效果,可能会按照相同的顺序在第二个核心上变得可见,但在第三个核心上却是相反的顺序。这可能发生在缓存不一致或共享存储缓冲区的情况下。这种效果无法通过第一个核心上的指令重排序来解释,因为这无法解释第二个和第三个核心观察到的不一致性。

我们在第 3 章中讨论的理论内存模型为这些处理器架构留下了空间,因为它没有要求除了顺序一致的原子操作之外的全局一致顺序。

我们在本章中关注的架构(x86-64 和 ARM64)是多副本原子的,这意味着一旦写操作对某个核心可见,就会同时对所有核心可见。对于多副本原子架构,内存排序只是指令重排序的问题。

一些架构,例如 ARM64,被称为弱排序的,因为它们允许处理器自由地重排序任何内存操作。而强排序架构,例如 x86-64,则对哪些内存操作可以重排序有很大的限制。

x86-64:强排序

在 x86-64 处理器上,加载操作永远不会看起来像是在后续内存操作之后发生。同样,这种架构不允许存储操作看起来像是在前面的内存操作之前发生。在 x86-64 上,唯一可能看到的重排序是存储操作延迟到后续加载操作之后。

注意
由于 x86-64 架构的重排序限制,通常将其描述为强排序架构,尽管有些人更喜欢将这一术语保留给可以保留所有内存操作顺序的架构。

这些限制满足了获取加载(acquire-loads)的所有需求(因为加载永远不会与后续操作重排序),以及释放存储(release-stores)的所有需求(因为存储永远不会与之前的操作重排序)。这意味着在 x86-64 上,我们可以“免费”获得释放和获取语义:释放和获取操作与松弛操作是相同的。

我们可以通过更改“加载和存储”以及“x86 锁前缀”中的 Relaxed 为 Release、Acquire 或 AcqRel,来验证这一点:

Rust 源代码编译后的 x86-64
pub fn a(x: &AtomicI32) {a:
x.store(0, Release);mov dword ptr [rdi], 0
}ret
pub fn a(x: &AtomicI32) -> i32 {a:
x.load(Acquire)mov eax, dword ptr [rdi]
}ret
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, AcqRel);lock add dword ptr [rdi], 10
}ret

如预期的那样,汇编代码是相同的,即使我们指定了更强的内存排序。

我们可以得出结论,在 x86-64 上,忽略潜在的编译器优化,获取和释放操作与松弛操作一样便宜。或者,更准确地说,松弛操作与获取和释放操作一样昂贵。

让我们看看 SeqCst 会发生什么:

Rust 源代码编译后的 x86-64
pub fn a(x: &AtomicI32) {a:
x.store(0, SeqCst);xor eax, eax
}xchg dword ptr [rdi], eax
ret
pub fn a(x: &AtomicI32) -> i32 {a:
x.load(SeqCst)mov eax, dword ptr [rdi]
}ret
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, SeqCst);lock add dword ptr [rdi], 10
}ret

加载和 fetch_add 操作仍然生成与之前相同的汇编代码,但存储操作的汇编代码完全改变了。xor 指令看起来有点不合适,但实际上它是通过将 eax 与自身异或来将 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 不同,获取(acquire)和释放(release)操作不会与松弛(relaxed)操作相同。

让我们看看 ARM64 对于 Release、Acquire 和 AcqRel 的情况:

Rust 源代码编译后的 ARM64
pub fn a(x: &AtomicI32) {a:
x.store(0, Release);stlr wzr, [x0]
}ret
pub fn a(x: &AtomicI32) -> i32 {a:
x.load(Acquire)ldar w0, [x0]
}ret
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, AcqRel);.L1:
}ldaxr w8, [x0]
add w9, w8, #10
stlxr w10, w9, [x0]
cbnz w10, .L1
ret

与之前的松弛版本相比,变化非常微妙:

  • str(存储寄存器)现在变为 stlr(存储-释放寄存器)。
  • ldr(加载寄存器)现在变为 ldar(加载-获取寄存器)。
  • ldxr(加载独占寄存器)现在变为 ldaxr(加载-获取独占寄存器)。
  • stxr(存储独占寄存器)现在变为 stlxr(存储-释放独占寄存器)。

如上所示,ARM64 为获取和释放排序提供了加载和存储指令的特殊版本。与 ldrldxr 指令不同,ldarldaxr 指令永远不会与任何后续的内存操作重排序。同样地,与 strstxr 指令不同,stlrstlxr 指令永远不会与任何前面的内存操作重排序。

注意
如果只使用释放或获取排序(而不是 AcqRel)进行获取-修改操作,那么分别只使用 stlxrldaxr 指令,并与常规 ldxrstxr 指令配对。

除了获取和释放语义所需的限制之外,任何特殊的获取和释放指令都不会与其他这些特殊指令重排序,这使得它们也适用于 SeqCst(顺序一致)操作。

如下所示,将操作升级为 SeqCst 结果与之前的汇编代码完全相同:

Rust 源代码编译后的 ARM64
pub fn a(x: &AtomicI32) {a:
x.store(0, SeqCst);stlr wzr, [x0]
}ret
pub fn a(x: &AtomicI32) -> i32 {a:
x.load(SeqCst)ldar w0, [x0]
}ret
pub fn a(x: &AtomicI32) {a:
x.fetch_add(10, SeqCst);.L1:
}ldaxr w8, [x0]
add w9, w8, #10
stlxr w10, w9, [x0]
cbnz w10, .L1
ret

这意味着在 ARM64 上,顺序一致的操作与获取和释放操作一样便宜。或者说,ARM64 的 Acquire、Release 和 AcqRel 操作与 SeqCst 一样昂贵。然而,与 x86-64 不同,松弛操作相对便宜,因为它们没有导致比必要更强的排序保证。

ARMv8.1 的原子释放和获取指令

正如在“ARMv8.1 原子指令”中所讨论的,ARMv8.1 版本的 ARM64 包含了一些面向原子操作的 CISC 风格指令,例如 ldadd(加载并添加),作为 ldxr/stxr 循环的替代。

就像加载和存储操作有获取和释放语义的特殊版本一样,这些指令也有更强内存排序的变体。由于这些指令同时涉及加载和存储,每个指令都有三个额外的变体:一个用于释放(-l),一个用于获取(-a),一个用于获取和释放(-al)语义。

例如,对于 ldadd,还存在 ldaddlldaddaldaddal。类似地,cas 指令有 caslcasacasal 变体。

与加载和存储指令一样,获取和释放(-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| {
        // 启动四个线程,每个线程迭代一百万次。
        for _ in 0..4 {
            s.spawn(|| for _ in 0..1_000_000 {
                // 使用错误的内存排序来获取锁。
                while locked.swap(true, Relaxed) {}
                compiler_fence(Acquire);

                // 在持有锁时非原子地递增计数器。
                let old = counter.load(Relaxed);
                let new = old + 1;
                counter.store(new, Relaxed);

                // 使用错误的内存排序来释放锁。
                compiler_fence(Release);
                locked.store(false, Relaxed);
            });
        }
    });

    println!("{}", counter.into_inner());
}

如果锁正常工作,我们期望计数器的最终值正好是四百万。请注意,递增计数器是以非原子的方式进行的,使用单独的加载和存储而不是单个 fetch_add,以确保自旋锁的问题可能导致漏增,从而导致计数器的总值偏低。

在一台带有 x86-64 处理器的计算机上多次运行这个程序,得到以下结果:

4000000
4000000
4000000

如预期的那样,我们“免费”获得了释放和获取语义,并且我们的错误没有引发任何问题。

在一台 2021 年的 Android 手机和一台 Raspberry Pi 3 型号 B(它们都使用 ARM64 处理器)上尝试这个程序,结果也是相同的:

4000000
4000000
4000000

这表明并不是所有 ARM64 处理器都使用了其指令重排序的所有形式,尽管基于这个实验,我们无法假设太多。

在一台 2021 年的 Apple iMac 上尝试,该设备包含基于 ARM64 的 Apple M1 处理器,我们得到了不同的结果:

3988255
3982153
3984205

我们之前隐藏的错误突然变成了一个实际的问题——这个问题只在弱排序系统上可见。计数器的偏差大约为 0.4%,这显示了问题的细微之处。在实际场景中,这样的问题可能在很长时间内不被发现。

提示
在尝试复制上述结果时,不要忘记启用优化(使用 cargo run --releaserustc -O)。没有优化的情况下,相同代码通常会生成更多指令,这可能隐藏指令重排序的细微效果。

内存栅栏

有一种与内存排序相关的指令我们尚未见过:内存栅栏。内存栅栏或内存屏障指令用于表示 std::sync::atomic::fence,我们在“栅栏”部分中讨论过。

如前所述,x86-64 和 ARM64 的内存排序主要涉及指令重排序。栅栏指令可以防止某些类型的指令在它们之间重排序。

获取(acquire)栅栏必须防止前面的加载操作与后续的任何内存操作重排序。同样,释放(release)栅栏必须防止后续的存储操作与之前的任何内存操作重排序。顺序一致(SeqCst)栅栏必须防止所有在其之前的内存操作与栅栏之后的内存操作重排序。

在 x86-64 上,基本的内存排序语义已经满足了获取和释放栅栏的需求。这种架构无论如何都不允许这些栅栏所防止的重排序类型。

让我们直接看看四种不同栅栏在 x86-64 和 ARM64 上分别编译成了哪些指令:

Rust 源代码编译后的 x86-64编译后的 ARM64
pub fn a() {a:a:
fence(Acquire);retdmb ishld
}retret
pub fn a() {a:a:
fence(Release);retdmb ish
}retret
pub fn a() {a:a:
fence(AcqRel);retdmb ish
}retret
pub fn a() {a:a:
fence(SeqCst);mfencedmb ish
}retret

不出所料,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 字节。
  • 缓存通过缓存一致性协议保持一致性,例如写通协议(write-through)或 MESI 协议。
  • 填充,例如通过 #[repr(align(64))],可以通过防止伪共享来提高性能。
  • 加载操作可能比失败的比较-交换操作便宜得多,部分原因是后者通常需要对缓存行的独占访问。
  • 指令重排序在单线程程序中是不可见的。
  • 在包括 x86-64 和 ARM64 在内的大多数架构中,内存排序的目的是防止某些类型的指令重排序。
  • 在 x86-64 上,每个内存操作都有获取和释放语义,使得它们与松弛操作的成本完全相同,除了存储和栅栏以外的其他操作也以顺序一致的语义进行,没有额外成本。
  • 在 ARM64 上,获取和释放语义的成本不如松弛操作便宜,但它们包含顺序一致的语义而没有额外成本。

本章所涉及的汇编指令的摘要可以在图 7-1 中找到。

image.png