Skip to main content

第 7 章 理解处理器(Understanding the Processor)

虽然第二和第三章的理论已足够我们编写正确的并发代码,但进一步了解处理器层面的实际运行情况也极具价值。在本章中,我们将探究原子操作编译成的机器指令、不同处理器架构的差异、为何存在弱版本的 compare_exchange、内存排序在单个指令的最底层意味着什么,以及缓存如何与之关联。

本章的目标并非理解每个处理器架构的所有相关细节。那将需要填满许多书架的书籍,其中很多可能尚未撰写或未公开提供。相反,本章旨在形成关于原子操作在处理器层面如何工作的一般概念,以便在实现和优化涉及原子操作的代码时能够做出更明智的决策。当然,也是为了满足我们对幕后情况的好奇心——从抽象理论中稍作休息。

为使内容尽可能具体,我们将聚焦于两种特定的处理器架构:

x86-64: x86架构的64位版本,由英特尔和AMD的处理器实现,用于大多数笔记本电脑、台式机、服务器和一些游戏主机。虽然最初的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。

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

处理器指令(Processor Instructions)

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

汇编语言简介

当编译用Rust或C等编译语言编写的软件时,您的代码会被翻译成最终运行程序的处理器可执行的机器指令。这些指令高度依赖于您编译程序所针对的处理器架构。

这些指令,也称为机器码,以二进制形式编码,这对我们人类来说相当难以阅读。汇编语言是这些指令的人类可读表示。每条指令由一行文本表示,通常以一个单词或缩写开头以标识指令,后跟其参数或操作数。汇编器将文本表示转换为二进制表示,反汇编器则执行相反操作。

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

以下是为某虚构架构的程序一小部分生成的汇编片段示例:

ldr x, 1234    // 从内存地址1234加载到x
li y, 0 // 将y设置为零
inc x // 增加x
add y, x // 将x加到y
mul x, 3 // 将x乘以3
cmp y, 10 // 将y与10比较
jne -5 // 如果不相等则向后跳转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编译器支持许多架构,但许多反汇编器仅支持它们编译所针对的架构。

更直接的选择是要求编译器通过使用--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,即要增加10的i32的内存地址。首先,ldr指令从该内存地址加载32位值到w8寄存器。然后,add指令将10加到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,通常具有更简单的指令集,变体很少。大多数指令只能操作寄存器,而加载和存储到内存需要单独的指令。这允许处理器更简单,从而可以降低成本或有时提高性能。

这种差异对于原子fetch-and-modify指令尤其重要,我们马上就会看到。

尽管编译器通常很聪明,但它们并不总是生成最优的汇编,尤其是在涉及原子操作时。如果您在实验中发现一些情况,对汇编中看似不必要的复杂性感到困惑,这通常只意味着未来版本的编译器有更多的优化机会。

加载和存储操作(Load and Store)

在深入探讨更高级的内容之前,让我们先看看最基本原子操作所使用的指令:加载和存储。

通过 &mut i32 进行的常规非原子存储,在x86-64和ARM64上都只需要一条指令,如下所示:

Rust 源代码编译的 x86-64编译的 ARM64
pub fn a(x: &mut i32) { *x = 0; }a: mov dword ptr [rdi], 0 reta: str wzr, [x0] ret

在x86-64上,使用非常通用的 mov 指令将数据从一个地方复制("移动")到另一个地方;在这种情况下,从零常量到内存。在ARM64上,使用 str(存储寄存器)指令将32位寄存器存储到内存。在这种情况下,使用特殊的 wzr 寄存器,它始终包含零。

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

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

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

当我们查看 relaxed 加载操作时,也会发生同样的情况:

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

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

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

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

读-修改-写操作(Read-Modify-Write Operations)

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

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

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

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

x86 lock 前缀(x86 lock prefix)

为了支持多核系统,Intel引入了一个称为 lock 的指令前缀。它用作 add 等指令的修饰符,使其操作变为原子操作。

