随笔-13  评论-168  文章-0  trackbacks-19

由来

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 Object VolatileRead(ref Object address);
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变量来实现线程安全。

  1. 对变量的写操作不依赖于当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

丫,是不是有些头大,这说的跟教导主任似的。实际上,它的意思是说,老子得了失忆症,不记得任何人了,甚至连自己现在的样子也记不起来了。老子独立于程序的任何状态,包括自己当前的状态。

我们谨记其限制,只有在其状态完全独立于程序其他状态时才可使用volatile变量。

先来看下最简单最规范的应用,使用一个布尔变量,用来表示某个重要事件的标志。例如完成初始化或请求停止。

很多桌面客户端应用程度都会提供一个扫描/查找文件的功能,这当然需要另开一个工作线程去查找,不然UI会失去响应一段时间,尤其是要扫描的文件特多的时候,这种情况下,客户会受不了的。我们还要注意的是,工作线程应当能够随时停止,不然客户点击取消按钮时,要等到线程真正结束时,才能完毕,这个情况也会影响客户体验。咋办呢,加个停止标志,让工作线程每次想要扫描/查找文件的时候都要先看看是否需要停止扫描了。如下代码所示:

private volatile bool stopped;

public void Stop()
{
  stopped
= true;
}


public void FindFiles()
{
 
while (!stopped)
 
{
   
// searching files
  }

}

另外的线程很可能调用上述代码的Stop方法,这需要某种同步方式来保证stopped变量的可见性,并且stopped变量也不依赖于程序内其他状态,因此此处非常适合使用volatile。当然使用lock也可以,但却不如volatile来的简便,而且volatile还有性能上的优势。

这种场景下的变量标志有个通性:通常只有一种状态转换。stopped从false转换为true,然后停止。可以扩展让状态标志来回转换,但只能在转换周期不被察觉的情况下才能扩展。此外,还需要某些原子状态转换机制。

接下来我们看一下同步对象引用。

缺乏同步会导致无法实现可见性。这使得何时写入对象引用而不是原语值变得困难起来。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

比如我们的后台线程在启动阶段从数据库加载一些数据,当其他代码在使用这些数据时,先检查一下这些数据是否已经被加载。

public class LargeDataObject
{
   
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变量来提供一个开销较低的计数器。

public class ThreadSafeCounter
{
   
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#》

posted on 2008-03-23 23:23 Angel Lucifer 阅读(3036) 评论(21)  编辑 收藏 所属分类: 并行程序设计

评论:
#1楼  2008-03-23 23:37 | Jeffrey Zhao      
其实这篇文章写得挺好的,为啥没有反响呢,呵呵……
  回复  引用  查看    
#2楼 [楼主] 2008-03-23 23:52 | Angel Lucifer      
@Jeffrey Zhao
多线程的文章,大家都不太爱看吧。
尤其这篇还要涉及到CPU架构以及内存交互。
  回复  引用  查看    
#3楼  2008-03-24 00:00 | Zealic      
在并发情况下
用了这东西反而更麻烦
尤其是大多数人都不理解它是啥用的

推荐只用在高实时响应且场景较简单的情况下
简单的说就是只用在元类型数据的单一读写
其他的情况会搞得很复杂

但我的原则是
宁愿增加锁的开销,也绝对不用 volatile

  回复  引用  查看    
#4楼  2008-03-24 00:00 | Jeffrey Zhao      
@Angel Lucifer
我觉得可能是工作中涉及到这方面的人不多,这也不是当下关注的重点。:)
  回复  引用  查看    
#5楼  2008-03-24 00:08 | Jeffrey Zhao      
@Zealic
嗯,其实volatile和lock还是有区别的,而且我觉得解决的问题其实并没有太多接近的地方,这点从文章里“两个准则”可以看出来……
  回复  引用  查看    
#6楼  2008-03-24 00:22 | Zealic      
@Jeffrey Zhao
“两个准则”其实和我所说的“元类型数据的单一读写”意思相差不多,不过我的要求更严格一些

多线程/并发 还是严格控制在自己手里比较好,否则代码一旦膨胀起来,就会陷入 Debug 地狱。
不能太依赖编译器!


  回复  引用  查看    
#7楼  2008-03-24 08:23 | Q.Lee.lulu      
看兄弟的ID
难道是玩 WAR3 的 ?
  回复  引用  查看    
#8楼  2008-03-24 11:22 | A.Z! [未注册用户]
volatile有很多益用的场合,我在很多工程里都用到了这个关键字,其实在msdn说的很清楚:
The volatile keyword indicates that a field might be modified by multiple threads that are executing at the same time. Fields that are declared volatile are not subject to compiler optimizations that assume access by a single thread. This ensures that the most up-to-date value is present in thed field at all times.

它可以高效的提供在多线程环境下,对一个field的访问的状态同步。
在demo中提到了在复合对象的集合上应用可能会导致一些副作用。事实上这是因为它只能原子性的提供简单对象的同步。

