指令引用了 内存 该内存不能为read 一直弹窗_【翻译】使用Rust测试ARM和X86内存模型

dd708fe5e59474359b95424691fed35f.png
原文标题: The Story of Tail Call Optimizations in Rust 原文标题: 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) -> (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 汇编,我们会看到前三个函数产生了相同的代码。直到更加严格的SeqCst次序,我们才得到一个生成的不同的指令集。

example::test_write:mov     dword ptr [rdi], 58retexample::test_atomic_relaxed:mov     dword ptr [rdi], 58retexample::test_atomic_release:mov     dword ptr [rdi], 58retexample::test_atomic_consistent:mov     eax, 58xchg    dword ptr [rdi], eaxret

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

example::test_write:mov     w8, #58str     w8, [x0]retexample::test_atomic_relaxed:mov     w8, #58str     w8, [x0]retexample::test_atomic_release:mov     w8, #58stlr    w8, [x0]retexample::test_atomic_consistent:mov     w8, #58stlr    w8, [x0]ret

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

使用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 ownslet data: Box<[u32]> = (0..self.samples as u32).collect();// publish to other threadslet 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 yetlet 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`', srcmain.rs:45:17
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' panicked at 'iteration 35 failed: Any', srcmain.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 ownslet mut data: Box<[u32]> = (0..self.samples as u32).collect();// publish (aka release) this data to other threadsself.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 dataif !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内存模型细节 - Intel X86 内存模型细节 - Rust的atomic模块内存序引用

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

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

总结

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

0e9366ac65f5e86050521a4be745920e.gif

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/370533.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

npm升级依赖包_Taro跨端开发之依赖管理

昨天跑的好好项目,今天跑不起来我们在开发周期比较长的前端项目的时候,必然会遇到依赖管理的问题. 我们在开发项目的时候,我们用了大量的三方库.这些三方的依赖库时不时的会更新自己的代码.第三方依赖库的代码更新会很容易造成代码运行的不稳定, 比如昨天还跑的好好的项目,另一…

设计模式 建造者模式 与 Spring Bean建造者 BeanDefinitionBuilder 源码与应用

建造者模式 定义: 将一个复杂对象的构建与它的表示分离&#xff0c;使得同样的构建过程可以创建不同的表示主要作用: 在用户不知道对象的建造过程和细节的情况下就可以直接创建复杂的对象如何使用: 用户只需要给出指定复杂对象的类型和内容, 建造者模式负责按顺序创建复杂对象…

java 布隆过滤器_什么是布隆过滤器(Bloom Filter)?

在日常工作中&#xff0c;有一个比较常见的需求&#xff0c;就是需要判断一个元素是否在集合中。例如以下场景&#xff1a;给定一个IP黑名单库&#xff0c;检查指定IP是否在黑名单中&#xff1f;在接收邮件的时候&#xff0c;判断一个邮箱地址是否为垃圾邮件&#xff1f;在文字…

STM32上使用JSON

一、STM32工程中添加JSON 最近在一网2串项目&#xff0c;串口和网口之间可能需要定义一下简单的通信协议&#xff0c;而通信协议上则需要去定义一下通信的数据格式&#xff0c;上次听剑锋说要用Json来定义&#xff0c;目前查了下资料具体如何去应用还不 会。因为最新的KEIL上支…

Flex 学习

Flex案例一&#xff1a; 1 <html>2 <head>3 <meta http-equiv"Content-Type" content"text/html; charsetutf-8" /> 4 <title>无标题</title>5 <style type"text/css">6 body,h1,h2,h3,h4,…

Cocos2d-X中实现自己定义菜单处理事件

当用户点击再松开后才会响应菜单事件&#xff0c;而在游戏中有些游戏须要玩家点击后就处理事件。如玩坦克大战的时候&#xff0c;玩家是点击一下就发射子弹。并是点击松手后发射子弹&#xff0c;在Cocos2d-X中没有这样的消息。以下就通过自己定义的方式实现当用户点击后就调用处…

java linkedhashset_java之LinkedHashSet

LinkedHashSet是Set集合的一个实现&#xff0c;具有set集合不重复的特点&#xff0c;同时具有可预测的迭代顺序&#xff0c;也就是我们插入的顺序。并且linkedHashSet是一个非线程安全的集合。如果有多个线程同时访问当前linkedhashset集合容器&#xff0c;并且有一个线程对当前…

Jmeter 场景设计

今天的业务场景是&#xff1a; 1.管理员登录后台---登录成功后添加一个某类型的产品---产品添加成功后&#xff0c;再为该产品添加10个排期。 2.管理员登录后台--登录成功后添加多个不同类型产品---产品全部添加完成后&#xff0c;依次为所有产品添加10个排期。 这是两种不同的…

Android IPC机制(五)用Socket实现跨进程聊天程序

1.Socket简介 Socket也称作“套接字“&#xff0c;是在应用层和传输层之间的一个抽象层&#xff0c;它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用以实现进程在网络中通信。它分为流式套接字和数据包套接字&#xff0c;分别对应网络传输控制层的TCP和UDP协议。TCP协…

java获取byte 长度_java获取字节的长度.

我们经常要获取中文,数字,或者英文字符所占字节的长度,下面就列出各种编码格式下所占字节的长度:代码如下:package pack.java.midea.dao;import java.io.UnsupportedEncodingException;/*** 测试;* author zhouhaitao* 2012-5-17*/public class Test {/*** param args* throws …

Batoo JPA –比领先的JPA提供商快15倍

介绍 我早在2000年代就喜欢JPA 1.0。 我甚至在稳定版本发布之前就将其与EJB 3.0一起使用。 我非常喜欢它&#xff0c;因此我为JBoss 3.x实现贡献了一些零碎的部分。 那时我们公司规模还很小。 创建新功能和应用程序比性能更重要&#xff0c;因为我们有很多想法&#xff0c;我…

python软件是哪个国家的品牌_有哪些好用的软件被国人误认为是外国研发的?

国产软件被标榜上了英文&#xff0c;即便不是英文&#xff0c;用拼音写出来&#xff0c;也会有人误认为是国外的软件。因为这样可以显得高大上&#xff0c;为什么我们会有这样的想法&#xff0c;是崇洋媚外吗&#xff0c;并不是&#xff0c;而是之前的国产软件的确有不少让我们…

postgres 支持的线程数_线程池被打满了怎么处理呢,你是否真的了解线程池?

0、前言线程池&#xff0c;顾名思义就是线程的池子&#xff0c;在每次需要取线程去执行任务的时候&#xff0c;没必要每次都创建新线程执行&#xff0c;线程池就是起着维护线程的作用&#xff0c;当有任务的时候就取出一个线程执行&#xff0c;如果任务执行完成则把线程放回到池…

[树形DP]没有上司的舞会

题目链接 思考 首先本题中的关系是一种树形结构&#xff0c;而且符号最优子结构和无后效性&#xff0c;所以可以进行记忆化搜索。 那么首先要在这颗树中选出一个点作为根节点&#xff0c;按照习惯我们将没有父节点的点作为根节点。 接下来要思考的是 状态&#xff1a; dp[i][0…

mybatis通用mapper_全网最全Mapper解析,附实操代码帮你更好理解

今天给大家介绍一位老朋友当你第一次接触Java开发的时候&#xff0c;这个老朋友就和你形影不离&#xff0c;当你要进行ORM的时候&#xff0c;单表的增删改查&#xff0c;这位老朋友给了你极大的帮助&#xff0c;不知道你想到他了吗&#xff1f;对&#xff0c;这就是通用mapper&…

初尝微信小程序2-基本框架

基本框架&#xff1a; .wxml &#xff1a;页面骨架 .wxss &#xff1a;页面样式 .js &#xff1a;页面逻辑 描述一些行为 .json &#xff1a;页面配置 创建一个小程序之后&#xff0c;app.js,app.json,app.wxss是必须的&#xff0c;而且名字也不能随意更改&#xff0c;…

洛谷 P1795 无穷的序列_NOI导刊2010提高(05)

P1795 无穷的序列_NOI导刊2010提高&#xff08;05&#xff09; 题目描述 有一个无穷序列如下&#xff1a; 110100100010000100000… 请你找出这个无穷序列中指定位置上的数字 输入输出格式 输入格式&#xff1a;第一行一个正整数N&#xff0c;表示询问次数&#xff1b; 接下来的…

此服务器的时钟与主域控制器的时钟不一致_中移动“超高精度时间同步服务器”开标,两家中标...

8月25日&#xff0c;中国移动发布《2020年至2022年同步网设备集中采购_中标候选人公示》公告。两家中标。同步网技术比较小众&#xff0c;但是同步网是5G承载网的重要一环&#xff0c;分享一下&#xff0c;供大家参考。中标情况 标包1-时钟同步设备中标候选人依次排序为&#x…

java 异常管理员_GitHub - kangZan/JCatch: Exception异常管理平台,支持Java、PHP、Python等多种语言...

什么是JCatch当程序发生异常(Exception)&#xff0c;处理方式一般是通过日志文件记录下来&#xff0c;这种方式很容易被忽略&#xff0c;而且查询起来比较麻烦。JCatch提供了一种方案&#xff0c;当程序发生异常时&#xff0c;通过JCatch平台接口提交到JCatch平台&#xff0c;由…

oled

gnd、vcc、clk、miso、rst、mosi、cs 转载于:https://www.cnblogs.com/scrazy/p/7892733.html