原文标题: 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。我们永远不会从内存中读取过时的值。

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

我之所以使用volatile操作是因为如果我使用普通的指针操作,编译器就会足够聪明地跳过内存读取而直接打印出58。Volatile操作阻止编译器重排序或跳过内存操作。但是,他们对硬件没有影响(或者说,编译器重排序相对于非易失性内存操作)。一旦我们引入了多线程,我们就会面临这样一个事实:CPU可能对我们的内存操作重排序。我们可以在多线程环境中测试下面的代码片段:

  1. pub unsafe fn writer(u32_ptr_1: *mut u32, u32_ptr_2: *mut u32) {
  2. u32_ptr_1.write_volatile(58);
  3. u32_ptr_2.write_volatile(42);
  4. }
  5. pub unsafe fn reader(u32_ptr_1: *mut u32, u32_ptr_2: *mut u32) -> (u32, u32) {
  6. (u32_ptr_1.read_volatile(), u32_ptr_2.read_volatile())
  7. }

如果我们把两个指针指向的内容都初始化为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)操作只能在所有正在进行的存储完成结束之后才能完成。让我们来看看,原子内存写操作相比较于常规写操作,实际上是怎么编译的。

  1. use std::sync::atomic::*;
  2. pub unsafe fn test_write(shared_ptr: *mut u32) {
  3. *shared_ptr = 58;
  4. }
  5. pub unsafe fn test_atomic_relaxed(shared_ptr: &AtomicU32) {
  6. shared_ptr.store(58, Ordering::Relaxed);
  7. }
  8. pub unsafe fn test_atomic_release(shared_ptr: &AtomicU32) {
  9. shared_ptr.store(58, Ordering::Release);
  10. }
  11. pub unsafe fn test_atomic_consistent(shared_ptr: &AtomicU32) {
  12. shared_ptr.store(58, Ordering::SeqCst);
  13. }

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

  1. example::test_write:
  2. mov dword ptr [rdi], 58
  3. ret
  4. example::test_atomic_relaxed:
  5. mov dword ptr [rdi], 58
  6. ret
  7. example::test_atomic_release:
  8. mov dword ptr [rdi], 58
  9. ret
  10. example::test_atomic_consistent:
  11. mov eax, 58
  12. xchg dword ptr [rdi], eax
  13. ret

前面两个次序,使用MOV(MOVe)指令把值写到内存。只有更严格的次序生成了不同的指令,XCHG(atomic eXCHanG),来对一个原生指针进行写操作。我们可以和生成的ARM汇编[2]进行比较:

  1. example::test_write:
  2. mov w8, #58
  3. str w8, [x0]
  4. ret
  5. example::test_atomic_relaxed:
  6. mov w8, #58
  7. str w8, [x0]
  8. ret
  9. example::test_atomic_release:
  10. mov w8, #58
  11. stlr w8, [x0]
  12. ret
  13. example::test_atomic_consistent:
  14. mov w8, #58
  15. stlr w8, [x0]
  16. ret

和之前相反,在我们达到release次序要求之后可以看到一些不同。原生指针和relax原子存储操作使用STR(SToreRegister)而release和sequential次序使用指令STLR(STore with reLeaseRegister)。在这段汇编代码里,MOV指令把常量58移动到一个寄存器,它不是一个内存操作。我们应该能够看出这里的风险,即对程序员的错误更加宽容。对我们而言,在抽象内存模型上写出错误的代码但是让它在某些CPU上产生正确的汇编代码并且正确工作也是有可能的。

使用Atomic写一个多线程程序

我们将要讨论的程序是构建于存储一个指针值是跨线程原子操作这一概念之上的。一个线程将要使用自己拥有的一个可变对象来执行某项任务。一旦它结束了那项任务,它将会以一个不可变的共享引用来发布该任务,使用一个原子指针写入工作完成的信号并且允许读线程使用数据。

仅X86模式下的实现

