• volatile的内存屏障的坑


    请看下面的代码并尝试猜测输出:

    可能一看下面的代码你可能会放弃继续看了,但如果你想要彻底弄明白volatile,你需要耐心,下面的代码很简单!

    在下面的代码中,我们定义了4个字段x,y,a和b,它们被初始化为0
    然后,我们创建2个分别调用Test1和Test2的任务,并等待两个任务完成。
    完成两个任务后,我们检查a和b是否仍为0,
    如果是,则打印它们的值。
    最后,我们将所有内容重置为0,然后一次又一次地运行相同的循环。

    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();
                a = y;
            }
    
            static void Test2()
            {
                y = 1;
                b = x;
            }
        }
    

    如果您运行上述代码(最好在Release模式下运行),则会看到输出为0、0的许多输出,如下图。

    image

    我们先根据代码自我分析下

    在Test1中,我们将x设置为1,将a设置为y,而Test2将y设置为1,将b设置为x
    因此这4条语句会在2个线程中竞争
    罗列下可能会发生的几种情况:

    1. Test1先于Test2执行:

    x = 1
    a = y
    y = 1
    b = x
    

    在这种情况下,我们假设Test1在Test2之前完成,那么最终值将是

    x = 1,a = 0,y = 1,b = 1
    

    2. Test2执行完成后执行Test1:

    y = 1 
    b = x
    x = 1
    a = y
    

    在这种情况下,那么最终值将是

    x = 1,a = 1,y = 1,b = 0
    

    2. Test1执行期间执行Test2:

    x = 1
    y = 1
    b = x
    a = y
    

    在这种情况下,那么最终值将是

    x = 1,a = 1,y = 1,b = 1
    

    3. Test2执行期间执行Test1

    y = 1
    x = 1
    a = y
    b = x
    

    在这种情况下,那么最终值将是

    x = 1,a = 1,y = 1,b = 1
    

    4. Test1交织Test2

    x = 1
    y = 1
    a = y
    b = x
    

    在这种情况下,那么最终值将是

    x = 1,a = 1,y = 1,b = 1
    

    5.Test2交织Test1

    y = 1
    x = 1
    b = x
    a = y
    

    在这种情况下,那么最终值将是

    x = 1,a = 1,y = 1,b = 1
    

    我认为上面已经罗列的
    已经涵盖了所有可能的情况,
    但是无论发生哪种竞争情况,
    看起来一旦两个任务都完成,
    就不可能使a和b都同时为零,
    但是奇迹般地,居然一直在打印0,0 (请看上面的动图,如果你怀疑的话代码copy执行试试)

    image

    真相永远只有一个

    先揭晓答案:cpu的乱序执行

    让我们看一下Test1和Test2的IL中间代码。
    我在相关部分中添加了注释。

    #ConsoleApp9.Program.Test1()
        #function prolog ommitted
        L0015: mov dword ptr [rax+8], 1   # 把值 1 上传到内存地址 'x'
        L001c: mov edx, [rax+0xc]         # 从内存地址 'y' 下载值并放到edx(寄存器)
        L001f: mov [rax+0x10], edx.       # 从(edx)寄存器把值上传到内存地址 'a'
        L0022: add rsp, 0x28.           
        L0026: ret
    
    #ConsoleApp9.Program.Test2()
        #function prolog
        L0015: mov dword ptr [rax+0xc], 1  # 把值 1 上传到内存地址 'y'
        L001c: mov edx, [rax+8].           # 从内存地址 'x' 下载值并放到edx(寄存器) 
        L001f: mov [rax+0x14], edx.        # 从(edx)寄存器把值上传到内存地址 'b'
        L0022: add rsp, 0x28
        L0026: ret
    

    请注意,我在注释中使用“上载”和“下载”一词,而不是传统的读/写术语。
    为了从变量中读取值并将其分配到另一个存储位置,
    我们必须将其读取到CPU寄存器(如上面的edx),
    然后才能将其分配给目标变量。
    由于CPU操作非常快,因此与在CPU中执行的操作相比,对内存的读取或写入真的很慢。
    所以我使用“上传”和“下载”,相对于CPU的高速缓存而言【读取和写入内存的行为】
    就像我们向远程Web服务上载或从中下载一样慢。

    以下是各项指标(2020年数据)(ns为纳秒)

    L1 cache reference: 1 ns
    L2 cache reference: 4 ns
    Branch mispredict: 3 ns
    Mutex lock/unlock: 17 ns
    Main memory reference: 100 ns
    Compress 1K bytes with Zippy: 2000 ns
    Send 2K bytes over commodity network: 44 ns
    Read 1 MB sequentially from memory: 3000 ns
    Round trip within same datacenter: 500,000 ns
    Disk seek: 2,000,000 ns
    Read 1 MB sequentially from disk: 825,000 ns
    Read 1 MB sequentially from SSD: 49000 ns

    由此可见 访问主内存比访问CPU缓存中的内容慢100倍

    如果让你开发一个应用程序,实现上载或者下载功能。
    您将如何设计此?肯定想要开多线程,并行化执行以节省时间!
    这正是CPU的功能。CPU被我们设计的很聪明,
    在实际运行中可以确定某些“上载”和“下载”操作(指令)不会互相影响,
    并且CPU为了节省时间,对它们(指令)进行了(优化)并行处理,
    也叫【cpu乱序执行】(out-of-order)

    上面我说道:在实际运行中可以确定某些“上载”和“下载”操作(指令)不会互相影响,
    这里有一个前提条件哈:该假设仅基于基于线程的依赖性检查进行(per-thread basis dependency checks)。
    虽然在单个线程是可以被确定为指令独立性,但CPU无法考虑多个线程的情况,所以提供了【volatile关键字】

    我们回到上面的示例,尽管我们已将字段标记为volatile,但感觉上没有起作用。为什么?

    一般说道volatile我都一般都会举下面的例子(内存可见性)

    using System;
    using System.Threading;
    public class C {
        bool completed;
        static void Main()
        {
          C c = new C();
          var t = new Thread (() =>
          {
            bool toggle = false;
            while (!c.completed) toggle = !toggle;
          });
          t.Start();
          Thread.Sleep (1000);
          c.completed = true;
          t.Join();        // Blocks indefinitely
        }
    }
    
    

    如果您使用release模式运行上述代码,它也会无限死循环。
    这次CPU没有罪,但罪魁祸首是JIT优化。

    你如果把:

    bool completed;
    

    改成

    volatile bool completed;
    

    就不会死循环了。
    让我们来看一下[没有加volatile]和[加了volatile]这2种情况的IL代码:

    没有加volatile

    L0000: xor eax, eax
    L0002: mov rdx, [rcx+8]
    L0006: movzx edx, byte ptr [rdx+8]
    L000a: test edx, edx
    L000c: jne short L001a
    L000e: test eax, eax 
    L0010: sete al
    L0013: movzx eax, al
    L0016: test edx, edx # <-- 注意看这里
    L0018: je short L000e
    L001a: ret
    

    加了volatile

    L0000: xor eax, eax
    L0002: mov rdx, [rcx+8]
    L0006: cmp byte ptr [rdx+8], 0
    L000a: jne short L001e
    L000c: mov rdx, [rcx+8]
    L0010: test eax, eax
    L0012: sete al
    L0015: movzx eax, al
    L0018: cmp byte ptr [rdx+8], 0  <-- 注意看这里
    L001c: je short L0010
    L001e: ret
    

    留意我打了注释的那行。上面的这些IL代码行 实际上是代码进行检查的地方:

            while (!c.completed)
    

    当不使用volatile时,JIT将完成的值缓存到寄存器(edx),然后仅使用edx寄存器的值来判断(while (!c.completed))。
    但是,当我们使用volatile时,将强制JIT不进行缓存,
    而是每次我们需要读取它直接访问内存的值 (cmp byte ptr [rdx+8], 0)

    JIT缓存到寄存器 是因为 发现了 内存访问的速度慢了100倍以上,就像CPU一样,JIT出于良好的意图,缓存了变量。
    因此它无法检测到别的线程中的修改。
    volatile解决了这里的问题,迫使JIT不进行缓存。

    说完可见性了我们在来说下volatile的另外一个特性:内存屏障

    1. 确保在执行下一个上传/下载指令之前,已完成从volatile变量的下载指令。

    2. 确保在执行对​​volatile变量的当前上传指令之前,完成了上一个上传/下载指令。

    但是volatile并不禁止在完成上一条上传指令之前完成对volatile变量的下载指令。
    CPU可以并行执行并可以继续执行任何先执行的操作。
    正是由于volatile关键字无法阻止,所以这就是这里发生的情况:

    mov dword ptr [rax+0xc], 1  # 把值 1 上传到内存地址 'y'
    mov edx, [rax+8].           # 从内存地址 'x' 下载值并放到edx(寄存器) 
    

    变成这个

    mov edx, [rax+8].           # 从内存地址 'x' 下载值并放到edx(寄存器)
    mov dword ptr [rax+0xc], 1  # 把值 1 上传到内存地址 'y'
    

    因此,由于CPU认为这些指令是独立的,因此在y更新之前先读取x,同理在Test1方法也是会发生x更新之前先读取y。
    所以才会出现本文例子的坑~~!

    如何解决?

    输入内存屏障 内存屏障是对CPU的一种特殊锁定指令,它禁止指令在该屏障上重新排序。因此,该程序将按预期方式运行,但缺点是会慢几十纳秒。

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

       //Interlocked.MemoryBarrierProcessWide();
    

    如果取消注释该行,程序将正常运行~~~~~

    总结

    平常我们说volatile一般很容易去理解它的内存可见性,很难理解内存屏障这个概念,内存屏障的概念中对于volatile变量的赋值,
    volatile并不禁止在完成上一条上传指令之前完成对volatile变量的下载指令。这个在多线程环境下一定得注意!


    如果您觉得阅读本文对您有帮助,请点一下“推荐”按钮,您的“推荐”将是我最大的写作动力!欢迎各位转载,转载文章之后须在文章页面明显位置给出作者和原文连接,谢谢。
  • 相关阅读:
    class7-附
    class6-附
    class6
    class5-附
    class4-附
    class4
    class3-附【家庭资产配置】
    class2
    芒果绿的blog
    java网络爬虫基础学习(四)
  • 原文地址:https://www.cnblogs.com/yudongdong/p/14403512.html
Copyright © 2020-2023  润新知