.Net学习难点讨论系列3 – .线程同步问题之一
此文基本属于拼凑J,出于总结知识的目的而写。
线程同步的问题是在多线程编程中常遇到的一个问题。从最底层的操作系统内核编程到高级的.Net托管模式下的编程都可以见到处理线程同步问题的代码的身影。
本文将讨论一下.Net中线程同步的几种实现方式
首先是介绍托管代码中CLR原生支持处理线程同步的方法
方法一:互锁方法
当多个线程访问共享数据是,必须以线程安全的方式访问该数据。最快的以线程安全的方式操作数据的方法是使用互锁方法。这一系列的静态方法位于System.Threading.Interlocked类。唯一的限制是它们实现的对象有限。下面的例子针对这些方法最常操作的Int32类型变量: IL代码中赋值与简单的数值运算都不是原子操作。以下代码演示了这些函数的使用:
{
//自动执行(location++)
public static Int32 Increment(ref Int32 location);
//自动执行(lcoation--)
public static Int32 Decrement(ref Int32 location);
//自动执行(location+=value)
//说明:值可以是一个可以进行
public static Int32 Add(ref Int32 location, Int32 value);
//自动执行(localion = value)
public static Int32 Exchange(ref Int32 location, Int32 value);
//自动执行: if (location = comparand) location = value
public static Int32 CompareExchange(ref Int32 location, Int32 value, Int32 comparand);
}
以上代码展示了这些函数的原型,之所以定义这样的函数是因为在IL代码中赋值与简单的数值运算都不是原子操作。以下代码演示了这些函数的使用:
{
int newVal = Interlocked.Increment(ref intVal);
}
参数(需递增的变量)以引用方式传入,Increment方法不但可以修改传入的参数值,还会返回递增后的新值。
注意:要在程序中使用Interlocked类,需引用System.Threading这个命名空间。
Exchange() – 将某数值赋给一个成员变量用,示例
{
Interlocked.Exchange(ref myInt, 82);
}
Interlocked类还提供了Exchange和CompareExchange操作Object,IntPtr,Single,Double等类型参数的版本。
还有一个泛型版本的Exchange,其接受的参数被限制为class(任意引用类型)。(由于互锁方法要求其变量的地址对其,所以这个方法实现的前提是CLR会自动对齐)
注意,也是出于以上强制变量地址对其这个原因,不要调用操作Int64类型的Interlocked类的方法,因为无法保证Int64类型对象的内存地址对齐。
方法二:使用Monitor类与同步块
System.Threading空间下的Monitor类几乎允许将任意一段代码设置为在某个时间仅能被一个线程执行,我们称这段代码为临界区。Monitor类提供了Enter(object)与Exit(object)这两个静态方法。该对象提供了一个简单的方式用于唯一标识那个将以同步方式访问的资源。当一个线程调用了Enter()方法,它将等待以获得访问该引用对象的独占权(仅当另一个拥有该权利的时候它才会等待)。一旦该权利被获得并使用,线程可以对同一个对象调用Exit()方法以释放该权利。
需要注意的地方:1.绝不能将一个值类型的实例作为参数传给Enter()与Exit()方法。
2.不管发生了什么,必须在finally子句中调用Exit()以释放所有的独占访问权。C#中lock语句的内部实现以保证了这一点,所以使用lock语句是更好的选择。
使用Monitor类做同步示例:(代码来自CLR Via C#)
// 表示最后一次事务处理执行的时间的字段
private DateTime timeOfLastTransaction;
public void PerformTransaction()
{
// 对this对象加锁
// 按Jeffrey Richter书中的介绍,此处不应使用this,而应创建一个私有类对象,并将其传入作为锁对象
Monitor.Enter(this);
// 执行事务处理
// 记录最近一次事务处理的时间
timeOfLastTransaction = DateTime.Now;
// 对this对象解锁
Monitor.Exit(this);
}
// 下面只读属性返回最后一次事务处理执行的时间
public DateTime LastTransaction {
get {
// 对this对象加锁
Monitor.Enter(this);
// 在一个临时变量中保存最后一次事务处理的时间
DateTime dt = timeOfLastTransaction;
// 对this对象解锁
Monitor.Exit(this);
// 返回已保存的日期和时间
return(dt);
}
}
}
以上代码示范了如何使用Monitor的Enter方法和Exit方法对对象的同步块进行加锁和解锁。Enter方法与Exit方法需成对出现,否则会出现异常。注意此处的dt对象必不可少,它可以防止返回可能被破坏的值。
示例2(此示例来自Practical .Net2 and C#2)
class Program {
static long counter = 1;
static void Main() {
Thread t1 = new Thread( f1 );
Thread t2 = new Thread( f2 );
t1.Start(); t2.Start(); t1.Join(); t2.Join();
}
static void f1() {
for (int i = 0; i < 5; i++){
try{
Monitor.Enter( typeof( Program ) );
counter *= counter;
}
finally{ Monitor.Exit( typeof( Program ) ); }
System.Console.WriteLine("counter^2 {0}", counter);
Thread.Sleep(10);
}
}
static void f2() {
for (int i = 0; i < 5; i++){
try{
Monitor.Enter( typeof( Program ) );
counter *= 2;
}
finally{ Monitor.Exit( typeof( Program ) ); }
System.Console.WriteLine("counter*2 {0}", counter);
Thread.Sleep(10);
}
}
}
使用C#的lock语句简化代码
代码示例:(此代码使用了私有对象做锁对象,这是安全的使用lock的方式,上文也有提到,代码功能同上段)
// 分配一个用于加锁的private对象
private Object m_lock = new Object();
private DateTime timeOfLastTransaction;
public void PerformTransaction()
{
// 对私有字段对象加锁
lock (m_lock)
{
// 执行事务处理
timeOfLastTransaction = DateTime.Now;
} // 对私有字段对象解锁
}
public DateTime LastTransaction
{
get
{
lock (m_lock)
{
return timeOfLastTransaction;
}
}
}
}
示例2的lock版:
class Program {
static long counter = 1;
static void Main() {
Thread t1 = new Thread(f1);
Thread t2 = new Thread(f2);
t1.Start(); t2.Start(); t1.Join(); t2.Join();
}
static void f1() {
for (int i = 0; i < 5; i++){
lock( typeof(Program) ) { counter *= counter; }
System.Console.WriteLine("counter^2 {0}", counter);
Thread.Sleep(10);
}
}
static void f2() {
for (int i = 0; i < 5; i++){
lock( typeof(Program) ) { counter *= 2; }
System.Console.WriteLine("counter*2 {0}", counter);
Thread.Sleep(10);
}
}
}
Monitor的其它方法
TryEnter()方法,此方法与Enter()相似,只不过它是非阻塞的。如果资源的独占访问权已经被另一个线程占据,该方法将立即返回一个false的返回值。下面的代码说明了TryEnter()使用的一些问题:
class Program {
private static object staticSyncRoot = new object();
static void Main() {
Monitor.Enter( staticSyncRoot );
Thread t1 = new Thread(f1);
t1.Start(); t1.Join();
}
static void f1() {
bool bOwner = false;
try {
if( ! Monitor.TryEnter( staticSyncRoot ) )
return;
bOwner = true;
//
}
finally {
//当你没有获得访问权时不要调用Monitor.Exit()
//使用bOwner变量做指示是否TryEnter()已获取访问权
if( bOwner )
Monitor.Exit( staticSyncRoot );
}
}
}
Monitor类中方法控制线程的作用Wait()方法,Pusle()方法与PulseAll()
Wait(),Pusle()与PulseAll()这三个方法常放在一起使用,当一个线程获得了某个对象的独占访问权,而它决定等待(通过调用Wait())直到该对象的状态发生变化。为此,该线程必须暂时失去对象独占访问权,以便让另一个线程修改对象的状态。修改对象状态的线程必须使用Pulse()方法通知那个等待的线程修改完成。
(此示例及其代码出自Practical .Net2 and C#2)
以下通过一个场景介绍它们的使用
-
拥有obj对象独占访问权的线程T1,调用Wait(obj)方法将它自己注册到obj对象的被动等待列表中。
-
由于以上调用,T1失去了对obj的独占访问权。因此,另一个线程T2通过调用Enter(obj)获得obj的独占访问权。
-
T2最终修改了obj的状态并调用Pulse(obj)通知了这次修改。该调用将导致obj被动等待列表中的第一个线程(这里是T1)被移动到obj的主动等待列表的首位。(PulseAll()将被动等待列表中的线程全部转移到主动等待列表,这些线程将按照它们调用Wait()的顺序到达非阻塞态。)
-
一旦obj的独立访问权被释放,obj主动等待列表中的第一个线程将被确保可以获得obj的独占访问权,然后它就从Wait(obj)方法中退出等待状态。
-
在我们的场景中,T2调用Exit(obj)以释放对obj的独占访问权,接着T1恢复访问权并从Wait(obj)方法退出。
注意:以上描述的场景中,T2需要成对调用Enter()与Exit()函数,这样才能保证T2放弃对obj的独占权,使T1重新获得独占访问。(当然前提是Pulse()已被调用从而通知了这次修改)
示例代码贴在下面:
public class Program {
static object ball = new object();
public static void Main() {
Thread threadPing = new Thread( ThreadPingProc );
Thread threadPong = new Thread( ThreadPongProc );
threadPing.Start(); threadPong.Start();
threadPing.Join(); threadPong.Join();
}
static void ThreadPongProc() {
System.Console.WriteLine("ThreadPong: Hello!");
lock ( ball )
for (int i = 0; i < 5; i++){
System.Console.WriteLine("ThreadPong: Pong ");
Monitor.Pulse( ball );
Monitor.Wait( ball );
}
System.Console.WriteLine("ThreadPong: Bye!");
}
static void ThreadPingProc() {
System.Console.WriteLine("ThreadPing: Hello!");
lock ( ball )
for(int i=0; i< 5; i++){
System.Console.WriteLine("ThreadPing: Ping ");
Monitor.Pulse( ball );
Monitor.Wait( ball );
}
System.Console.WriteLine("ThreadPing: Bye!");
}
}
运行结果:
ThreadPing: Hello!
ThreadPing: Ping
ThreadPong: Hello!
ThreadPong: Pong
ThreadPing: Ping
ThreadPong: Pong
ThreadPing: Ping
ThreadPong: Pong
ThreadPing: Ping
ThreadPong: Pong
ThreadPing: Ping
ThreadPong: Pong
ThreadPing: Bye!
C#实现双检锁(double-check locking)技巧(摘自CLR Via C#)
private static Object s_lock = new Object();
private static Singleton s_value;
//私有构造器组织这个类之外的任何代码创建实例
private Singleton() {}
// 下述共有,静态属性返回单实例对象
public static Singleton Value {
get {
// 检查是否已被创建
if (s_value == null) {
// 如果没有,则创建
lock (s_lock) {
// 检查有没有另一个进程创建了它
if (s_value == null) {
// 现在可以创建对象了
s_value = new Singleton();
}
}
}
return s_value;
}
}
}
实现相同效果更简便的方法
public sealed class Singleton {
private static Singleton s_value = new Singleton();
private Singleton() { }
public static Singleton Value {
get {
return s_value;
}
}
}
}
这正是设计模式中单例模式,TerryLee的设计模式系列文章有这两段代码
方法3:ReaderWriterLock类
ReaderWriterLock类位于System.Threading命名空间下,它实现了多用户读/单用户写的同步访问机制。在合适的情况下ReaderWriterLock相对于Monitor类或Mutex类是一个更好的选择,因为后者的独占访问模型不允许任何形式的并发访问,这是的它们的处理效率始终不高,应用程序使用读的情况比写的情况要多。
ReaderWriterLock类与后文要介绍的互斥体及事件一样都是在使用前被初始化。必须从用于同步的对象的角度去考虑,而不是被同步的对象。
示例:
{
static int theResource = 0;
static ReaderWriterLock rwl = new ReaderWriterLock();
static void Main()
{
Thread tr0 = new Thread(ThreadReader);
Thread tr1 = new Thread(ThreadReader);
Thread tw = new Thread(ThreadWriter);
tr0.Start(); tr1.Start(); tw.Start();
tr0.Join(); tr1.Join(); tw.Join();
}
static void ThreadReader()
{
for (int i = 0; i < 3; i++)
{
try
{
// 使用AcquireReaderLock()请求读锁,超时触发异常
rwl.AcquireReaderLock(1000);
Console.WriteLine("Begin Read theResource = {0}",theResource);
Thread.Sleep(10);
Console.WriteLine("End Read theResource = {0}",theResource);
// 读取完毕后释放读锁
rwl.ReleaseReaderLock();
}
catch ( ApplicationException ){}
}
}
static void ThreadWriter() {
for (int i = 0; i < 3; i++)
{
try
{
// 通过AcquireWriterLock()请求写锁,超时触发异常
rwl.AcquireWriterLock(1000);
Console.WriteLine("Begin Write theResource = {0}",theResource);
Thread.Sleep(100);
theResource ++;
Console.WriteLine("End Write theResource = {0}",theResource);
//释放写锁
rwl.ReleaseWriterLock();
}
catch ( ApplicationException ) {}
}
}
}
Jeffrey Richter在他的CLR Via C#中提到不用类库中自带的ReaderWriterLock类,他提到的此类的缺陷如下,首先,进入与离开这个锁的性能非常慢。其次,当面临读线程与写线程同时等待处理时,这个锁给予了读线程优先级,这将导致处于等待状态的写线程非常慢。(通产我们知道,读线程往往大大多于写线程,所以这样的处理方式常造成写线程发生饥饿,不能及时完成任务)
Jeffrey Richter推荐他的Power Threading库中它实现的读/写线程锁。详情参见此处。
方法4:使用[Synchronization]特性进行同步
首先来说这是一个比较偷懒的进行同步的方法,它不需要我们实际深入线程控制敏感数据的细节,这意味着使用这种方法进行同步是非常简单的。[Synchronization]特性位于System.Runtime.Remoting.Contexts命名空间下。这个类级别的特性有效地使对象的所有示例的成员都保持线程安全。当CLR分配带[Synchronization]的对象时,它会把这个对象放在同步上下文中。要想使对象不被在上下文边界中移动,就必须让它继承ContextBoundObject类。下面代码示范了此代码的使用。
using System.Runtime.Remoting.Contexts;
[Synchronization]
public class Printer : ContextBoundObject
{
public void PrinterNumbers()
{
//
}
}
这种方法的问题在于,即使一个方法没有使用线程敏感的数据,CLR仍然会锁定对该方法的调用。这回明显的降低性能,所以此方法慎用。
由于本文过长托管代码包装Windows内核对象完成线程同步的方法放到下一篇文章中。
参考书籍:
框架设计(第2版):CLR Via C# 清华大学出版社
C#与.Net3.0高级程序设计(特别版) 人民邮电出版社