如果我们真的想要测试X86的内存模型有多么宽容(forgiving 译者注:这里暂未想到更合适的翻译 ),我们可以写一段跳过任意使用了std::sync::atomic模块的代码。我想强调的是,这不是你真正应该考虑做的事情。事实上,由于没有保证避免编译器对指令的重排序,所以这段代码有未定义行为(尽管如此,Rust1.44.1版编译器没有进行”重排序”,所以这段代码可以”工作”)。这仅仅是个用作学习的小练习。

  1. pub struct SynchronisedSum {
  2. shared: UnsafeCell<*const u32>,
  3. samples: usize,
  4. }
  5. impl SynchronisedSum {
  6. pub fn new(samples: usize) -> Self {
  7. assert!(samples < (u32::MAX as usize));
  8. Self {
  9. shared: UnsafeCell::new(std::ptr::null()),
  10. samples,
  11. }
  12. }
  13. pub fn generate(&self) {
  14. // do work on data this thread owns
  15. let data: Box<[u32]> = (0..self.samples as u32).collect();
  16. // publish to other threads
  17. let shared_ptr = self.shared.get();
  18. unsafe {
  19. shared_ptr.write_volatile(data.as_ptr());
  20. }
  21. std::mem::forget(data);
  22. }
  23. pub fn calculate(&self, expected_sum: u32) {
  24. loop {
  25. // check if the work has been published yet
  26. let shared_ptr = self.shared.get();
  27. let data_ptr = unsafe { shared_ptr.read_volatile() };
  28. if !data_ptr.is_null() {
  29. // the data is now accessible by multiple threads, treat it as an immutable reference.
  30. let data = unsafe { std::slice::from_raw_parts(data_ptr, self.samples) };
  31. let mut sum = 0;
  32. for i in (0..self.samples).rev() {
  33. sum += data[i];
  34. }
  35. // did we access the data we expected?
  36. assert_eq!(sum, expected_sum);
  37. break;
  38. }
  39. }
  40. }
  41. }

计算数组之和的函数从执行一个循环开始,这个循环里会读取共享指针的值。因为我们已知的原子存储保证所以read_volatile()只返回null或者一个指向u32slice的指针。我们不断地进行循环直到生成线程结束并且发布它的工作。一旦它被发布,我们就能读取到它并且计算元素的和。

测试代码

作为一个简单的测试,我们将要同时运行两个线程,一个用来生成值另一个用来计算总和。两个线程执行完各自的工作之后都会退出,我们通过使用join来等待它们退出。

  1. pub fn main() {
  2. print_arch();
  3. for i in 0..10_000 {
  4. let sum_generate = Arc::new(SynchronisedSum::new(512));
  5. let sum_calculate = Arc::clone(&sum_generate);
  6. let calculate_thread = thread::spawn(move || {
  7. sum_calculate.calculate(130816);
  8. });
  9. thread::sleep(std::time::Duration::from_millis(1));
  10. let generate_thread = thread::spawn(move || {
  11. sum_generate.generate();
  12. });
  13. calculate_thread
  14. .join()
  15. .expect(&format!("iteration {} failed", i));
  16. generate_thread.join().unwrap();
  17. }
  18. println!("all iterations passed");
  19. }

如果我在一个Intel的CPU上运行测试,我会得到下面的结果:

  1. running on x86_64
  2. all iterations passed

如果我在一个具有两个核的ARM CPU上运行测试,我会得到:

  1. running on aarch64
  2. thread '<unnamed>' panicked at 'assertion failed: `(left == right)`
  3. left: `122824`,
  4. right: `130816`', src\main.rs:45:17
  5. note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
  6. thread 'main' panicked at 'iteration 35 failed: Any', src\main.rs:128:9

X86处理器能够成功运行10000次测试,但是ARM处理器在第35次运行失败了。

哪里出问题了?

在我们执行最后的写入共享指针将其发布给其他线程之前,我们模式的正常运作要求我们正在进行的“工作(work)”在内存中处于正确的状态。ARM的内存模型不同于X86内存模型的地方在于ARM CPU将会对写入操作进行重排序,而X86不会。所以,计算线程能够看到一个非空(non-null)的指针并且在slice还没被写入之前就开始从其中读取值。对于我们程序中的大多数内存操作,我们想要给CPU足够的自由来重新整理操作从而使性能最大化。我们只想要指定最小的必要性约束来确保正确性。至于我们的generate函数, 我们想要slice中的值以任意能够带来最快速度的顺序写入内存。但是,所有的写入必须在我们把值写入共享指针之前完成。在calculate函数上正好相反。我们有一个要求,从slice内存中读取的值至少和共享指针中的值来自相同的时间点。尽管在对共享指针的读取完成之前不会执行这些指令,但我们需要确保不会从过期的缓存中得到这些值。

