并发数据结构:谈谈volatile变量
由来
在CLR 2.0 Memory Model中,我们知道现代CPU架构从CPU到Memory Controller每一级都有速度,容量不同的高速缓存。之所以这样设计,主要是因为性能。为了进一步提升性能,当线程读取内存中所期望的元素值时,CPU并不是只读取我们所期望的元素值,它实际上会同时读取该值周围的若干字节,并将其放入高速缓存中。这是因为应用程序通常读取的字节在内存中彼此相邻。当应用程序又读取该值周围的字节时,这些字节已经在高速缓存中了,这样就避免了应用程序再次访问内存,也提升了性能。
应用程序在单核CPU的机器上运行时,高速缓存不会有什么影响。但是当应用程序跑在多CPU/多核CPU的机器上时,我们就要考虑高速缓存所带来的显著影响了(请参考CLR 2.0 Memory Model)。更槽糕的是,C#或JIT编译器编译代码时,会将指令重新排序。因此,应用程序的执行顺序可能会跟编写的顺序不同,而且现代CPU本身也支持乱序执行CPU指令。
这样,我们就不得不考虑如何来处理高速缓存一致性。不同的CPU处理方式也不尽相同。比如在CLR 2.0 Memory Model中讲到的x86架构的CPU就会维持高速缓存一致性,而x64架构向后兼容x86架构,所以也有此特性。但是IA64架构的CPU则被设计用来充分利用每个CPU的高速缓存,而且为了提升性能,尽量避免高速缓存一致性问题。
为了解决高速缓存一致性所带来的问题,CLR在System.Threading.Thread类中提供了若干个下述形式的静态方法(这是最简单,最原始的方式,所有的锁机制都会强制高速缓存一致性):
static Byte VolatileRead(ref Byte address);
static void VolatileWrite(ref Object address, Object value);
static void VolatileWrite(ref Byte address, Byte value);
static void MemoryBarrier();
所有的VolatileRead方法都执行一个包含获取语义的读取操作,这些方法读取由参数address引用的值,然后使得CPU高速缓存内的相应字节失效。所有的VolatileWrite方法则执行一个包含释放语义的写入操作,这些方法将CPU高速缓存内的字节刷到内存中,然后将address参数引用的值修改为value参数所表示的值。MemoryBarrier方法则执行一个内存栅栏,将CPU高速缓存内的字节刷到内存中,然后使CPU的高速缓存内的相应字节失效。
C#编译器提供了volatile关键字,该关键字可以用于下述类型的静态/实例字段:byte,sbyte,short,ushort,int,uint,char,float和bool。此外,我们还可以将volatile关键字应用于引用类型以及枚举类型的基础类型是byte,sbyte,short,ushot,int,uint,float和bool的枚举字段。volatile关键字告诉C#和JIT编译器不再在CPU寄存器中缓存字段,从而确保字段的所有读写操作都是对内存的读写,JIT编译器则确保其语义正确,这样就不必显式调用Thread的静态方法VolatileXXX了。
飞升
锁具有两种主要特性:互斥和可见性。互斥指的是一次只允许一个线程持有某个特定的锁,因此可以保证共享数据内容的一致性;可见性指的是必须确保锁被释放之前对共享数据的修改,随后获得锁的另一个线程能够知道该行为。
volatile变量可以看作是“轻量级lock”。当出于简单编码和可伸缩性考虑时,我们可能会选择使用volatile变量而不是锁机制。某些情况下,如果读操作远多于写操作,也会比锁机制带来更高性能。
volatile变量具有“lock”的可见性,却不具备原子特性。也就是说线程能够自动发现volatile变量的最新值。volatile变量可以实现线程安全,但其应用有限。使用volatile变量的主要原因在于它使用非常简单,至少比使用锁机制要简单的多;其次便是性能原因了,某些情况下,它的性能要优于锁机制。此外,volatile操作不会造成阻塞。
所有的并发专家都在告诫,尽量不要用volatile变量来实现线程安全。为啥呢?因为使用volatile的代码比锁机制更加容易出错,看看CLR 2.0 Memory Model和前面我啰里啰嗦的废话就知道,要用这玩意,得加倍小心。下面咱们就来看看如何正确使用这害人不偿命的“小可爱”。
先说两个准则,只要我们的程序能遵循它,咱就可以放心使用volatile变量来实现线程安全。
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
丫,是不是有些头大,这说的跟教导主任似的。实际上,它的意思是说,老子得了失忆症,不记得任何人了,甚至连自己现在的样子也记不起来了。老子独立于程序的任何状态,包括自己当前的状态。
我们谨记其限制,只有在其状态完全独立于程序其他状态时才可使用volatile变量。
先来看下最简单最规范的应用,使用一个布尔变量,用来表示某个重要事件的标志。例如完成初始化或请求停止。
很多桌面客户端应用程度都会提供一个扫描/查找文件的功能,这当然需要另开一个工作线程去查找,不然UI会失去响应一段时间,尤其是要扫描的文件特多的时候,这种情况下,客户会受不了的。我们还要注意的是,工作线程应当能够随时停止,不然客户点击取消按钮时,要等到线程真正结束时,才能完毕,这个情况也会影响客户体验。咋办呢,加个停止标志,让工作线程每次想要扫描/查找文件的时候都要先看看是否需要停止扫描了。如下代码所示:
public void Stop()
{
stopped = true;
}
public void FindFiles()
{
while (!stopped)
{
// searching files
}
}
另外的线程很可能调用上述代码的Stop方法,这需要某种同步方式来保证stopped变量的可见性,并且stopped变量也不依赖于程序内其他状态,因此此处非常适合使用volatile。当然使用lock也可以,但却不如volatile来的简便,而且volatile还有性能上的优势。
这种场景下的变量标志有个通性:通常只有一种状态转换。stopped从false转换为true,然后停止。可以扩展让状态标志来回转换,但只能在转换周期不被察觉的情况下才能扩展。此外,还需要某些原子状态转换机制。
接下来我们看一下同步对象引用。
缺乏同步会导致无法实现可见性。这使得何时写入对象引用而不是原语值变得困难起来。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。
比如我们的后台线程在启动阶段从数据库加载一些数据,当其他代码在使用这些数据时,先检查一下这些数据是否已经被加载。
{
public volatile LargeDataType LargeData;
public void InitInBackground()
{
// do lots of work
// here initialize a LargeDataType object
LargeData = new LargeDataType();
}
}
public class OtherClass
{
public void DoWork()
{
while (true)
{
// if it is ready, process it.
if (largeDataObject.LargeData != null)
{
Process(largeDataObject.LargeData);
}
}
}
}
假如LargeData引用不是volatile类型,当DoWork处理数据时,就有可能得到一个不完全构造的LargeData对象。这里还有一个必要条件就是LargeData必须是线程安全的,或者该对象创建之后永远不会被修改。volatile类型的引用可以确保LargeData的可见性,但是如果其被修改,那么就需要额外的同步措施了。
到这里,我们已经知道volatile提供的同步机制还不足以能够实现线程安全计数器。因为计数器虽然简单,却是三种操作的组合,如果多线程试图进行增量操作,很可能会丢失其更新值。如果读操作远多于写操作,那我们可以结合锁机制和volatile变量来提供一个开销较低的计数器。
{
private volatile int value;
public int Value
{
get
{
return value;
}
}
public int Increase()
{
lock (this)
{
return value++;
}
}
}
如果更新不频繁的话,该方法可实现更好的性能,通常优于一个无竞争的锁的开销。这个例子的写操作因为违反了第一个准则,所以我们使用锁来确保原子操作。当读操作远多于写操作时,该示例可以提供竞争性的性能优势,因为volatile变量读操作的开销非常低,几乎跟非volatile变量一样,但我们同时也应该牢记这个弱点,否则其带来的可能是性能的低下。
此外,该示例还有一个缺陷,就是有死锁的危险。问题主要出现在lock(this)语句上,Jeffrey Richter在他的《CLR via C#》第24章3.7节讲述的非常清楚。
最后,我们来总结一下,volatile关键字提供了一个非常脆弱的同步机制,在上述情境下或者我们知道其能带来线程安全的情境下,可以使用volatile变量来简化编码,以及提升程序性能和伸缩性。重申一下使用volatile变量的正确条件 -- volatile变量必须真正独立于其他变量和其以前的值。还有并发专家也同时告诫我们:尽量远离volatile变量,除非你真正的理解其涵义和使用场景。
参考
《CLR via C#》