  回复  引用    
#9楼  2008-03-24 12:47 | John Rambo      
对我这样专业基础不是太好的人来说,例子就是一切。。。
如果楼主能够通过代码表现出用和不用volatile的区别那就太好。
顺便问一下cs规范里面关于volatile的那个例子:
~~~~~~~~cs语言规范里的原文开始~~~~~~~~~~~~~
using System;
using System.Threading;
class Test
{
public static int result;
public static volatile bool finished;
static void Thread2() {
result = 143;
finished = true;
}
static void Main() {
finished = false;
// Run Thread2() in a new thread
new Thread(new ThreadStart(Thread2)).Start();
// Wait for Thread2 to signal that it has a result by setting
// finished to true.
for (;;) {
if (finished) {
Console.WriteLine("result = {0}", result);
return;
}
}
}
}
产生下列输出:
result = 143
在本示例中,方法 Main 启动一个新线程,该线程运行方法 Thread2。该方法将一个值存储在叫做 result 的非可变字段中,然后将 true 存储在可变字段 finished 中。主线程等待字段 finished 被设置为 true,然后读取字段 result。由于 finished 已被声明为 volatile,主线程从字段 result 读取的值一定是 143。如果字段 finished 未被声明为 volatile,则存储 finished 之后,主线程可看到存储 result,因此主线程从字段 result 读取值 0。将 finished 声明为 volatile 字段可以防止这种不一致性。
~~~~~~~~cs语言规范里的原文结束~~~~~~~~~~~~
我测试了大概100遍把volatile去掉也没有输出过0,都是143。volatile难道只是为了避免偶尔出现的优化?(也就是没有volatile并不必然作出优化)
  回复  引用  查看    
#10楼  2008-03-24 13:28 | Corvallis [未注册用户]
@John Rambo
你需要在CPU不共享缓存的系统上测试。至少要QUAD-CORE或两个以上的独立CPU
  回复  引用    
#11楼  2008-03-24 15:00 | lbq1221119      
写的挺好的,呵呵
  回复  引用  查看    
#12楼 [楼主] 2008-03-24 17:29 | Angel Lucifer      
@John Rambo
我猜你用来测试的程序跑在x86/x64架构的机器上。x86架构的CPU会维持高速缓存一致性,x64则向后兼容x86,所以如果在其上运行测试程序,不会观察到这个问题,也就是说程序要么不显示任何东西,要么显示143。
在CLR 2.0 Memory Model中仔细的讲解了x86架构的行为。

@Zealic
volatile的确是个令人讨厌的东西,而且应用场景也非常有限。
接下来我会探讨关于原子操作和无锁编程,这个就有点意思了。

@lbq1221119
Thanks,:-)

@Q.Lee.lulu
war3虽然玩过,但是玩的不咋地。这个名字在war3出来N久之前,就一直再用了,我是比较欣赏Lucifer的,嘿嘿。

  回复  引用  查看    
#13楼  2008-03-25 12:35 | waw [未注册用户]
好文章,.net应该多点这样的文章。
  回复  引用    
#14楼  2008-03-25 13:21 | John Rambo      
@Angel Lucifer
谢谢你的讲解。我确实是在x86上跑的,pentium D 1.6g。这些东西实在是太奇妙了。
  回复  引用  查看    
#15楼  2008-07-03 21:45 | 王庆      
挺好的,我这段时间一直都在关注多线程操作。
  回复  引用  查看    
#16楼  2008-07-19 13:16 | 夏天可是个好季节      
volatile对于多线程程序几乎可以说没有作用,你仅仅在阻止编译器优化你的代码并且在一些情况下,更改load的的语义。不要理会MSDN上的说明。MSDN对于volatile的说明包含太多的误导成分。
实际上对于.NET,volatile很简单,任何对于volatile的load均有acquire语义,任何对于(不关事volatile还是非volatile的)store均有release语义。并且,不论是load acq还是 store rel都是针对当前的processor而言的,并不保证对于其他 processor的可见性。因此说volatile对于可见性有帮助也是没有设么原因的。
  回复  引用  查看    

标题  
姓名  
主页
Email (博主才能看到) 
验证码 *  看不清,换一张 [登录][注册]
内容(请不要发表任何与政治相关的内容)  
  登录  使用高级评论  新用户注册  返回页首  恢复上次提交      
该文被作者在 2008-04-19 10:21 编辑过


相关链接: