【C#】通过一个案例 彻底了解 Volatile和 内存屏障

案例如下的。我个人理解是不会出现出现0,0的结果,但是很明显出现了。

说明对我对 Volatile\内存屏障\乱序排序的理解是不对。

今天就通过这个案例,理清这些概念。

using System;
using System.Threading;
using System.Threading.Tasks;

namespace MemoryBarriers
{
    class Program
    {
        static volatile int x, y, a, b;
        static void Main()
        {
            while (true)
            {
                var t1 = Task.Run(Test1);
                var t2 = Task.Run(Test2);
                Task.WaitAll(t1, t2);
                if (a == 0 && b == 0)
                {
                    Console.WriteLine("{0}, {1}", a, b);
                }
                x = y = a = b = 0;
            }
        }

        static void Test1()
        {
            x = 1;
           //方案一,只用一个 Interlocked.MemoryBarrierProcessWide();test2不需要添加内存屏障。问题就可以解决
//方案二 Interlocked.MemoryBarrier(); 为什么不用这个内存屏障,即使添加了也还是会出现,必须同时在test、和test2中同时添加 a = y; } static void Test2() { y = 1;

//方案二 Interlocked.MemoryBarrier(); b = x; } } }

对这个案例我提出几个问题:

1、为什么不用interlocked.MemoryBarrier(),它和Interlocked.MemoryBarrierProcessWide();有什么区别

2、 即使在test1中添加了Interlocked.MemoryBarrier()也还是会出现(0,0)的结果,虽然输出结果过程很慢,只有同时在test、和test2中添加才能完全杜绝这个问题。

3、为什么会出现0,0的结果

带着这几个疑问开始搜索答案,首先这些问题和多任务、多核、多线程有关系,所以应该从缓存一致性入手。

先给答案

问题在于无序执行。让我们看一下 Test1 的反汇编代码。如果你不熟悉x86组装,不用担心,它其实很简单,我已经为相关行添加了注释。

#MemoryBarriers.Program.Test1()
    #function prolog ommitted
    L0015: mov dword ptr [rax+8], 1  # 上传1到x的内存空间
    L001c: mov edx, [rax+0xc]        # 将y值加载到寄存器
    L001f: mov [rax+0x10], edx.      # 将y寄存器中的值,上传到变量a的内存空间 
    L0022: add rsp, 0x28.           
    L0026: ret

cpu访问寄存器是1个时钟周期,而访问内存要106个时钟周期,所以用上传和下载来替代写入和读取 显得更合理。

为了从变量y中读取值并将其分配给另一个内存位置a,我们必须将y读取到CPU寄存器中,在这种情况下,edx仅用于此目的,然后我们才能将y分配给目标变量a。

    作为开发人员,您正在开发一个应用程序,假设您有一些独立的上传下载操作到某些Web服务。您将如何设计此类呼叫?您可以并行化它们以节省时间!这正是CPU的作用。 CPU足够聪明,可以弄清楚这些上传和下载操作不会在每个线程上相互影响,并且为了节省时间,这意味着这些指令的执行顺序可以根据哪一个先完成而改变。因此乱序执行。

   然而,我们已经知道我们的代码并没有像我们想要的那样工作。因为cpu没有按我们的做出的假设执行 。这个假设只是基于每个线程的依赖项检查。不幸的是,CPU在决定指令独立性时不能考虑多线程,对于这种情况,我们必须手动帮助它。

 y=1是释放语义。它允许后面的读指令在它前面执行。b=x分成两条指令 一条是【读取x值】  一条是【将x寄存器中的值赋值给y】。所以CPU会优化一下将【读取x值】的指令移动到【 y=1】指令之前执行 。这直接导致b=0;同样的道理得出a=0;

 解决方案

我们已经看到,那些来自波动的半"内存屏障"是没有帮助的。那么我们能做些什么呢?加入全内存屏障内存屏障是针对 CPU 的特殊锁定指令,禁止指令跨屏障重新排序。因此,该程序的行为是意料之中的,但作为缺点,速度将慢数十纳秒。

在我们的示例中,我注释了一行:

   //Interlocked.MemoryBarrierProcessWide();

如果取消注释该行,程序将按预期工作。但这是一个相当奇特的呼吁。.NET 中的实际内存障碍是通过以下方式发出的:

   Interlocked.MemoryBarrier();

但是,如果您只是使用MemoryBarrier,您仍然会看到0,0的出现速度较慢!这样做的原因是,由于我们有两个线程,对于实际的解决方案,我们必须在这两个函数中设置两个内存屏障。只有这样,程序才能正常运行。然而,作为开发人员,在现实生活中的项目中,你能确定这样的第3个方法不存在吗?使用相同变量的此类代码可能隐藏在不同的库中,我们可能没有意识到这一点。因此,Interlocked.MemoryBarrierProcessWide()是你从轨道按钮上获得的核武器。它确保这些变量的这种重新排序永远不会成为"进程范围"的问题。话虽如此,需要格外小心。我预计,这将是超级慢的。不过,我会采用慢代码而不是错误的代码。不过,如果您可以完全控制代码,则首选单个内存屏障而不是进程范围的内存屏障(或锁定关键字本质上执行相同的操作)。

 =======================================================扩展学习  摸索的过程 摸索了2天=====================================

1、缓存一致性

在分析问题之前必须对缓存一致性有完全理解,并且知道MESI协议的工作原理和通信消息。我这里简要罗列以下:

 缓存一致性协议有MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol等等

inter芯片采用MESI协议。

MESI消息的类型,有如下几种:

  • Read : 请求消息,用于通知其他处理器、主内存,当前处理器准备读取某个数据。该消息内包含待读取数据的主内存地址。

  • Read Response: 响应消息,该消息内包含了被请求读取的数据。该消息可能是主内存返回的,也可能是其他高速缓存嗅探到Read 消息返回的。

  • Invalidate: 请求消息,通知其他处理器删除指定内存地址的数据副本。其实就是告诉他们你这个缓存条目内的数据无效了,删除只是逻辑上的,其实就是更新下缓存条目的Flag.

  • Invalidate Acknowledge: 响应消息,接收到Invalidate消息的处理器必须回复此消息,表示已经删除了其高速缓存内对应的数据副本。

  • Read Invalidate: 请求消息,此消息为Read 和 Invalidate消息组成的复合消息,作用主要是用于通知其他处理器当前处理器准备更新一个数据了,并请求其他处理器删除其高速缓存内对应的数据副本。接收到该消息的处理器必须回复Read Response 和 Invalidate Acknowledge消息。

  • Writeback: 请求消息,消息包含了需要写入主内存的数据和其对应的内存地址。

详细请查看:https://www.cnblogs.com/cdaniu/p/15758916.html

2、MESI协议带来的问题

MESI协议带来的问题

1、MESI协议的消息在cpu的多core之间通信需要消耗时间,导致内核在此期间将无事可做。甚至一旦某一个内核发生阻塞,将会导致其他内核也处于阻塞,从而带来性能和稳定性的极大消耗。

 

2、MESI协议状态切换需要时间。

为了解决这两个问题,引入了store buffer和invalidate queue

  • store buffer:将cpu的写入先保存这个堆栈中。等到得到其他cpu已经更新缓存行的消息后,再将数据写入缓存。
  • invalidate queue:当前cpu的缓存收到其他cpu缓存行状态变更的消息时就回复Acknowledge 消息,不用等当前缓存行更新完后在回复其他cpu缓存信息、并且当前cpu的缓存下设置一个invalidate queue。用户存储其他cpu的缓存发过来的信息,等到有空再处理。

Store Buffere---存储缓存器

store buffer即存储缓存。也是同常所说的写缓存(WriteBuffer)位于内核和缓存之间。当处理器需要处理将计算结果写入在缓存中处于shared状态的数据时,需要通知其他内核将该缓存置为 Invalid(无效),引入store buffer后将不再需要处理器去等待其他内核的响应结果,只需要把修改的数据写到store buffer,通知其他内核,然后当前内核即可去执行其它指令。当收到其他内核的响应结果后,再把store buffer中的数据写回缓存,并修改状态为M。(很类似分布式中,数据一致性保障的异步确认)

Invalidate Queue---失效队列

简单说处理器修改数据时,需要通知其它内核将该缓存中的数据置为Invalid(失效),我们将该数据放到了Store Buffere处理。那收到失效指令的这些内核会立即处理这种失效消息吗?答案是不会的,因为就算是一个内核缓存了该数据并不意味着马上要用,这些内核会将失效通知放到Invalidate Queue,然后快速返回Invalidate Acknowledge消息(意思就是尽量不耽误正在用这个数据的内核正常工作)。后续收到失效通知的内核将会从该queue中逐个处理该命令。(意思就是我也不着急用,所以我也不着急处理)。

我们继续接着聊。

存储转发(Store Fowarding)

通过上面内容我们知道了有了写缓冲器后,处理器在写数据时直接写入缓冲器就直接返回了。

那么问题就来了,当我们写完一个数据又要马上进行读取可咋办呢?话不多说,咱们还是举个例子来说,如图:

 

 

此时第一步处理器将变量S的更新后的数据写入到写缓冲器返回,接着马上执行了第二布进行S变量的读取。由于此时处理器对S变量的更新结果还停留在写缓冲器中,因此从高速缓存缓存行中读到的数据还是变量S的旧值。

为了解决这种问题,存储转发(Store Fowarding)这个概念上线了。其理论就是处理器在执行读操作时会先根据相应的内存地址从写缓冲器中查询。如果查到了直接返回,否则处理器才会从高速缓存中查找,这种从缓冲器中读取的技术就叫做存储转发。看图:

 

再解决这两个问题后,指令重排开始发挥它的价值。想想这种等待有时是没有必要的,因为在这个等待时间内内核完全可以去干一些其他事情。即当内核处于等待状态时,不等待当前指令结束接着去处理下一个指令。

 由于写缓冲器和无效化队列的出现,处理器的执行都变成了异步操作。缓冲器是每个处理器私有的,一个处理器所存储的内容是无法被其他处理器读取的。

但是这时候又带来另一个问题。内存重排序和可见性的问题

 

 当cpu0要写数据到本地cache的时候,如果不是M或者E状态,需要发送一个invalidate消息给cpu1,只有收到cpu1的acknowledgement才能写数据到cache中,在这个过程中cpu0需要等待,这大大影响了性能。一种解决办法是在cpu和cache之间引入store buffer,当发出invalidate之后直接把数据写入store buffer。当收到acknowledgement之后可以把store buffer中的数据写入cache。现在的架构图是这样的:

 

 

 3、内存重排序和可见性的问题

 

由于store buffer和无效化队列的出现,处理器的执行都变成了异步操作。缓冲器是每个处理器私有的,一个处理器所存储的内容是无法被其他处理器读取的。

举个例子:

CPU1 更新变量到store buffer中,而CPU2因为无法读取到CPU1 store buffer内容所以从高速缓存中读取的仍然是该变量旧值。

其实这就是store buffer导致StoreLoad重排序问题,而store buffer还会导致StoreStore重排序问题等。

为了使一个处理器上运行的线程对共享变量所做的更新被其他处理器上运行的线程读到,我们必须将store buffer的内容写到其他处理器的高速缓存上,从而使在缓存一致性协议作用下此次更新可以被其他处理器读取到。

处理器在store buffer满、I/O指令被执行时会将store buffer中的内容写入高速缓存中。但从变量更新角度来看,处理器本身无法保障这种更新的”及时“性。为了保证处理器对共享变量的更新可被其他处理器同步,编译器等底层系统借助一类称为内存屏障的特殊指令来实现。

内存屏障中的存储屏障(Store Barrier)会使执行该指令的处理器将store buffer内容写入高速缓存。

内存屏障中的加载屏障(Load Barrier)会根据无效化队列内容指定的内存地址,将相应处理器上的高速缓存中相应的缓存条目状态标记为I。

 

四、内存屏障

因为说了存储屏障(Store Barrier)和加载屏障(Load Barrier) ,所以这里再简单的提下内存屏障的概念。

划重点:(你细品)

处理器支持哪种内存重排序(LoadLoad重排序、LoadStore重排序、StoreStore重排序、StoreLoad重排序),就会提供相对应能够禁止重排序的指令,而这些指令就被称之为内存屏障(LoadLoad屏障、LoadStore屏障、StoreStore屏障、StoreLoad屏障)

划重点:

如果用X和Y来代替Load或Store,这类指令的作用就是禁止该指令左侧的任何 X 操作与该指令右侧的任何 Y 操作之间进行重排序(就是交换位置),确保指令左侧的所有 X 操作都优先于指令右侧的Y操作。

内存屏障的具体作用:

屏障名称示例具体作用
StoreLoad Store1;Store2;Store3;StoreLoad;Load1;Load2;Load3 禁止StoreLoad重排序,确保屏障之前任何一个写(如Store2)的结果都会在屏障后任意一个读操作(如Load1)加载之前被写入
StoreStore Store1;Store2;Store3;StoreStore;Store4;Store5;Store6 禁止StoreStore重排序,确保屏障之前任何一个写(如Store1)的结果都会在屏障后任意一个写操作(如Store4)之前被写入
LoadLoad Load1;Load2;Load3;LoadLoad;Load4;Load5;Load6 禁止LoadLoad重排序,确保屏障之前任何一个读(如Load1)的数据都会在屏障后任意一个读操作(如Load4)之前被加载
LoadStore Load1;Load2;Load3;LoadStore;Store1;Store2;Store3 禁止LoadStore重排序,确保屏障之前任何一个读(如Load1)的数据都会在屏障后任意一个写操作(如Store1)的结果被写入高速缓存(或主内存)前被加载

内存屏障中的加载屏障(Load Barrier)会根据无效化队列内容指定的内存地址,将相应处理器上的高速缓存中相应的缓存条目状态标记为I。
X86中有三种内存屏障:
Store Memory Barrier:写屏障,等同于前文的StoreStore Barriers 将store buffer都写入缓存。
告诉处理器在执行这之后的指令之前,执行所有已经在存储缓存(store buffer)中的修改(M)指令。即:所有store barrier之前的修改(M)指令都是对之后的指令可见。


Load Memory Barrier:读屏障,等同于前文的LoadLoad Barriers 将Invalidate的 都执行完成。
告诉处理器在执行任何的加载前,执行所有已经在失效队列(Invalidte Queues)中的失效(I)指令。即:所有load barrier之前的store指令对之后(本核心和其他核心)的指令都是可见的。
Full Barrier:万能屏障,即Full barrier作用等同于以上二者之和。将将store buffer都写入缓存并且将Invalidate的 都执行完成
即所有store barrier之前的store指令对之后的指令都是可见的,之后(本核心和其他核心)的指令也都是可见的,完全保证了数据的强一致性。

内存屏障的用法案例解说:

 cpu0cache里面有个b,初值为0,cpu1cache有个a,初值为0,现在cpu0运行foo, cpu1运行bar

    void foo(void) 
    { 
    a = 1; 
    smp_mb(); //内存屏障
    b = 1; 
    } 
    void bar(void) 
    { 
    while (b == 0) continue; 
    smp_mb(); //内存屏障
                             
    assert(a == 1); 
    } 


在assert之前插入内存屏障,作用是把invalidate queue标记下,在读取下面的数据的时候,譬如a的时候会先把invalidate queue中的消息都处理掉,这里的话会使得a失效而去cpu0获取最新的数据。

进而我们知道smp_mb有两个作用,1,标记store buffer,在处理之后的写请求之前需要把store buffer中的数据apply到cache,2,标记invalidate queue,在加载之后的数据之前把invalidate queue中的消息都处理掉

 进而我们再观察上面的例子,我们发现,在foo中我们不需要处理invalidate queue,而在bar中,我们不需要处理store buffer,我们可以使用一种更弱的内存屏障来修改上例让我们程序的性能更高,smp_wmb写屏障,只会标记store buffer,smp_rmb读屏障,只会标记invalidate queue,代码如下:

    void foo(void) 
    { 
    a = 1; 
    smp_wmb(); //写屏障
    b = 1; 
    } 
    void bar(void) 
    { 
    while (b == 0) continue; 
    smp_rmb(); //读屏障
    assert(a == 1); 

 

 

 内存屏障的问题

CPU知道什么时候需要加入内存屏障,什么时候不需要吗?CPU将这个加入内存屏障的时机交给了程序员。在java中这个加入内存屏障的命令就是volatile关键字。
澄清一点,volatile并不是仅仅加入内存屏障这么简单,加入内存屏障只是volatile内核指令级别的内存语义。
除此之外:volatile还可以禁止编译器的指令重排,因为JVM为了优化性能并且不违反happens-before原则的前提下也会进行指令重排。

 

易失性(volatile)关键字

该关键字指示编译器在每次从该字段读取时生成一个获取围栏,并在每次写入该字段时生成一个释放围栏。获取栅栏可防止其他读取/写入操作在栅栏之前移动;释放栅栏可防止其他读取/写入操作在栅栏之后移动。这些"半围栏"比完整围栏更快,因为它们为运行时和硬件提供了更大的优化空间。volatile

碰巧的是,英特尔的 X86 和 X64 处理器始终对读取应用获取栅栏,对写入应用释放栅栏 (无论您是否使用关键字 ), 因此,如果您使用这些处理器,则此关键字对硬件没有影响。但是,它确实对编译器和 CLR 执行的优化以及 64 位 AMD 和(在更大程度上)安腾处理器有影响。这意味着,由于客户端运行特定类型的 CPU,您要用volatile关键字。

 

 volatile字段的应用效果可以总结如下:

第一条指令第二条指令它们可以交换吗?
否(CLR 确保永远不会交换写写操作,即使没有关键字)volatile
是的!

 参考:

揭开易失性关键字|的神秘面纱奥努尔·古姆斯的博客 (onurgumus.github.io)

C# - 理论与实践中的 C# 内存模型,第 2 部分 | Microsoft Docs

C# 中的线程处理 - 第 4 部分 - 高级线程处理 (albahari.com)

hwViewForSwHackers.pdf (puppetmastertrading.com)

posted @ 2022-01-08 18:19  小林野夫  阅读(1190)  评论(0编辑  收藏  举报
原文链接:https://www.cnblogs.com/cdaniu/