使用锈蚀检查ARM与x86内存型号

2020-06-27 16:04:40

随着苹果公司最近宣布,他们将在未来的笔记本电脑和台式机上从Intel X86 CPU转向他们自己的ARM CPU,我认为现在是一个很好的时机来看看可能会影响在Rust工作的系统程序员的一些差异。

ARM CPU与X86不同的一个关键方面是它们的内存模型。本文将介绍什么是内存模型,以及它如何在一个CPU上导致代码正确,而在另一个CPU上导致争用情况。

特定CPU上的多个线程之间加载和存储到内存的交互方式称为该体系结构的内存模型。

根据CPU的内存模型,一个线程的多次写入可能会以与发出它们的顺序不同的顺序对另一个线程可见。

发出多次读取的线程也是如此。发出多个读取的线程可以接收表示与发出顺序不同的时间点的全局状态的“快照”。

现代硬件需要这种灵活性才能最大限度地提高内存操作的吞吐量。虽然CPU时钟频率和核心计数随着每一次新的CPU迭代而不断增加,但内存带宽却一直难以跟上。从内存中移动数据进行操作通常是影响应用程序性能的瓶颈。

如果您从未编写过多线程代码,或者只使用更高级的同步原语(如std::sync::mutex)编写过,那么您可能从未接触过内存模型的细节。这是因为,不管CPU的内存模型允许它执行什么重新排序,它总是向当前线程呈现一致的内存视图。

如果我们看一下下面的代码片段,该代码写入内存,然后直接读回相同的内存,那么当我们读取时,我们总是会得到期望值58。我们从来不会从记忆中读到一些陈旧的值。

发布不安全的FN READ_AFTER_WRITE(u32_ptr:*mut u32){u32_ptr.write_volatile(58);let u32_value=u32_ptr.read_volatile();println!(";值为{}";,u32_value);}。

我使用易失性操作,因为如果我使用普通指针操作,编译器就足够智能,可以跳过内存读取,只打印值58。易失性操作会阻止编译器重新排序或跳过我们的内存操作。但是,它们对硬件没有影响。

一旦我们引入了多个线程,我们现在就会发现CPU可能会重新排序我们的内存操作。

