• C#学习笔记之线程


    非阻塞同步 - Nonblock Synchronization


    前面提到,即使在简单的赋值和增加一个字段的情况下也需要处理同步。尽管,使用锁可以完成这个功能,但是锁必定会阻塞线程,需要线程切换,在高并发的场景中,这使非常关键的。.NET框架的非阻塞同步能够执行简单的操作而不需要阻塞,暂停或等待。

    编写非阻塞或无锁的多线程代码是一种技巧。内存屏障很容易出错(volatile关键字更容易出错)。仔细想一想,在你不使用锁之前,你是否真的需要这些性能。毕竟,获取和释放一个不竞争的锁还不需20ns。

    非阻塞方法也可以跨进程。在读写进程间共享内存时可能有用。

    内存屏障和Volatility

    想一想下面的代码:

    class Foo 
    { 
      int _answer; 
      bool _complete; 
      void A() 
      { 
        _answer = 123; 
        _complete = true; 
      } 
      void B() 
      { 
        if (_complete) Console.WriteLine (_answer); 
      } 
    } 

    如果A和B同时运行在不同的线程上,B是否有可能输入0?答案是Yes--因为以下2个原因:

    • 编译器,CLR或CPU可能为了改善效率重新排序了程序指令。
    • 编译器,CLR或CPU可能引入了cache来优化变量的赋值,但是其它线程不能立即看到。

    C#和CLR非常小心地确保这样的优化不会打断普通的单线程代码--或者正确使用锁的多线程代码。这些场景之外,你必须显式地通过创建内存屏障来击败这些优化,确保限制指令的重新排序和读写缓存的影响。

    完全内存屏障 full memory barrier (full fence)

    最简单的内存屏障是完全内存屏障,阻止任何对指令的排序和缓存。调用Thread.MemoryBarrier产生一个完全内存屏障,我们可以通过full fence来解决这个问题:

    class Foo 
    { 
      int _answer; 
      bool _complete; 
      void A() 
      { 
        _answer = 123; 
        Thread.MemoryBarrier();    // Barrier 1 
       _complete = true; 
        Thread.MemoryBarrier();    // Barrier 2 
      } 
      void B() 
      { 
        Thread.MemoryBarrier();    // Barrier 3 
        if (_complete) 
        { 
          Thread.MemoryBarrier();       // Barrier 4 
          Console.WriteLine (_answer); 
        } 
      } 
    } 

    Barrier1和4阻止写“0”。Barrier2和3保证:如果B在A之后运行,_complete肯定是true。一个full fence只需10ns。

    下面隐式地产生了full fences:

    • C#的lock语句(Montor.Enter/Montor.Exit)
    • Interlocked类的所有方法
    • 使用线程池的异步回调--包含异步委托,APM回调和Task
    • Set和等待Signal
    • 任何依赖于Signal的东西,如在Task上开始或等待的事情。下面的代码是线程安全的:
    int x=0;
    Task t = Task.Factory.StartNew(()=>x++);
    t.Wait();
    Console.WriteLine(x);

    不必为每一个读写都使用full fence。如果你3个answer字段,我们也只需4个fences:

    class Foo 
    { 
      int _answer1, _answer2, _answer3; 
      bool _complete; 
      void A() 
      { 
        _answer1 = 1; _answer2 = 2; _answer3 = 3; 
        Thread.MemoryBarrier(); 
        _complete = true; 
        Thread.MemoryBarrier(); 
      } 
      void B() 
      { 
        Thread.MemoryBarrier(); 
        if (_complete) 
        { 
          Thread.MemoryBarrier(); 
          Console.WriteLine (_answer1 + _answer2 + _answer3); 
        } 
      } 
    } 

     一个好的方法是在读写每一个共享字段前后都加上内存屏障,跳过你不需要的。如果你不确定,随他去。更好的办法是:使用锁。

    确实需要Lock和内存屏障吗?

    与没有加锁或内存屏障的共享写字段工作是自找麻烦。这里有大量的误用--包括MSND对于MemoryBarrier的文档,它说仅在多盒处理器,如有多个Itanium处理器的系统中才要求MemoryBarrier。我们演示的例子揭示了内存屏障在Interl core-2处理器上的重要性。你需要优化它并不能在debug模式下(在Visual Studio中选择Release,并且以非debug方式启动)。

    static void Main() 
    { 
      bool complete = false;  
      var t = new Thread (() => 
      { 
        bool toggle = false; 
        while (!complete) toggle = !toggle; 
      }); 
      t.Start(); 
      Thread.Sleep (1000); 
      complete = true; 
      t.Join();        // Blocks indefinitely 
    } 

    这个程序不会终止,因为complete变量被缓存在CPU的寄存器中。在while循环中插入一个MemoryBarrier(或者围绕读complete加锁)可以解决这个问题。

    关键字volatile

    另外一个解决这个问题的方法是对complete使用volatile关键字。volatile bool complete;

    关键字volatile指示编译器在每次读这个字段时产生一个获取屏障,并在每次写字段时释放屏障。一个获取屏障在屏障之前阻止其它读写被移动;释放屏障阻止在屏障之后其它读写被移动。这些半屏障比full fence更快。

    到目前为止,Intel的X86和X64处理器总是使用获取屏障来读及写后释放屏障--不管你是否使用volatile关键字--所以这个关键字对于正在使用这些处理器人没有任何影响。但是,volatile在编译器和CLR上执行优化有影响,64位的AMD和Itanium处理器也有影响。这意味着你不会更轻松,因为你的程序运行在不同的处理器上。

    如果你使用volatile,那么说明你渴望你的程序更加健康。

    对字段使用volatile的影响概括如下:

    First instruction Second instruction Can they be swapped?
    Read  Read No
    Read Write  No 
    Write Write  No (The CLR ensures that write-write operations are never swapped, even without the volatile keyword 
    Write Read  Yes

    可以看出volatile并不阻止写紧接着读可以被交换,这就像脑筋急转弯。Joe Duffy用下面的例子很好的演示了这个问题:如果Test1和Test2同时运行在不同的线程上,a和b结束时同时为0这是可能的(不管你是否对x和y使用volatile)。

    class IfYouThinkYouUnderstandVolatile 
    { 
      volatile int x, y; 
      void Test1()        // Executed on one thread 
      { 
        x = 1;            // Volatile write (release-fence) 
        int a = y;        // Volatile read (acquire-fence) 
        ... 
      } 
      void Test2()        // Executed on another thread
      { 
        y = 1;            // Volatile write (release-fence) 
        int b = x;        // Volatile read (acquire-fence) 
        ... 
      } 
    } 

    MSDN上说使用volatile关键字可以确保任何时候它的值是最新的。这是不正确的,因为我们已经看到写紧接着读是可能重新排序的。

    这强烈说明应该避免使用volatile:即使你理解这个例子的细节,其它开发者呢?在每个赋值语句中使用完成内存屏障或锁可以解决这个问题。

    volatile并不支持通过引用传递给参数或局部变量:这些情况应该使用volatileRead和VolatileWrite函数。

    VolatileRead和VolatileWrite

    这2个方法是Thread的静态方法来读写一个变量,被volatile关键字强迫保证。它们的实现也相对低效,实际上它们是通过full fence来实现的。下面是对integer类型完整实现:

    public static void VolatileWrite(ref in address, int value)
    {
        MemoryBarrier();address=value;
    }
    public static void VolatileRead(ref int address)
    {
        int num =address; MemoryBarrier();return num;
    }

    从中可以看出,如果你使用VolatileWrite紧接着调用VolatileRead,那么在两者之间没有屏障:前面的问题又出现了。

    内存屏障和锁 - Memory barrier & lock

    前面提到,Monitor.Enter和Monitor.Exit都产生了完全屏障。所以如果我们忽略锁的排斥保证,那么可以这么认为:

    lock(someField){...}等价于Thread.MemoryBarrier();{...}Thread.MemoryBarrier();

    Interlocked

    在一个不使用锁的代码中只用内存屏障是不够的。在64位的字段上的自增或自减要求使用Interlocked类。Interlocked类也提供了Exchange和CompareExchange方法,后者不用锁,能读-修改-写操作,而不需要额外的代码。

    如果一个语句在处理器上作为一个指令执行那么它就是原子的。严格的原子性排除了任何被抢占的可能性。一个32位字段的读写或者小于总是原子操作的。64位的字段在64位的运行时环境中也是原子的,超过一个读写操作的语句不是原子的。

    class Atomicity
    {
      static int _x, _y;
      static long _z;
      static void Test()
      {
        long myLocal;
        _x = 3; // Atomic
        _z = 3; // Nonatomic on 32-bit environs (_z is 64 bits)
        myLocal = _z; // Nonatomic on 32-bit environs (_z is 64 bits)
        _y += _x; // Nonatomic (read AND write operation)
        _x++; // Nonatomic (read AND write operation)
      }
    }

    读写一个64位的字段在32位环境中是非原子的,因为这要求2条指令:每个32位内存位置1条。所以,如果当Y线程正在更新它,而线程X正在读取,那么X线程可能读到不正确的值。

    编译器实现一个二元运算x++,是通过读取变量,处理它并写回来实现的。

    下面的例子:

    class ThreadUnsafe

    {

      static int _x=1000;

      static void Go(){for(int i=0;i<100;i++)_x--;}

    }

    把内存屏障放在一边,你可能预期如果10个线程同时运行Go,_x可能最后是0。然而,这是不保证的,因为这里有一个竞争条件:其它线程可能在当前线程找回x的当前值,递增它并写回这个过程中抢占(导致它的值不是最新的)。

    当然,你可以用lock来封装这些非原子代码来解决这个问题。Interlocked为这些简单的操作提供了一个更简单,更快的解决方案。

    Interlocked的数学运算仅限于Increment,Decrement和Add。如果你想要乘法或除法运算,你可以在不使用锁的代码中使用CompareExchange来完成(通常与自旋等待连用)。

    操作系统和虚拟机知道Interlocked需要原子性操作。

    Interlocked这类函数大概需要10ns的时间--大概是无竞争lock的一半时间。而且,它们从来没有由于阻塞而切换上下文的花费。在一个循环内部使用Interlocked可能比围绕循环加锁更加低效。

  • 相关阅读:
    面向对象分析与设计
    数据摘要pandas
    面向对象(简介)
    SQL触发器、事物
    SQL——查询考试
    SQL存储过程、视图
    SQL变量、运算符、分支、循环语句
    SQL连接查询
    SQL主外键和子查询
    SQL各种语句、函数
  • 原文地址:https://www.cnblogs.com/C-Sharp2/p/4255772.html
Copyright © 2020-2023  润新知