lock 前缀最初导致处理器在指令执行期间暂时阻止所有其他核心访问内存。虽然这是一种简单有效的方法,使某些操作对其他核心显示为原子操作,但为每个原子操作停止世界可能相当低效。较新的处理器对 lock 前缀的实现要先进得多,它不会阻止其他核心操作不相关的内存,并且允许核心在等待某块内存可用时执行有用的工作。

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

让我们通过将上一个示例改为操作 AtomicI32 来查看 lock add 的实际效果:

Rust 源代码编译的 x86-64
pub fn a(x: &AtomicI32) { x.fetch_add(10, Relaxed); }a: 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 { x.fetch_add(10, Relaxed) }a: 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 compare-and-exchange instruction)

"比较并交换操作"中,我们看到了如何将任何原子fetch-and-modify操作实现为比较并交换循环。这正是编译器将用于无法用单个x86-64指令表示的操作,因为该架构确实包含(可加 lock 前缀的)cmpxchg(比较并交换)指令。

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

Rust 源代码编译的 x86-64
pub fn a(x: &AtomicI32) -> i32 { x.fetch_or(10, Relaxed) }a: 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 并应用二进制 or 操作,使得 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 指令。

加载链接和条件存储指令(Load-Linked and Store-Conditional Instructions)

在RISC架构上,最接近比较并交换循环的是加载链接/条件存储(LL/SC)循环。它涉及两条特殊的配对指令:加载链接指令,其行为大多像常规加载指令;以及条件存储指令,其行为大多像常规存储指令。它们成对使用,两条指令针对相同的内存地址。与常规加载和存储指令的关键区别在于存储是条件性的:如果自加载链接指令以来任何其他线程覆盖了该内存,则拒绝存储到内存。

这两条指令允许我们从内存加载值,修改它,并且仅在我们加载之后没有人覆盖该值时才将新值存储回去。如果失败,我们可以直接重试。一旦成功,我们可以安全地假装整个操作是原子的,因为它没有被打断。

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

这使得在跟踪内存更改时可以不太精确,代价可能是LL/SC循环中多几个周期。内存访问可以不是按字节跟踪,而是按64字节块、千字节,甚至整个内存来跟踪。不太准确的内存跟踪会导致LL/SC循环中出现更多不必要的周期,显著降低性能,但也降低了实现复杂性。

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

ARM 独占加载和存储(ARM load-exclusive and store-exclusive)

在ARM64上,或者至少在ARMv8的第一个版本中,没有任何原子fetch-and-modify或比较并交换操作可以用单个指令表示。符合其RISC本质,加载和存储步骤与计算和比较是分开的。

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

为了查看它们的实际效果,让我们看看在ARM64上执行原子加法时会发生什么:

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

我们得到的东西看起来与我们之前得到的非原子版本(在第133页的"读-修改-写操作"中)非常相似:加载指令、加法指令和存储指令。加载和存储指令已被其"独占"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 原子指令

ARM64的后续版本,作为ARMv8.1的一部分,还包括用于常见原子操作的新CISC风格指令。例如,新的 ldadd(加载并加)指令相当于原子 fetch_add 操作,无需LL/SC循环。它甚至包括用于像 fetch_max 这样的操作的指令,这在x86-64上不存在。

它还包括与 compare_exchange 对应的 cas(比较并交换)指令。当使用此指令时,compare_exchangecompare_exchange_weak 之间没有区别,就像在x86-64上一样。虽然LL/SC模式非常灵活且很好地适应了通用RISC模式,但这些新指令可能性能更高,因为它们更容易通过专用硬件进行优化。

ARM 上的比较并交换(Compare-and-exchange on ARM)

compare_exchange 操作很好地映射到这种LL/SC模式,通过使用条件分支指令在比较失败时跳过存储指令。

让我们看看生成的汇编代码:

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

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

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

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

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