发布不安全FN编写器(u32_ptr_1:*mut u32,u32_ptr_2:*mut u32){u32_ptr_1.write_volatile(58);u32_ptr_2.write_volatile(42);}发布不安全FN读取器(u32_ptr_1:*mut u32,u32_ptr_2:*mut u32)->;(u32,u32){(u32。

如果我们将两个指针的内容初始化为0,然后在不同的线程中运行每个函数,我们就可以为读取器列出可能的结果。我们知道没有同步,但是根据我们使用单线程代码的经验,我们认为可能的返回值是(0,0)、(58,0)或(58,42)。但是,影响多线程的存储器写入的硬件重新排序的可能性意味着存在第四个选项(0,42)。

您可能认为由于缺乏同步,可能会有更多的可能性。但所有硬件内存型号都保证对齐的加载和存储(64位CPU上的u32或32位CPU、u64)是原子的。如果我们将其中一个写入更改为0xFFFF_FFFF,则读取将仅看到旧值或新值。它永远不会看到像0xFFFF_0000这样的部分值。

如果在使用常规内存访问时隐藏了CPU内存模型的细节,我们似乎无法在多线程程序中控制它,因为它会影响程序的正确性。

幸运的是,Rust提供了STD::SYNC::ATOM模块,其中包含给我们提供所需控制的类型。我们使用这些类型精确地指定代码所需的内存排序要求。我们用性能来换取正确性。我们对硬件可以执行内存操作的顺序进行了限制,从而取消了硬件想要执行的任何带宽优化。

在使用原子模块时,我们不担心各个CPU体系结构的实际内存模型。相反,原子模块的操作使用与CPU无关的抽象内存模型。一旦我们使用这个Rust内存模型表达了对加载和存储的要求,编译器就会执行映射到目标CPU的内存模型的工作。

我们在每个操作上指定的要求采用我们想要允许(或拒绝)操作的重新排序的形式。排序形成一个层次结构,每个级别对CPU施加更多限制。例如,ORDING::REAXED意味着CPU可以自由地执行它想要的任何重新排序。Ording::Release意味着商店只有在所有后续商店都完成之后才能完成。

让我们看看原子内存写入实际上是如何编译的,与常规写入相比。

使用std::sync::atom::*;pub unSafe FN test_write(Shared_ptr:*mut u32){*Shared_ptr=58;}pub unSafe FN test_ATOM_RELAX(Shared_PTR:&;AtomicU32){Shared_PTR.store(58,Order::Relaced);}pub unsafe FN test_ATOM_Release(Shared_PTr:&;AtomicU32){Shared_PTR.store(。}pub unsafe fn test_ATOM_CONSISTENT(SHARED_PTR:&;AtomicU32){Shared_PTR.store(58,Ording::SeqCst);}。

如果我们查看上面代码的X86程序集,我们会看到前三个函数生成相同的代码。直到更严格的SeqCst顺序,我们才会得到不同的指令。

示例::test_write:MOV双字PTR[RDI],58 ret示例::test_ATOM_RELAX:MOV双字PTR[RDI],58 ret示例::TEST_ATOM_RELEASE:MOV双字PTR[RDI],58 ret示例::TEST_ATOM_CONSISTENT:MOV eax,58xchg双字PTR[RDI],eAX ret。

前两个排序使用MOV(移动)指令将值写入内存。只有最严格的排序才会产生与原始指针写入不同的指令XCHG(原子e XCHan G)。

示例::test_write:MOV W8,#58字符串W8,[X0]ret示例::test_ATOM_RELAX:MOV W8,#58字符串W8,[X0]ret示例::TEST_ATOM_RELEASE:MOV W8,#58 STLR W8,[X0]RET示例::TEST_ATOM_CONSISTENT:MOV W8,#58 STLR W8,[X0]RET示例::TEST_ATOM_CONSISTENT:MOV W8,#58 STLR W8,[X0]RET示例

相反,我们可以看到,一旦达到发布顺序要求,就会有所不同。原始指针和松弛的原子存储使用STR(存储寄存器),而释放和顺序排序使用指令STLR(使用RELease寄存器存储)。MOV指令是这种反汇编将常量58移动到寄存器中,这不是内存操作。

我们应该能在这里看到风险。理论Rust内存模型和X86内存模型之间的映射更能容忍程序员的错误。我们可以编写与抽象内存模型相关的错误代码,但仍然可以让它生成正确的汇编代码,并在某些CPU上正常工作。

我们将要探索的程序构建在跨线程存储原子指针值的概念之上。一个线程将使用它拥有的可变对象执行一些工作。一旦该工作完成,它将把该工作作为不可变的共享引用发布,使用原子指针写来通知工作已经完成,并允许读取线程使用数据。

如果我们真的想测试X86的内存模型的容错程度,我们可以编写多线程代码,跳过对std::sync::atom模块的任何使用。我想强调的是,这不是你真正应该考虑做的事情。事实上,此代码可能是未定义的行为。这只是一个学习练习。

pub struct SynchronisedSum{Shared:UnsafeCell<;*const u32>;,Samples:usize,}Impl SynchronisedSum{pub FN new(Samples:usize)->;self{assert!((Samples As U32)<;=u32::max);Self{Shared:UnsafeCell::New(std:ptr::null(),Samples,}}pub FN生成。self){//处理此线程拥有的数据let data:box<;[u32]>;=(0.。Self.Samples as u32).Collect();//发布到其他线程让Shared_PTR=sel.Shared.get();unsafe{Shared_PTR.write_Volatile(data.as_ptr());}std::mem::forget(Data);}pub FN Calculate(&;self,Expect_sum:u32){loop{//检查作品是否已经发布,但让Shared_PTR=sel.shared.get();让DATA_PTR=UNSAFE{SHARED_PTR.read_Volatile()};如果!data_ptr.is_null(){//数据现在可由多个线程访问,请将其视为不可变的引用。设data=unsafe{std::Slice::from_raw_part(data_ptr,self.sample)};设mut sum=0;for i in(0.。sel.sample).rev(){sum+=data[i];}//我们是否访问了预期的数据?assert_eq!(SUM,EXPECTED_SUM);Break;}。

计算数组和的函数从执行读取共享指针的值的循环开始。由于原子存储保证,我们知道read_volatile()将只返回NULL或指向u32片的指针。我们只是简单地保持循环,直到生成线程完成并发布它的工作。一旦它出版了,我们就可以阅读它并计算所有元素的总和。

作为一个简单的测试,我们将同时运行两个线程,一个用于生成值,另一个用于计算总和。两个线程都在执行完它们的工作后退出,我们将等待它们使用Join完成。

pub fn main(){print_arch();for i in 0..。10_000{let SUM_GENERATE=Arc::New(SynchronisedSum::New(512%));让SUM_COMULATE=Arc::Clone(&;SUM_GENERATE);让Calculate_Thread=线程::Span(Move||{SUM_Calculate.Calculate(130816);});Thread::Slear(std::Time::Duration::From_Millis(1));让GENERATE_THREAD=THREAD::Spron(Move|{SUM_GENERATE.GENERATE。Calculate_Thread.Join().Expect(&;Format!(";iteration{}Failed";,i));Generate_Thread.Join().unwire();}println!(";所有通过的迭代";);}

在aarch64thread';<;unname>;';上运行时死机断言失败:`(左==右)`左:`122824`,右:`130816`';,src\main.rs:45:17注意:使用`RUST_Backtrace=1`环境变量运行以显示在';main';35处死机的回溯ethread';main';迭代失败:任何&。

x86处理器能够成功运行测试所有10,000次,但ARM处理器在第35次尝试时失败。

我们的模式的正确运行要求我们正在做的所有“工作”在内存中处于正确的状态,然后执行对共享指针的最终写入以将其发布到其他线程。

ARM的内存模型与X86的不同之处在于,ARM CPU将相对于其他写入重新排序写入,而X86不会。因此,Calculate线程可以看到非空指针,并在值被写入之前开始从片中读取值。

对于我们程序中的大多数内存操作,我们希望让CPU可以自由地重新安排操作,以最大限度地提高性能。我们只想指定确保正确性所需的最小约束。

在我们的GENERATE函数中,我们希望片中的值以给我们最快的顺序写入内存。但是,在我们将值写入共享指针之前,所有写入都必须完成。

从计算上看,情况正好相反。我们要求从片内存读取的值至少来自与共享指针的值相同的时间点。尽管这些指令在共享指针的读取完成后才会发出,但我们需要掩码来确保我们不会从过时的缓存中获取值。

为了确保代码的正确性,对共享指针的写入必须具有释放排序,并且由于计算中的读取顺序要求,我们使用获取排序。

我们对数据的初始化不会改变,我们的SUM代码也不会改变,我们想让CPU自由地执行最高效的操作。

struct SynchronisedSumFixed{Shared:AtomicPtr<;u32>;,Samples:usize,}Impl SynchronisedSumFixed{fn new(Samples:usize)->;Self{assert!((Samples As U32)<;u32::Max);Self{Shared:AtomicPtr::New(std::ptr::null_mut()),Samples,}}FN Generate(&。self){//处理此线程拥有的数据,让mut data:box<;[u32]>;=(0.。Self.Samples as u32).Collect();//将此数据发布(也称为释放)到其他线程unsafe{sel.share.store(data.as_mut_ptr(),order::release);}std::mem::forget(Data);}fn Calculate(&;self,expecred_sum:u32){loop{let data_ptr=unsafe{sel.Shared.load(order::Acquiire)};//当指针为非NULL时,我们已经安全地获取了对全局数据的引用,如果!data_ptr.is_null(){let data=unsafe{std::Slice::from_raw_part(data_ptr,self.sample)};设mut sum=0;for i in(0.。Self.Samples).rev(){sum+=data[i];}assert_eq!(sum,projected_sum);Break;}。

当在多个CPU上工作时,使用原子模块仍然需要小心。正如我们在查看X86和ARM程序集输出中看到的那样,如果我们在我们的商店上用Ording::Release替换Order::Release,我们将返回到在x86上工作正常但在ARM上失败的版本。

使用原子是不安全的,因为程序员有责任通过使用太弱的排序来确保没有未定义的行为。

这只是对内存模型的简要介绍,希望对这个主题不熟悉的人会很清楚。

我想我对无锁编程的第一次介绍是这篇文章。它可能看起来并不相关,因为详细信息涵盖了C++、Xbox360中的PowerPC CPU和Windows API。但这仍然是对这些原则的很好解释。另外,开场白中的这段话仍然站不住脚:

无锁编程是多线程编程的一种有效技术,但不能掉以轻心。在使用它之前,您必须了解它的复杂性,并且您应该仔细测量以确保它确实给您带来了您期望的收益。在许多情况下,有更简单、更快的解决方案,比如不那么频繁地共享数据,应该改为使用这些解决方案。

希望我们已经了解了系统编程的一个新方面,随着ARM芯片变得越来越普遍,这一方面将变得越来越重要。确保原子代码的正确性从来都不是一件容易的事,但是当使用不同的内存模型跨不同的架构工作时,它就变得更加困难了。