揭开内存屏障的面纱

推荐阅读(强烈推荐)c++标准库内存屏障的使用

一 什么是内存屏障   

    内存屏障(英语:Memory barrier),也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。

    每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。因此大多数现代计算机为了提高性能而采取乱序执行。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。

    如何解决以上的问题?那就是内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样.语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。 

   大多数处理器提供了内存屏障指令:

1) 完全内存屏障(full memory barrier)保障了早于屏障的内存读写操作的结果提交到内存之后,再执行晚于屏障的读写操作。
2) 内存读屏障(read memory barrier)仅确保了内存读操作;
3) 内存写屏障(write memory barrier)仅保证了内存写操作。

  很简单的一个例子

二、as-if-serial语义
     As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。

int a = 1;
int b = 2;
int c = a + b;

    对a赋值1,对b赋值2,取a的值,取b的值 ,将取到两个值相加后存入c 在上面5个动作中,在编译器编译后,由于编译器的优化或者cpu的乱序执行,动作1可能会和动作2、4 重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序,动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系,一旦重排,as-if-serial语义便无法保证。看下面的例子

// thread 1
while (!ok);
do(x);
 
// thread 2
x = 42;    // #1
ok = 1;    // #2

    ok 初始化为 0,线程 1 等待 ok 被设置为 1 后执行 do 函数。由于#1和#2并没有所谓的数据依赖关系,假如线程2编译器对 #1和#2写重排序,那么在线程1读到下的值时,并不一定是我们期望的42。

  如何保证我们读到的x值是我们期望的呢?首先我们来看几个问题

 1)多线程编程与内存可见性
     多线程程序通常使用高层程序设计语言中的同步原语,如Java与.NET Framework,或者API如pthread或Windows API。因此一般不需要明确使用内存屏障。

 2)内存可见性问题

    主要是高速缓存与内存的一致性问题。一个处理器上的线程修改了某数据,而在另一处理器上的线程可能仍然使用着该数据在专用cache中的老值,这就是可见性出了问题。解决办法是令该数据为volatile属性,或者读该数据之前执行内存屏障。

 3) 乱序执行与编译器重排序优化的比较
      C与C++语言中,volatile关键字意图允许内存映射的I/O操作。这要求编译器对此的数据读写按照程序中的先后顺序执行,不能对volatile内存的读写重排序。因此关键字volatile并不保证是一个内存屏障。对于Visual Studio 2003,编译器保证对volatile的操作是有序的,但是不能保证处理器的乱序执行。因此,可以使用InterlockedCompareExchange或InterlockedExchange函数。对于Visual Studio 2005及以后版本,编译器对volatile变量的读操作使用acquire semantics,对写操作使用release semantics。对于Visual Studio 2003,编译器保证对volatile的操作是有序的,但是不能保证处理器的乱序执行。因此,可以使用InterlockedCompareExchange或InterlockedExchange函数。对于Visual Studio 2005及以后版本,编译器对volatile变量的读操作使用acquire semantics,对写操作使用release semantics。

4)volatile 与内存屏障

     上面我们已经提到了,指令的乱序可能由两个原因造成 编译器编译时的优化和处理器执行时的乱序优化,对于编译器编译时的优化导致的乱序我们可以用通过volatile标记,可以解决编译器层面的可见性与重排序问题,而内存屏障则解决了运行时的硬件层面的可见性与重排序问题(注:这里所指的是c/c++中的volatile关键字,在java中,jvm对volatile做了很大一部分的强化,保证编译时的乱序和执行时的处理器的乱序,其原理就是在volatile前后插入相应的内存屏障来实现的:深入理解 Java 内存模型——volatile)。

 三 创建内存屏障

     Linux 内核提供函数 barrier() 用于让编译器保证其之前的内存访问先于其之后的完成。内核实现 barrier() 如下(X86-64 架构):#define barrier() __asm__ __volatile__("" ::: "memory")
现在把此编译器 barrier 加入代码中:

// thread 1
while (!ok);
do(x);
 
// thread 2
x = 42;    // #1
__asm__ __volatile__("" ::: "memory");
ok = 1;    // #2

     这样我们就保正我们读到的x是我们的期望值了,另外再c++11 标准库中提供了上面提到的不同内存屏障的创建方法:atomic_thread_fence(memory_order __m),具体使用方法(见文章末尾,建议把整篇文章都看了,了解不同的内存序很重要,可以更好的理解不同的内存屏障):C++11的6种内存序总结

参考文章

  C++ 多线程与内存模型资料汇

  C和C++中的volatile、内存屏障和CPU缓存一致性协议MESI

  理解Memory Barrier(内存屏障)

  JVM内存模型、指令重排、内存屏障概念解析

  一文解决内存屏障

  维基百科内存屏障

  聊聊原子变量、锁、内存屏障那点事

 

你可能感兴趣的:(c++,linux,c/c++学习)