理解memory barrier

理解memory barrier

本文只适合粗看,从不同层面上过一过相关概念,请不要带脑子。

细看请参考其他更优秀的文章。

1 为什么会有memory barrier?

要理解memory barrier必须先知道memory ordered(内存顺序)的概念,
接触过一点底层架构的同学大概知道,x86是strongly-ordered,
arm是weakly-ordered。
但实际上这种说法都是不准确的,比如386的x86就真的是严格的strongly-ordered,
但目前大家使用的x86架构并不会保证load-store序列。

memory ordered又和speculative execution(预测执行)有极大关系。

程序员脑子里CPU执行的方式是,(假设为PModel)

  1. 读取PC指向的指令
  2. PC++ 或者 PC = target(如果是跳转指令)
  3. 执行读取到的指令
  4. 重复1~3

但真实的硬件都是一次读取一批指令,但 由于存在内存操作这类极度耗时的指令,所以PModel简单方式会存在大量的CPU空闲时间
为了提高硬件利用率,在CPU空闲时间里,speculative execution模块就会工作提前执行后面的指令。

1 int port, action;
2 void forbar(int base)
3 {
4   action = 4;
5   port = base + 3;
6 }

比如以上这个简单的函数,在line 4时CPU需要把常量4存放到action所在位置的内存中,
这时候如果CPU按照PModel的方式则必须等内存操作执行结束后才能执行 base+3 这个
纯计算的操作(速度很快),然后再把计算结果存放到port所在地址的内存中。
因此效率更高的方式是先把没有数据依赖的操作提前进行(当PC指针还没变动时),等
实际要执行这条指令的时候就可以快速得到结果。

但遇到存储相关的指令时这种方式会就存在两个问题。

  1. 乱序执行乱序执行是指CPU为了更好的效率,不按program ordered(即程序员脑子里的逻辑顺序)执行,
    而是先执行line 5然后执行line 4, 一般我们会说这个只要是在单核系统下是没问题的。
    但实际上有没有问题不取决于有几个核,而在于理解问题的本质。
    比如action和port并非是内存地址里的数据,而是硬件设备的地址或者即使是内存地址的数据
    但涉及到DMA区域,则会出现至少两个观察者。一个是CPU本身,一个是磁盘驱动等外设(实际也
    可以理解为是一个CPU)。
  2. 多核并发执行其他文章里有很详细的解释这种 CPU即使是按照program ordered来执行的,但如果存在
    多个CPU同时执行且都操作了action或者port这些地址,则一样会出现各种错误。
    从而出现了各种lock机制来定义临界区避免多个实体同时操作了临界区所保护的地址区域。

今天我们说到的memory barrier实际只涉及到如何处理乱序的问题。

CPU设计者选择了不同的方式来缓解这个问题(不是解决,因为有充足的理由让程序员面临这些问题)。

2 memory barrier的种类

软件层面来说,很多地方都有定义memory barrier api,最早(应该)的是Java memory
model对此进行了详细的定义,然后c++11也做出了详细的定义。
此外gtk,qt,erlang,gcc,Go等语言实现或框架API都有定义各种的barrier api。

硬件层面来说,所有的CPU都会有准确的memory model描述,说明了在哪里情况下会出现哪些乱序执行。

我们先不考虑具体的软件或具体的硬件情况,仅仅从逻辑上考虑这个问题。
最简单的模型就是只简化内存操作为Store(内存存储)和Load(内存读取)这两个操作,因此2×2可以组合出
至少以下4种序列。

  • Store, Load (SL)
  • Store, Store (SS)
  • Load, Load (LL)
  • Load, Store (LS)

如果某个barrier可以保证Store,Load 不被乱序 执行,则我们称其为SL memory barrier(SLB)。
类似的我们可以定义出SSB, LLB, LSB。

拿之前的例子来看,为了保证执行顺序,我们可以插入一个SSB。

int port, action;
void forbar(int base)
{
  action = 4;  __asm__("SSB");  port = base + 3;
}

这样我们就可以保证两个store是按顺序执行的。

c++11以及erlang定义的模型比这个要更复杂,大概有以下

  • full memory barrier(mb)
  • release memory barrier(relb)
  • acquire memory barrier(acqb)
  • write memory barrier(wb)
  • read memory barrier(rb)
  • data dependency memory barrier(ddrb)

是不是有点懵。。
要理解这些模型得先把我们上面的简单模型再细化一下,虽然存储指令只有store和load两种,
两两组合也确实是4种,但我们实际指令里的序列长度可不是2,而是非常非常大的。
因此实际的规范并不是用SLB, SSB这种简单方式而是根据更抽象的语义来定义memory barrier
(SLB,SSB这种一般是在讨论CPU级别的barrier时出现)。

比如relb这个的语义是release write memory barrier,要保护的序列是
“anymemops, relb, action”这种序列,这个anymemops可以是多个store和load的任意组合,可以保证
在执行关键操作前一定可以得到最新的内存信息。

而acqb就是acquire load memory barrier,要保护的序列是
“action, acqb, anymemops”,可以保证执行关键操作后的anymemops是基于最新的内存数据。

顺便提一句,这个action往往就是各种atomic operation,但并非一定是。这也是memory barrier容易
与原子操作混淆的一个原因。

硬件设计者为了效率所以就让程序员必须得处理所有的乱序组合吗? 并不是的。

比如x86下我们只会遇到操作内存时(IO)的load, store被乱序执行了,其他的一些序列是由CPU来确保不会乱序的。

