“
原文标题: 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操作是因为如果我使用普通的指针操作,编译器就会足够聪明地跳过内存读取而直接打印出58
。Volatile操作阻止编译器重排序或跳过内存操作。但是,他们对硬件没有影响(或者说,编译器重排序相对于非易失性内存操作)。
一旦我们引入了多线程,我们就会面临这样一个事实: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) -> (u32, u32) {
(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碎碎念
参考资料
[1]X86 汇编: https://godbolt.org/z/uVQM8T
[2]ARM汇编: https://godbolt.org/z/wWQo8P