C#学习笔记之线程 - 高级主题:非阻塞同步

时间:2021-09-08 01:34:22

非阻塞同步 - Nonblock Synchronization


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

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

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

内存屏障和Volatility

想一想下面的代码:

class Foo
{
int _answer;
bool _complete;
void A()
{
_answer = ;
_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 = ;
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=;
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 = ; _answer2 = ; _answer3 = ;
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 ();
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 = ; // Volatile write (release-fence)
int a = y; // Volatile read (acquire-fence)
...
}
void Test2() // Executed on another thread
{
y = ; // 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可能比围绕循环加锁更加低效。