【C# 线程】内存屏障 MemoryBarrier

 

背景

同步基元分为用户模式和内核模式

用户模式:Iterlocked.Exchange(互锁)、SpinLocked(自旋锁)、易变构造(volatile关键字、volatile类、Thread.VolatitleRead|Thread.VolatitleWrite)、MemoryBarrier。

内存屏障(英語:Memory barrier),也称内存栅栏内存栅障屏障指令等,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行, 也就是说在内存屏障之前的指令和之后的指令不会由于系统优化等原因而导致乱序。

大多数现代计算机为了提高性能而采取乱序执行,这使得内存屏障成为必须。

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。因此,对于敏感的程序块,写操作之后、读操作之前可以插入内存屏障。

内容来源:维基百科

 C#的内存屏障操作有哪些

Thread.MemoryBarrier、Volatile 变量、volatile 类、InterLocked 。这个结论是clr via C#4中可以得到答案P678\P677

MESI优化带来的可见性问题

 MESI协议,也就是缓存一致性协议。这个协议存在一个问题,就是当CPU0修改当前缓存的共享数据时,需要发送一个消息给其他缓存了相同数据的CPU核心,这个消息传递给其他CPU核心以及收到消息完成各自缓存状态的切换这个过程中,CPU会等待所有缓存响应完成,这样会降低处理器的性能。为了解决这个问题,引入了 StoreBufferes存储缓存。


处理器把需要写入到主内存中的值先写入到存储缓存中,然后继续去处理其他指令。当所有的CPU核心返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题。 如果某个CPU尝试将其他CPU占有的共享数据写入到内存,消息提交给store buffer以后,当前CPU继续做其他事情,而如果后面的指令依赖于这个被写入内存的最新数据(由于store buffer还没有写入到内存),就会产生可见性问题(也就是值还没有更新到内存中,这个时候读取到的共享数据的值是错误的)。

Store Bufferes带来的CPU内存的乱序访问导致的可见性问题

Store Bufferes中的数据何时写入到内存中是不确定的,那么意味着这个过程的执行顺序也是不确定的,比如下面这个例子 exeToCPU0和exeToCPU1分别在两个独立的cpu核心上执行,假如CPU0 缓存了 isFinish这个共享变量,并且状态为(E->独占),而value可能是(S共享状态被其他CPU核心修改以后变为I(失效状态)。 这种情况下value的缓存数据变更路径为, value将失效状态需要响应给触发缓存更新的CPU核心,接着该CPU将 StoreBufferes写入到内存,这就会导致value会比isFinish更迟的抛弃存储缓存。那么就可能出现CPU1读取到了isFinish的值为true,而value的值不等于10的情况。 这种CPU的内存乱序访问,会带来可见性问题。

复制代码
value = 3void exeToCPU0(){

 value = 10;

 isFinsh = true;

}

void exeToCPU1(){

 if(isFinsh){

   assert value == 10;

 }

}
复制代码

 

从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决,因此CPU层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier就是CPU flush store bufferes中的指令。软件层面可以决定在适当的地方来插入内存屏障。

CPU层面的内存屏障

什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。 X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)

  • Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的

  • Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的

  • Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题

复制代码
value = 3void exeToCPU0(){

 value = 10;

 storeMemoryBarrier(); //这个是一个伪代码,插入一个写屏障,使得value=10这个值强制写入到主内存中

 isFinsh = true;

}

void exeToCPU1(){

 if(isFinsh){

   loadMemoryBarrier();//伪代码,插入一个读屏障,使得cpu1从主内存中获得最新的数据

   assert value == 10;

 }

}
复制代码

 

总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性

总结:内存屏障 :从硬件层面来看这个 memroy barrier就是CPU flush store bufferes中的指令。内存屏障就是将 store bufferes中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。MemoryBarrier 解决Store Bufferes带来的CPU内存的乱序访问导致的可见性问题