正确的版本

为了确保我们代码的正确性,对共享指针的写入必须使用release次序,并且由于calculate的读取顺序要求,我们使用acquire次序。我们对数据的初始化以及计算总和的代码都没有改变,我们想给CPU足够的自由以最高效的方式来运行。

  1. struct SynchronisedSumFixed {
  2. shared: AtomicPtr<u32>,
  3. samples: usize,
  4. }
  5. impl SynchronisedSumFixed {
  6. fn new(samples: usize) -> Self {
  7. assert!(samples < (u32::MAX as usize));
  8. Self {
  9. shared: AtomicPtr::new(std::ptr::null_mut()),
  10. samples,
  11. }
  12. }
  13. fn generate(&self) {
  14. // do work on data this thread owns
  15. let mut data: Box<[u32]> = (0..self.samples as u32).collect();
  16. // publish (aka release) this data to other threads
  17. self.shared.store(data.as_mut_ptr(), Ordering::Release);
  18. std::mem::forget(data);
  19. }
  20. fn calculate(&self, expected_sum: u32) {
  21. loop {
  22. let data_ptr = self.shared.load(Ordering::Acquire);
  23. // when the pointer is non null we have safely acquired a reference to the global data
  24. if !data_ptr.is_null() {
  25. let data = unsafe { std::slice::from_raw_parts(data_ptr, self.samples) };
  26. let mut sum = 0;
  27. for i in (0..self.samples).rev() {
  28. sum += data[i];
  29. }
  30. assert_eq!(sum, expected_sum);
  31. break;
  32. }
  33. }
  34. }
  35. }

如果我们在ARM CPU上运行使用了AtomicPtr更新后的版本,我们会得到:

  1. running on aarch64
  2. all iterations passed

次序的选择

在跨多个CPU进行工作的时候,使用atomic模块仍然需要注意。正如我们看到的X86和ARM汇编代码的输出,如果我们在store上使用Ordering::Relaxed来替换Ordering::Release,我们能回退到一个在x86上正确运行但是在ARM上会失败的版本。使用AtomicPtr尤其需要在最终访问指针指向的值的时候避免未定义行为。

延伸阅读

这只是对内存模型的一个简要介绍,希望对这个主题不熟悉的小伙伴们能有个清晰的认知。

  • ARM V-8内存模型细节[3]
  • Intel X86 内存模型细节[4]
  • Rust的atomic模块内存序引用[5]

我的第一篇介绍无锁编程的文章是这篇[6]。这篇文章看起来可能和内存模型不太相关,因为它是关于C++, Xbox360上的PowerPC CPU以及Windows API的一些细节。但是,它仍然是对这些原则的一个很好的解释。而且下面这段话从开始到现在都站得住脚:
无锁编程一种有效的多线程编程技术,但是不应该轻易使用。在使用它之前,你必须理解它的复杂性,并且你应该仔细评估以确保它真正能带来预期的益处。在很多情况下,应该使用更简洁高效的解决方案,比如更少地使用共享数据。

总结

希望我们已经了解了关于系统编程的一个新的方面,随着ARM芯片的越来越普及,这方面的知识会更加重要。确保原子性的代码从来都不简单,而当其跨不同架构下的不同内存模型时,就变得更加困难了。

参考资料

[1] X86 汇编:https://godbolt.org/z/uVQM8T
[2] ARM汇编:https://godbolt.org/z/wWQo8P
[3] ARM V-8内存模型细节:https://developer.arm.com/docs/100941/0100/the-memory-model
[4] Intel X86 内存模型细节:https://software.intel.com/content/www/us/en/develop/download/intel-64-and-ia-32-architectures-sdm-volume-3a-system-programming-guide-part-1.html
[5] Rust的atomic模块内存序引用:https://doc.rust-lang.org/std/sync/atomic/enum.Ordering.html
[6] 这篇:https://docs.microsoft.com/en-au/windows/win32/dxtecharts/lockless-programming?redirectedfrom=MSDN

https://mp.weixin.qq.com/s?__biz=MzU5MDQ5NTIyNg==&mid=2247483749&idx=1&sn=c778b43dada0cd5e7ccbb275f9ed2d22