【译】使用Rust测试ARM和X86内存模型(一)

1,072 阅读7分钟

原文标题: Examining ARM vs X86 Memory Models with Rust

原文链接: https://www.nickwilcox.com/blog/arm_vs_x86_memory_model/

公众号: Rust碎碎念

苹果公司最近宣布,他们将要把笔记本和桌面电脑从Intel x86 CPU 迁移到自研的ARM架构的CPU。我认为是时候来看一下这两者之间那些会对使用Rust工作的系统程序员有影响的区别了。

ARM架构的CPU不同于X86 CPU的很重要的一点是它们的内存模型。这篇文章将会讨论什么是内存模型以及它是如何让代码在一种CPU架构上正确运行而在另一种CPU架构上引起竞争条件(race condition)。

内存模型

特定CPU上多个线程之间交互时对内存进行加载(load)和存储(store)的方式称为该架构的内存模型。

根据CPU的内存模型的不同,一个线程的多次写入操作可能会被另一个线程以不同的顺序可见。

进行多次读取操作的线程也是如此。一个正在进行多次读取操作的线程可能收到全局状态的“快照”,这些状态表示的时间顺序不同于事实上发生的顺序。

现代硬件需要这种灵活性从而能够最大化内存操作的吞吐量。每次CPU的更新换代就会提升CPU的时钟频率和核数,但是内存带宽一直在努力追赶保持同步。将数据从内存中取出进行操作通常是应用程序的性能瓶颈。

如果你从来没有写过多线程代码,或者仅仅使用高级同步原语,如std::sync::Mutex来完成任务,那你可能从来没有接触过内存模型的细节。这是因为,不管CPU的内存模型允许它执行什么样的重新排序,它总是对当前线程呈现出一致的内存视图。

如果我们看一下下面的代码片段,这段代码写入内存然后直接读取相同的内存,当我们进行读取时,我们总能按照预期读到58。我们永远不会从内存中读取过时的值。

pub unsafe fn read_after_write(u32_ptr: *mut u32) {
    u32_ptr.write_volatile(58);
    let u32_value = u32_ptr.read_volatile();
    println!("the value is {}", u32_value);
}

我之所以使用volatile操作是因为如果我使用普通的指针操作,编译器就会足够聪明地跳过内存读取而直接打印出58Volatile操作阻止编译器重排序或跳过内存操作。但是,他们对硬件没有影响(或者说,编译器重排序相对于非易失性内存操作)。

一旦我们引入了多线程,我们就会面临这样一个事实:CPU可能对我们的内存操作重排序。

我们可以在多线程环境中测试下面的代码片段:

pub unsafe fn writer(u32_ptr_1: *mut u32, u32_ptr_2: *mut u32) {
    u32_ptr_1.write_volatile(58);
    u32_ptr_2.write_volatile(42);
}