CPU内部是通过类似replay trap(不知道怎么翻译,属于DEC alpha内的术语)机制来实现硬件上的barrier。
也就是实际上所有的store,load都会被乱序执行(并不准确),但CPU内部在”预取指”模块里会做类似这种操作。

  1. 若正在执行的是一条load/store指令,
  2. 则,检测指令buffer里后面的指令是否是store/load。
  3. 若是且检测到乱序情况则将正在执行的指令终止掉,重新进行指令读取操作。

步骤3是一个非常影响性能的操作。
但整个过程实际上还涉及到load/store的具体地址是否相同等维度的数据,因此整个组合也并非简单的2×2=4种
情况。

类似Java, C++等为了移植性无法绑定到一个具体的平台,但为了效率又想尽量能把内部结构与实际硬件一一对应起来。
因此软件规范上只能尽可能把经常遇到的模式都抽象出来定义出一个即灵活又完整的barrier api。

那有么有简单一点的方式呢? 有

  1. 软件上,任何时候都暴力的使用full barrier,也就是不细分barrier类型,全部同步。
  2. 硬件上,也不要去管什么lfence,sfence全部暴力的使用full barrier。

实际上不论是软件还是硬件大部分实现上都没有规范里说的那么细化。
比如gtk,Go的atomic相关操作全部都是full barrier语义,alpha也只提供了一个full barrier的指令,
x86-64也是在sse2之后才有细分的非full barrier指令。

插入一个kernel里的概念,kernel里对barrier的定义是分SMP和非SMP前缀的,区别是在逻辑上
前者保证的是CPU之间的范围(cache,内存),后者是保证整个系统(除了前者还有外设等等)。虽然实际
上他们在某些硬件下是完全相等的。

再插入一个概念,C语言里的volatile和这里讨论的是没有关系的,它的作用只是让compiler不要做乱序处理,也就是
其到一个compiler memory barrier的作用。而Java里的volatile则是会转换到实际的CPU指令上。

相关概念很容易混淆,在不理解时一定要提醒下自己barrier为什么出现,以及去理解这个barrier所处理的问题范围是什么
(cache,还是memory,还是device,还是虚拟机内的),以及要保护的是什么操作序列。

3 对国产架构的影响

了解这块主要是因为在移植erlang到申威架构时遇到了类似问题,因此整理了一下这方面的概念。

x86在硬件上已经提供了很强的barrier保证了,而其他几个CPU都只提供了很弱的barrier。
因此造成国产架构下一个非常普遍的问题。
很多软件在x86下工作的好好的,但移植到飞腾(arm),龙芯(mips),申威上就出现各种各样的问题。
有些时候并非这些CPU的问题,而是他们的内存模型不同。

因此在一些驱动代码里,虽然代码都是和相同的外设打交道,但不同CPU下却有不同结果,可能就是
原始代码是在x86下编写测试的,驱动代码又是大量和内存以及设备
寄存器进行交互的,很多bug是没法在x86下触发的,但在其他平台下就很容易触发了。

而仅仅我个人就至少遇到了飞腾,申威,龙芯在这方面的实际问题。
举一个能说的例子,
龙芯官网的FAQ上有提到3A/3B的ll指令存在缺陷必须在前面放一个sync指令(实际就是一个产生full barrier的指令),但
在deepin上用gcc的atomic builtin来生成acqb,relb,mb等情况下的操作时候会发现

  1. 不论什么barrier类型,后面都会被强制生成一个sync指令。
  2. 部分ll指令前会出现两个sync指令。

问题1会导致所有的relb语言上升到了full barrier,问题2会额外增加一个sync等待操作。

Created: 2018-12-19 Wed 15:54

Validate

 

3 条思考于 “理解memory barrier

    1. xiabin 文章作者

      内存模型主要定义 *CPU乱序执行* 相关的特征,memory barrier是提供给程序员/编译器来*阻止*CPU乱序执行的工具。(一般通过ISA的特定指令来实现)。在这个范围内可以认为它和原子操作没有任何关系。 

      原子操作仅仅是保证 load A, A++, store A 这类序列是事务性的,也就是这个整体所体现出的逻辑一定是不可分割的(相对于其他观察者)。

      粗犷的实现方式一般是在原子操作序列的前面和后面都放置一个full memory barrier(gtk,Go)。因为原子操作指令本身无法保证*输入的读取*或”结果的存放*不会被乱序。(具体加什么栅栏取决于实际场景,比如原子load操作其实就不用关系store类型的乱序)

      原子操作: cmpxchg, ll/st
      内存栅栏: mfence, memb

    2. xiabin 文章作者

      具体拿 qt来说 http://doc.qt.io/qt-5/qatomicint-members.html
      qt的原子操作是把op和barrier放在同一个维度来(相对于gcc的__atomic builtin是把两者分开,barrier作为一个参数传递给op)

      testAndSet,fetchAndRelease这些就是具体的原子op。
      每个op后面还有各种Acquired,Ordered,Release等不同的barrier效果。

      不同后缀的主要区别就是在实际的原子op前或后插入了不同的barrier,以便更小粒度的阻止乱序行为。(以便尽可能减少性能损失)

      而gtk和Go可以理解为性能最差的方式(但对程序员的负担更小),也就是所有的原子操作全都是full barrier的(也就是qt里的ordered)

发表评论

电子邮件地址不会被公开。 必填项已用*标注