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

799 阅读10分钟

使用Atomic写一个多线程程序

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

仅X86模式下的实现

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

pub struct SynchronisedSum {
    shared: UnsafeCell<*const u32>,
    samples: usize,
}

impl SynchronisedSum {
    pub fn new(samples: usize-> Self {
        assert!(samples < (u32::MAX as usize));
        Self {
            shared: UnsafeCell::new(std::ptr::null()),
            samples,
        }
    }

    pub fn generate(&self) {
        // do work on data this thread owns
        let dataBox<[u32]> = (0..self.samples as u32).collect();

        // publish to other threads
        let shared_ptr = self.shared.get();
        unsafe {
            shared_ptr.write_volatile(data.as_ptr());
        }
        std::mem::forget(data);
    }

    pub fn calculate(&self, expected_sum: u32) {
        loop {            
            // check if the work has been published yet
            let shared_ptr = self.shared.get();
            let data_ptr = unsafe { shared_ptr.read_volatile() };
            if !data_ptr.is_null() {
                // the data is now accessible by multiple threads, treat it as an immutable reference.
                let data = unsafe { std::slice::from_raw_parts(data_ptr, self.samples) };
                let mut sum = 0;
                for i in (0..self.samples).rev() {
                    sum += data[i];
                }

                // did we access the data we expected?
                assert_eq!(sum, expected_sum);
                break;
            }
        }
    }
}

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

测试代码

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

pub fn main() {
    print_arch();
    for i in 0..10_000 {
        let sum_generate = Arc::new(SynchronisedSum::new(512));
        let sum_calculate = Arc::clone(&sum_generate);
        let calculate_thread = thread::spawn(move || {
            sum_calculate.calculate(130816);
        });
        thread::sleep(std::time::Duration::from_millis(1));
        let generate_thread = thread::spawn(move || {
            sum_generate.generate();
        });

        calculate_thread
            .join()
            .expect(&format!("iteration {} failed", i));
        generate_thread.join().unwrap();
    }
    println!("all iterations passed");
}

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

running on x86_64
all iterations passed

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

running on aarch64
thread '<unnamed>' panicked at 'assertion failed: `(left == right)`
  left: `122824`,
 right: `130816`', src\main.rs:45:17
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
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足够的自由以最高效的方式来运行。

struct SynchronisedSumFixed {
    shared: AtomicPtr<u32>,
    samples: usize,
}

impl SynchronisedSumFixed {
    fn new(samples: usize-> Self {
        assert!(samples < (u32::MAX as usize));
        Self {
            shared: AtomicPtr::new(std::ptr::null_mut()),
            samples,
        }
    }

    fn generate(&self) {
        // do work on data this thread owns
        let mut dataBox<[u32]> = (0..self.samples as u32).collect();

        // publish (aka release) this data to other threads
        self.shared.store(data.as_mut_ptr(), Ordering::Release);

        std::mem::forget(data);
    }

    fn calculate(&self, expected_sum: u32) {
        loop {
            let data_ptr = self.shared.load(Ordering::Acquire);

            // when the pointer is non null we have safely acquired a reference to the global data
            if !data_ptr.is_null() {
                let data = unsafe { std::slice::from_raw_parts(data_ptr, self.samples) };
                let mut sum = 0;
                for i in (0..self.samples).rev() {
                    sum += data[i];
                }
                assert_eq!(sum, expected_sum);
                break;
            }
        }
    }
}

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

running on aarch64
all iterations passed

次序的选择

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

延伸阅读

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

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

我的第一篇介绍无锁编程的文章是这篇[4]。这篇文章看起来可能和内存模型不太相关,因为它是关于C++, Xbox360上的PowerPC CPU以及Windows API的一些细节。但是,它仍然是对这些原则的一个很好的解释。而且下面这段话从开始到现在都站得住脚:

无锁编程一种有效的多线程编程技术,但是不应该轻易使用。在使用它之前,你必须理解它的复杂性,并且你应该仔细评估以确保它真正能带来预期的益处。在很多情况下,应该使用更简洁高效的解决方案,比如更少地使用共享数据。

总结

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

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

Rust碎碎念
Rust碎碎念

参考资料

[1]

ARM V-8内存模型细节: https://developer.arm.com/docs/100941/0100/the-memory-model

[2]

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

[3]

Rust的atomic模块内存序引用: https://doc.rust-lang.org/std/sync/atomic/enum.Ordering.html

[4]

这篇: https://docs.microsoft.com/en-au/windows/win32/dxtecharts/lockless-programming?redirectedfrom=MSDN