内存屏障类型类型

  • 全内存屏障(mfence):barrier之前的load/store操作均比之后的先完成,且前后的指令不能共同参与指令重排序;
  • 读屏障(lfence):barrier之前的load比之后的load先完成;
  • 写屏障(sfence):barrier之前的store比之后的store先完成;

.net内存屏障类型

全屏障:Thread.MemoryBarrier()\Interlocked.MemoryBarrier(),编译器或cpu在给代码做优化时,不允许代码上下流动(以全屏障代码为基准)。

读屏障:volatile关键定义的变量,读取时候有读屏障功能。编译器或cpu在给代码做优化时,下面代码不能跑到上面,上面的代码可以跑下来(以读屏障代码为基准)。

写屏障: volatile关键定义的变量,写取时候有写屏障功能。编译器或cpu在给代码做优化时,上面的代码不能跑下来,下面的代码可以跑上去(以写屏障代码为基准)。

内存屏障主要有两个作用:

1、刷新写缓存:Thread.MemoryBarrie就是刷新store bufferes 使得数据同步到内存。
2、 阻止指令重排:编译器或clr cpu不能将Thread.MemoryBarrier() 前面代码,移动到他后面,也不允许它后面的代码 移到它前面。它就像一堵墙隔离了代码优化带来的代码移动。

 内存屏障 案例:

using System;
using System.Threading;
class Foo
{
    int _answer;
    bool _complete;

    void A()
    {
        _answer = 123;
        Thread.MemoryBarrier();    // Barrier 1刷新Store Bufferes使得_answer = 123这个值强制写入到主内存中
        _complete = true;
      Thread.MemoryBarrier(); // Barrier 2刷新Store Bufferes使得 _complete=true这个值强制写入到主内存中
    }

    void B()
    {
        Thread.MemoryBarrier();    // Barrier 3
        if (_complete)
        {
            Thread.MemoryBarrier();       // Barrier 4
            Console.WriteLine(_answer);
        }
    }
}

Barrier1 ,Barrier2刷新store bufferes 使得数据同步到内存,保证了其他cpu得到最新的数据。

Barrier3,Barrier4刷新store bufferes保证了_complete、_answer从内存中获取最新值。

“内存模型”和“超标量流水技术”。

C# 我们围绕MemoryBarrier来了解两个概念,“内存模型”和“超标量流水技术”。
内存模型是一个硬件概念,表示机器指令以什么样的顺序被处理器执行。
超标量流水线技术是指处理器中设置了一条以上的流水线,并且每时钟周期内可以并行完成多条指令的执行。
由于这种情况下指令的执行顺序并不是顺序执行的,所以处理器的内存模型分为“强顺序模型(Strong ordered) 和弱顺序模型(Weak ordered )”两种。常见的x86架构的处理器采用强顺序内存模型,而移动终端设备大量使用的ARMv7处理器则采用弱顺序内存模型。所以问题就来了,在移动平台上我们就没法保证QueuedWork = InQueuedWork;和DoWorkEvent->Trigger();这两句话的执行顺序了。好在对应的平台提供了“内存栅栏”的技术。
内存栅栏是一类同步指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。我们看下FPlatformMisc::MemoryBarrier()在不同平台下的实现:


Android和Linux:

FORCEINLINE static void MemoryBarrier()
{
    __sync_synchronize();
}

iOS和Mac:

FORCEINLINE static void MemoryBarrier()
{
    OSMemoryBarrier();
}

Windows:

void FGenericPlatformMisc::MemoryBarrier()
{
}

可以看出在ARMv7架构对应的平台都调用了对应的内存栅栏的接口,而基于x86的Windows平台就不需要担心这个问题,所以实现也为空。


如果对硬件架构感兴趣,可以对CISC和RISC做一些了解,计算机组成原理相关的知识,找一找相关的书籍阅读下。

posted @ 2021-12-26 17:09  小林野夫  阅读(1853)  评论(0编辑  收藏  举报
原文链接:https://www.cnblogs.com/cdaniu/