pub unsafe fn reader(u32_ptr_1: *mut u32, u32_ptr_2: *mut u32-> (u32u32) {
    (u32_ptr_1.read_volatile(), u32_ptr_2.read_volatile())
}

如果我们把两个指针指向的内容都初始化为0, 然后每个函数放在不同的线程中运行,我们可以列出可能读取到的结果。我们知道,虽然没有同步机制,但是基于我们对单线程中代码的经验,我们可以想到可能的返回值是(0,0)(58,0)(58,42)。但是硬件对内存写操作的重排序可能会影响多线程,这意味着,还有第四种可能性(0,42)

你可能认为,由于缺少同步机制,可能会产生更多的可能性。但是所有的硬件内存模型保证了原生字(word)对齐的加载(load)和存储(store)是原子性的(32位CPU的u32类型,64位CPU的u64类型)。如果我们把其中一个写入改为0xFFFF_FFFF,读取操作将永远只能看到旧值或新值。它将不会看到一个不完整的值,比如0xFFFF_0000

当使用常规方式访问内存时,如果CPU的内存模型的细节被隐藏起来,当其影响到程序的正确性时,似乎我们就没有办法在多线程程序中对其进行控制。

幸运地是,Rust提供了如std::sync::atomic这样的模块,其中提供了能够满足我们控制需要的类型。我们使用这些类型来明确指定我们的代码所需要的内存序(memory order)要求。我们用性能换取正确性。我们对硬件执行内存操作的顺序进行了限制,取消了硬件希望执行的带宽优化。

当使用atomic模块进行工作的时候,我们不用担心各个CPU架构上的实际的内存模型。atomic模块工作在一个抽象的内存模型之上,对底层CPU并不知道。一旦我们在使用Rust内存模型时表明我们对加载(load)和存储(store)的需求,编译器就会将其映射到目标CPU的内存模型上。

我们对于每个操作的要求表现为我们想要在操作上允许(或拒绝)什么样的重排序。次序形成了一个层级,每一层对CPU进行了更多的限制。例如,Ordering::Relaxed意味着CPU可以自由执行任意的重排序。Ordering::Release意味着一个存储(store)操作只能在所有正在进行的存储完成结束之后才能完成。

让我们来看看,原子内存写操作相比较于常规写操作,实际上是怎么编译的。

use std::sync::atomic::*;

pub unsafe fn test_write(shared_ptr: *mut u32) {
    *shared_ptr = 58;
}

pub unsafe fn test_atomic_relaxed(shared_ptr: &AtomicU32) {
    shared_ptr.store(58, Ordering::Relaxed);
}

pub unsafe fn test_atomic_release(shared_ptr: &AtomicU32) {
    shared_ptr.store(58, Ordering::Release);
}

pub unsafe fn test_atomic_consistent(shared_ptr: &AtomicU32) {
    shared_ptr.store(58, Ordering::SeqCst);
}

如果我们看一下上面的代码生成的 X86 汇编[1],我们会看到前三个函数产生了相同的代码。直到更加严格的SeqCst次序,我们才得到一个生成的不同的指令集。

example::test_write:
        mov     dword ptr [rdi], 58
        ret


example::test_atomic_relaxed:
mov     dword ptr [rdi], 58
ret




example::test_atomic_release:
mov     dword ptr [rdi], 58
ret




example::test_atomic_consistent:
mov     eax, 58
xchg    dword ptr [rdi], eax
ret

example::test_atomic_consistent: mov eax, 58 xchg dword ptr [rdi], eax ret

前面两个次序,使用MOV(MOVe)指令把值写到内存。只有更严格的次序生成了不同的指令,XCHG(atomic eXCHanG),来对一个原生指针进行写操作。

我们可以和生成的ARM汇编[2]进行比较:

example::test_write:
        mov     w8, #58
        str     w8, [x0]
        ret


example::test_atomic_relaxed:
mov     w8, #58
str     w8, [x0]
ret




example::test_atomic_release:
mov     w8, #58
stlr    w8, [x0]
ret




example::test_atomic_consistent:
mov     w8, #58
stlr    w8, [x0]
ret

example::test_atomic_consistent: mov w8, #58 stlr w8, [x0] ret

和之前相反,在我们达到release次序要求之后可以看到一些不同。原生指针和relax原子存储操作使用STR(STore Register)而release和sequential次序使用指令STLR(STore with reLease Register)。在这段汇编代码里,MOV指令把常量58移动到一个寄存器,它不是一个内存操作。

我们应该能够看出这里的风险,即对程序员的错误更加宽容。对我们而言,在抽象内存模型上写出错误的代码但是让它在某些CPU上产生正确的汇编代码并且正确工作也是有可能的。

本文禁止转载,谢谢配合! 欢迎关注我的微信公众号: Rust碎碎念

Rust碎碎念
Rust碎碎念

参考资料

[1]

X86 汇编: https://godbolt.org/z/uVQM8T

[2]

ARM汇编: https://godbolt.org/z/wWQo8P