内存屏障 – MemoryBarrier[通俗易懂]

内存屏障 – MemoryBarrier[通俗易懂]处理器的乱序和并发执行目前的高级处理器,为了提高内部逻辑元件的利用率以提高运行速度,通常会采用多指令发射、乱序执行等各种措施。现在普遍使用的一些超标量处理器通常能够在一个指令周期内并发执行多条指令。处理器从L1I-Cache预取了一批指令后,就会分析找出那些互相没有关联可以并发执行的指令,然后送到几个独立的执行单元进行并发执行。比如下面这样的代码(假定编译器不做优化):  z

大家好,又见面了,我是你们的朋友全栈君。

处理器的乱序和并发执行

目前的高级处理器,为了提高内部逻辑元件的利用率以提高运行速度,通常会采用多指令发射、乱序执行等各种措施。现在普遍使用的一些超标量处理器通常能够在一个指令周期内并发执行多条指令。处理器从L1 I-Cache预取了一批指令后,就会分析找出那些互相没有关联可以并发执行的指令,然后送到几个独立的执行单元进行并发执行。比如下面这样的代码(假定编译器不做优化):

    z = x + y;
    p = m + n; 
CPU就有可能将这两行无关代码分别送到两个算术单元去同时执行。像Freescale的MPC8541这种嵌入式处理器一个指令周期能够加载4条指令、发射2条指令到流水线、用5个独立的执行单元来并发执行。

通常来说访存指令(由LSU单元执行)所需要的指令周期可能很多(可能要几十甚至上百个周期),而一般的算术指令通常在一个指令周期就搞定。所以有 可能代码中的访存指令耗费了多个周期完成执行后,其他几个执行单元可能已经把后面有多条逻辑上无关的算术指令都执行完了,这就产生了乱序。

另外访存指令之间也存在乱序的问题。高级的CPU可以根据自己Cache的组织特性,将访存指令重新排序执行。访问一些连续地址的可能会先执行,因为这时候Cache命中率高。有的还允许访存的Non-blocking,即如果前面一条访存指令因为Cache不命中,造成长延时的存储访问时,后面的访存指令可以先执行以便从Cache取数。对写指令的访存乱序有可能造成的错误后果,所以处理器通常有专门的机制(通常是做了个缓冲)保证在出现异常或者错误的时候,可以丢弃异常点后面的写指令的结果不做写入。

处理器的分支预测功能也能引起并发执行。处理器的分支预测单元有可能直接把两条分支的指令都预取来一块并发执行掉。等到分支判断的结果出来以后,再丢弃错误分支的计算结果。这样在很多情况下可以实现0周期跳转。比如这样的代码(假定编译器不做优化):

    z = x + y; 
    if (z > 0) then
    p = m + n;
    else
    p = m – n; 
看上去如果z不计算出来是无法继续的。但是实际上CPU有可能先把三个加法都同时进行计算,然后根据z=x+y的结果直接挑选正确的p值。

因此,即使是从汇编上看顺序正确的指令,其执行的顺序也是不可预知的。处理器能够保证并发和乱序执行不会得到错误结果,但是如果是对一些硬件寄存器的操作不能允许乱序的话,程序员就必须把这个情况告诉CPU。告诉的方法就是通过CPU提供的一组同步指令实现,通常在CPU的文档里面有对同步指令的使用说明。系统函数库里面的内存屏障(rmb/wmb/mb)实际上也是通过这些同步指令实现的。因此在C编码的时候,只要设置好内存屏障,就能告诉CPU 哪些代码是不能乱序的。

编译器的乱序优化

受到处理器预取单元的能力限制,处理器每次只能分析一小块指令的并发性,如果指令相隔比较远就无能为力了。但是从编译器的角度来看,编译器能够对很大一个范围的代码进行分析,能够从更大的范围内分辨出可以并发的指令,并将其尽量靠近排列让处理器更容易预取和并发执行,充分利用处理器的乱序并发功能。所以现代的高性能编译器在目标码优化上都具备对指令进行乱序优化的能力。并且可以对访存的指令进行进一步的乱序,减少逻辑上不必要的访存,以及尽量提高 Cache命中率和CPU的LSU(load/store unit)的工作效率。所以在打开编译器优化以后,看到生成的汇编码并不严格按照代码的逻辑顺序是正常的。和处理器一样,如果想要告诉编译器不要去对某些指令乱序优化,也要通过一些方式来告诉编译器。通常可以通过volatile关键字来抑制(注意,不是禁止)编译器对相关变量的访问优化。举个例子:

    int *p, *q; 
    ……; 
    *p = 1; 
    *p = 2; 
    *q = *p; 
这样,编译器通常会优化掉前面一个对*p的写入(逻辑上冗余),仅对*p写入2。而对*q赋值的时候,编译器认为此时*q的结果就应该是上次*p的值,会优化掉从*p取数的过程,直接把在寄存器中保存的*p的值给*q(PowrPC汇编):

(假设r3=p,r4=q) 
    li r5, 2 // r5赋值2 
    stw r5, 0(r3) // 把r5写到*p 
    stw r5, 0(r4) // 把r5写到*q 
但是如果为p指针加上了volatile关键字,情况就不同了:

    volatile int *p; 
    int *q; 
    ……; 
    *p = 1; 
    *p = 2; 
    *q = *p; 
在这种情况下,编译器看见*p是volatile的时候,就会:

不对*p操作生成乱序指令(通常如此,具体请看后面的解释)

每次从*p取数据的时候,一定会进行一次访存操作,哪怕前面不久才取过*p的值放在寄存器里。

不合并对*p的写操作(也只是通常如此,解释见后)

所以这回的结果如下(PowrPC汇编):

