非阻塞同步 - 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可能比围绕循环加锁更加低效。