内存栅障简介

如果运行平台的共享内存模型是确定的,则遵照这个模型编写的多线程程序库可以在支持这个运行平台的任何底层平台上正确的运行。但是,有时算法(尤其是无锁算法)需要我们自己实现某一种特定的内存操作的语义以保证算法的正确性。这时我们就需要显式的使用一些指令来控制内存操作指令的顺序以及其可见性定义。这种指令称为内存栅障(内存栅栏)。

 

我们刚才提到了。内存一致性模型需要在各种的程序与系统的各个层次上定义内存访问的行为。在机器码与的层次上,其定义将影响硬件的设计者以及机器码开发人员;而在高级语言层次上,其定义将影响高级语言开发人员以及编译器开发人员和硬件设计人员。即,内存操作的乱序在各个层次都是存在的。这里,所谓的程序的执行顺序有三种:

 

(1)程序顺序:指在特定CPU上运行的,执行内存操作的代码的顺序。这指的是编译好的程序二进制镜像中的指令的顺序。编译器并不一定严格按照程序的顺序进行二进制代码的编排。编译器可以按照既定的规则,在执行代码优化的时候打乱指令的执行顺序,也就是上面说的程序顺序。并且,编译器可以根据程序的特定行为进行性能优化,这种优化可能改变算法的形式与算法的执行复杂度。(例如将switch转化为表驱动序列)

(2)执行顺序:指在CPU上执行的独立的内存相关的代码执行的顺序。执行顺序和程序顺序可能不同,这种不同是编译器和CPU优化造成的结果。CPU在执行期(Runtime)根据自己的内存模型(跟编译器无关)打乱已经编译好了的指令的顺序,以达到程序的优化和最大限度的资源利用。

(3)感知顺序:指特定的CPU感知到他自身的或者其他CPU对内存进行操作的顺序。感知顺序和执行顺序可能还不一样。这是由于缓存优化或者内存优化系统造成的。

(参见:Memory Ordering in Modern Microprocessors)

而最终的共享内存模型的表现形式是由这三种“顺序”共同决定的。即从源代码到最终执行进行了至少三个层次上的代码顺序调整,分别是由编译器和CPU完成的。我们上面提到,这种代码执行顺序的改变虽然在单线程程序中不会引发副作用,但是在多线程程序中,这种作用是不能够被忽略的,甚至可能造成完全错误的结果。因此,在多线程程序中,我们有时需要人为的限制内存执行的顺序。而这种限制是通过不同层次的内存栅障完成的。

注:在以前我们没有提到不同层次的内存栅障而实际上,如果您在使用Visual Studio 2005/2008,您就会发现API中出现了_ReadBarrier, _WriteBarrier, _ReadWriteBarrier等等,看看MSDN得知这是内存栅障,可是这是什么层次上的内存栅障呢?

 

严格意义上来讲,内存栅障仅仅应用于硬件层次而并非软件。即,内存栅障并不是对于编译器而言的。但是由于编译器更改代码顺序的现象确实存在,因此又引入了编译器内存栅障的概念。以下给出其定义:

  • 编译器内存栅障指,编译器保证在内存栅障两侧的代码不会跨越内存栅障,但是不能够阻止CPU改变代码的执行顺序。

  • 内存栅障指,一系列强制促使CPU按照一定顺序,在其两侧按照一致性规则执行内存指令的指令。

 以Visual C++ 8.0/9.0编译器为例。编译器规则中规定,Visual C++编译器有权利对声明为volatile的变量的操作调整其顺序以达到优化的效果,因此,在Platform SDK中引入了编译器内存栅障——_ReadBarrier(),_WriteBarrier(),_ReadWriteBarrier()。恰当的使用这些函数可以确保在多线程模式下,代码的执行顺序不会因为编译器优化的原因而更改。否则,优化之后的程序行为可能会被改变。可见,这种优化仅仅是对于编译器一级而言的。而并不保证执行过程。

 

所以,在"Loads are not reorderd with other loads" is a FACT!! 再续:.NET MM IS BROKEN ,才有了这样的代码:

 

static long InterlockedExchange(volatile long* ptr, long value)

{

  long result;

  volatile long* p = ptr;

  __asm

  {

    __asm mov edx, p

    __asm mov eax, value

    __asm lock xchg [edx], eax

    __asm result, eax

  }

  load_with_acquire(*ptr); 

  return result;

}

template <typename T>

static long load_with_acquire(const T& ref)

{

  long to_return = ref;

  #if (_MSC_VER >= 1300)

  _ReadWriteBarrier();

  #endif

  return to_return;

}

 

为什么在InterlockedExchange中还需要load_with_acquire呢?原文中的注释欠考虑了,实际上是因为阻止编译器对 voltaile进行优化的原因。而如果编译器保证volatile不会进行顺序调整,例如Intel编译器也就不用再来一次load_with_acquire了。读者可能要问了,那么如果_ReadWriteBarrier仅仅是编译器一级的,那么执行顺序不就变化了吗?厄,单纯对于这个load是不会的,因为这个代码是IA-32下的,CPU已经保证了load的语义,只要编译器不擅自改变就OK了。

 

而真正的内存栅障是硬件一级的。是采用了CPU提供的某些特定的指令。例如,在Microsoft Platform SDK中,MemoryBarrier宏即该类型的内存栅障。其定义如下(在x86,x64,IA64平台下):

 

#ifdef _AMD64_

#define MemoryBarrier __faststorefence

#endif

#ifdef _IA64_

#define MemoryBarrier __mf

#endif

// x86

FORCEINLINE

VOID

MemoryBarrier(void)

{

    LONG Barrier;

    __asm {

        xchg Barrier, eax

    }

}

 

通过以上说明可见,如果希望得到正确的内存操作顺序,就需要在程序中恰当的使用软件的或者真正的内存栅障。内存栅障使用过度,会造成程序性能比较严重的下降(因为CPU的内存操作顺序优化和Cache优化不能发挥作用);而使用不当则会造成非常隐蔽而难以调试的错误。

 

[END:内存栅障]

 

三、Cache的一致性

 

在“之一”中,有一句话:“CPU 1操作了内存单元1进而操作了内存单元2,但是另一个CPU先看到了内存单元2的改变而后又看到了内存单元1的改变”需要指出的是:这个在大多数处理器中都是不存在的,大多数处理器保证了这种顺序的可见性与操作顺序是一致的。(对于Intel处理器,参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.4)

 

在大多数有关多线程的文章中,都会提到由于内存的优化,以至于可见性...需要澄清的是,这种可见性的差异在大多数情况下都不是Cache造成的。应该说是Memory Optimization造成的还不错。因为正因为为了避免Cache的不一致,才有了Cache一致性协议的研究。才有了Intel关于我的Cache采用了四种状态云云,实际上就是所谓的采用了总线监听协议。

 

我们在"Loads are not reorderd with other loads" is a FACT!! 系列文章中,通过了一个例子说明.NET MM是有问题的,但是如果Cache都没问题,那么到底是什么造成的这个问题呢?答案是,Store Buffer!!我们先来看看Cache的工作:

 

如果CPU发现系统内存的操作数是可以被缓存的(并非所有的内存单元都是可以被缓存的),CPU便将一个Cache Line全部读到恰当的缓存中L1,L2或者L3(如果有的话)。该操作称为Cache Line Fill。如果CPU发现需要的操作数已经存在在Cache中,则CPU直接从缓存中而不是内存中读取操作数,这种情况称之为Cache Hit。

当CPU试图向可缓存的系统内存写入操作数时,其首先检查Cache中是否已经缓存了该操作数。如果一个有效地(Valid)Cache Line的确存在在缓存中,CPU(根据当前写操作数的策略)可以将操作数写入Cache中而不是系统内存中。这种操作称之为Write Hit。反之,如果缓存中并不包含该操作数的地址,则CPU执行一次Cache Line Fill操作,并将操作数写入缓存,同时(根据当前写操作数的策略)可以将操作数写回系统内存。如果真的要将操作数写回内存,则其首先写回存储缓冲区(Store Buffer),然后等待总线空闲,并从存储缓冲区(Store Buffer)回写到内存中。(参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.2)

 

好了,现在我们知道Store Buffer是什么东西了。如果没有Store Buffer那么我们必须等待总线空闲再将操作数写回内存,但是现在,即使总线不是空闲的Cache回写的操作也能立即返回!但是,当一个Store临时的处于Store Buffer时,他满足了自身处理器的Load操作但是不能满足其他处理器的Load操作。也就是,此时如果其他CPU需要Load这个地址,则他不能看到新的值!这不是因为Cache不一致造成的,但是现象好像是Cache不一致造成的!(参见:Intel 64 Architecture Memory Ordering White Paper,2.4)

 

因此,我们说,如果要修正这个错误就要Flush Store Buffer,而不是Flush Cache!而如何Flush Store Buffer呢?列举如下:

 

当一个CPU异常或者中断产生的时候;

当一个顺序执行指令执行的时候;

当一个I/O指令执行的时候;

当一个LOCK操作执行的时候;

当一个BINIT操作执行的时候;

当使用LFENCE限制操作的调整的时候;

当使用MFENCE限制操作调整的时候;

(参见:Intel 64 and IA-32 Architectures Software Developer's Manual,Volume 3A: System Programming Guide,10.10)

 

而我们知道,System.Threading.Thread.MemoryBarrer()实际上就是一个xchg(对于IA-32),属于一个LOCK操作,因此具有Flush Store Buffer的功能!因此我们说System.Threading.Thread.MemoryBarrer()可以修正这个错误,但是原因并不是Cache,而是Store Buffer!

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值