(假设r3=p,r4=q) 
    li r5, 1 // r5赋值1 
    stw r5, 0(r3) // 把r5写到*p 
    li r5, 2 // r5赋值2 
    stw r5, 0(r3) // 把r5写到*p 
    lwz r5, 0(r3) // 从*p取值到r5 
    stw r5, 0(r4) // 把r5写到*q 
这样编译器会在汇编码级别保证指令有序和不优化掉访存操作。通常简单地使用volatile关键字就可以解决编译器的乱序问题,但是这些指令到了处理器执行的时候,仍然可能被乱序。对于处理器乱序执行的避免就需要用到一组内存屏障函数(barrier)了。

重要 

绝大多数的编译器,通常不会优化掉对volatile对象的访问,并且通常保持同一个volatile对象的一系列读写操作是有序的(但是不能保证不同的volatile对象之间有序)。

但是,这不是绝对的。因为ANSI C99标准关于对volatile对象访问时编译器是否要绝对保证禁止乱序(reorder)和禁止访问合并(combine access)并没有做任何规定!仅仅是鼓励编译器最好不要去优化对volatile对象的访问,而唯一的强制要求仅仅是要求编译器保证对 volatile对象的访问优化不会跨越“sequence point”即可(所谓sequence point是指一些诸如外部函数调用、条件或循环跳转等关键点,具体定义请查阅C99标准内的详细说明)。

这就是说,如果一个编译器在两个sequence point之间像对待普通变量一样去优化volatile变量,也是完全符合C99标准的!比如:

   volatile int a;

    if (…) { … } // sequence point
    a = 1;
    a = 2;
    a = 3;
    printk(“…”); // sequence point 
在两个sequence point之间,要是有编译器对a的赋值操作合并(即仅写入3)或者乱序(如写1和写2对调),都是完全符合C99标准的。所以,我们在使用的时候,不能指望用了volatile以后绝对能生成有序的完整的汇编码,即不要指望volatile来保证访存有序。实质上 volatile最大的作用主要还是在保证每次使用从内存中取值,而并不能保证编译器不做其他任何优化(毕竟volatile从字面上看意思是“易变”而不是“有序”。编译器只保证对volatile对象即时更新但不保证访问有序也不是说不过去的)。

从另一个角度看,即使是编译器生成的汇编码有序,处理器也不一定能保证有序。就算编译器生成了有序的汇编码,到了处理器那里也拿不准是不是会按照代码顺序执行。所以就算编译器保证有序了,程序员也还是要往代码里面加内存屏障才能保证绝对访存有序,这倒不如编译器干脆不管算了,因为内存屏障本身就是一个sequence point,加入后已经能够保证编译器也有序。

因此,对于切实是需要保障访存顺序的代码,就算当前使用的编译器能够编译出有序的目标码来,我们也还是必须通过设置内存屏障的方式来保证有序,否则都是不严谨,有隐患的。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/157925.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

  • Javac编译器详解「建议收藏」

    Javac编译器详解「建议收藏」 转载请注明出处:坦GA 前端编译器:把*.java文件转变成*.class文件 后端运行期编译器(JIT编译器,JustInTimeCompiler):把字节码转成机器码 静态提前编译器(AOT编译器,AheadOfTimeCompiler):把*.java编译成本地机器码 前端编译器:Sun的Javac、EclipseJDT中的增量式编辑器(ECJ) JIT编译器:H…

  • 十进制小数转二进制小数方法

    十进制小数转二进制小数方法十进制小数转二进制小数方法转自:http://www.cnblogs.com/upzone/articles/1389365.html十进制小数→→→→→二进制小数 方法:“乘2取整”对十进制小数乘2得到的整数部分和小数部分,整数部分既是相应的二进制数码,再用2乘小数部分(之前乘后得到新的小数部分),又得到整数和小数部分.如此不断重复,直到小数部分为0或达到精度要求为止

  • DHCP协议介绍

    DHCP协议介绍DHCP(DynamicHostConfigurationProtocol:动态主机设置协议)DHCP是一个局域网协议DHCP是应用UDP协议的应用层协议DHCP的作用主要是为临时加入局域网的设备提供一个临时的IP地址DHCP协议提供了即插即用联网的功能,使得我们的设备在不同的地方时(例如:家、公司、户外等),不需要自行地配置IP地址就可以进行网络的连接,只要用了DHCP协议,就可以在不同的地方使用不同的网络如下图中,如果配置自动获得IP地址,就相当于启用DHCP协议,会允许我们自动地从

  • c++ 中map 的find 用法[通俗易懂]

    c++ 中map 的find 用法[通俗易懂]用find函数来定位数据出现位置,它返回的一个迭代器,当数据出现时,它返回数据所在位置的迭代器,如果map中没有要查找的数据,它返回的迭代器等于end函数返回的迭代器,程序说明#include<map>#include<string>#include<iostream>Usingnamespacestd;Intmain(){      Map&lt…

    2022年10月14日
  • 初中英语语法(008)-动词不定式

    初中英语语法(008)-动词不定式动词不定式动词不定式(台版叫不定词)是非限定动词,不受主词的人称和数的限制,一般由to+原形动词构成,有时可不带to或不可带to。动词不定式的“不定”主要体现在“身份”不定,也就意味着它在句子中所充当的成分不定。1、动词不定式可以充当哪些成分1、名词可以代替名词属性,所以也就可以充当主语,宾语,表语和补语。例如:(1)Tobuythingsinthismarketmu…

  • javascript实现表单提交加密「建议收藏」

    javascript实现表单提交加密「建议收藏」通常表单的提交有两种方式,一是直接通过html的form提交,代码如下:<formaction=""method=""id="forms"><inputtype="text"name="username"value=""/><inputtype="password&quot

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号