C#线程同步(2)- 临界区&Monitor

监视器(Monitor)的概念

可以在MSDN(http://msdn.microsoft.com/zh-cn/library/ms173179(VS.80).aspx)上找到下面一段话:

与lock关键字类似,监视器防止多个线程同时执行代码块。Enter方法允许一个且仅一个线程继续执行后面的语句;其他所有线程都将被阻止,直到执行语句的线程调用Exit。这与使用lock关键字一样。事实上,lock 关键字就是用Monitor 类来实现的。例如:

lock(x)
{
DoSomething();
}

这等效于:

System.Object obj = (System.Object)x;
System.Threading.Monitor.Enter(obj);
try
{
DoSomething();
}
finally
{
System.Threading.Monitor.Exit(obj);
}

使用 lock 关键字通常比直接使用 Monitor 类更可取,一方面是因为 lock 更简洁,另一方面是因为 lock 确保了即使受保护的代码引发异常,也可以释放基础监视器。这是通过 finally 关键字来实现的,无论是否引发异常它都执行关联的代码块。

这里微软已经说得很清楚了,Lock就是用Monitor实现的,两者都是C#中对临界区功能的实现。用ILDASM打开含有以下代码的exe或者dll也可以证实这一点(我并没有自己证实):

lock (lockobject)
{
int i = 5;
}

反编译后的的IL代码为:

IL_0045: call void [mscorlib]System.Threading.Monitor::Enter(object)
IL_004a: nop
.try
{
IL_004b: nop
IL_004c: ldc.i4.5
IL_004d: stloc.1
IL_004e: nop
IL_004f: leave.s IL_0059
} // end .try
finally
{
IL_0051: ldloc.3
IL_0052: call void [mscorlib]System.Threading.Monitor::Exit(object)
IL_0057: nop
IL_0058: endfinally
} // end handler

Monitor中和lock等效的方法

Monitor是一个静态类,因此不能被实例化,只能直接调用Monitor上的各种方法来完成与lock相同的功能:

Enter(object)/TryEnter(object)/TryEnter(object, int32)/TryEnter(object, timespan):用来获取对象锁(Lock中已经提到过,这里再强调一次,是对象类型而不能是值类型),标记临界区的开始。与Enter不同,TryEnter永远不会阻塞代码,当无法获取对象锁时它会返回False,并且调用者不进入临界区。TryEnter还有两种重载,可以定义一个时间段,在该时间段内一直尝试获得对象锁,超时则返回False。
Exit(object):没啥好说的,释放对象锁、退出临界区。只是一定记得在try的finally块里调用,否则一但由于异常造成Exit无法执行,对象锁得不到释放,就会造成死锁。此外,调用Exit的线程必须拥有 object 参数上的锁,否则会引发SynchronizationLockException异常。在调用线程获取指定对象上的锁后,可以重复对该对象进行了相同次数的 Exit 和 Enter 调用;如果调用 Exit 与调用 Enter 的次数不匹配,那么该锁不会被正确释放。
  上篇中提到的有关lock的所有使用方法和建议,都适用于它们。

比lock更“高级”的Monitor

到此为止,所有见到的还是我们在lock中熟悉的东西,再看Monitor的其它方法之前,我们来看看那老掉牙的“生产者和消费者”场景。试想消费者和生产者是两个独立的线程,同时访问一个容器:

很显然这个容器是一个临界资源(你不会问我为什么是显然吧?),同时只允许一个线程访问。
生产者往容器里存放生产好的资源;消费者消费掉容器里的资源。
  粗看这个场景并没有什么特殊的问题,只要在两个线程中分别调用两个方法,这两个方法内部都用同一把锁进入临界区访问容器即可。可是问题在于:

消费者锁定容器,进入临界区后可能发现容器是空的。它可以退出临界区,然后下次再盲目地进入碰碰运气;如果不退出,那么让生产者永远无法进入临界区,往容器里放入资源供消费者消费,从而造成死锁。
而生产者也可能进入临界区后,却发现容器是满的。结果一样,直接退出等下次来碰运气;或者不退出造成死锁。
  两者选择直接退出不会引发什么问题,无非就是可能多次无功而返。这么做,你的程序逻辑总是有机会得到正确执行的,但是效率很低,因为这样的机制本身是不可控的,业务逻辑是否得以成功执行完全是随机的。

所以我们需要更有效、更“优雅”的方式:

消费者在进入临界区发现容器为空后,立即释放锁并把自己阻塞,等待生产者通知,不再做无谓的尝试;如果顺利消费资源完毕后,主动通知生产者可以进行生产了,随后仍然阻塞自己等待生产者通知。
生产者如果发现容器是满的,那么立即释放锁并阻塞自己,等待消费者在消费完成后唤醒;在生产完毕后,主动给消费者发出通知,随后也仍然阻塞自己,等待消费者告诉自己容器已经空了。
  在按这个思路写出Sample Code前,我们来看Monitor上需要用的其它重要方法:

Wait(Object)/Wait(Object, Int32)/Wait(Object, TimeSpan)/Wait(Object, Int32, Boolean)/Wait(Object, TimeSpan, Boolean): 释放对象上的锁并阻塞当前线程,直到它重新获取该锁。
这里的阻塞是指当前线程进入“WaitSleepJoin”状态,此时CPU不再会分配给这种状态的线程CPU时间片,这其实跟在线程上调用Sleep()时的状态一样。这时,线程不会参与对该锁的分配争夺。
要打破这种状态,需要其它拥有该对象锁的线程,调用下面要讲到的Pulse()来唤醒。不过这与,Sleep()不同,只有那些因为该对象锁阻塞的线程才会被唤醒。此时,线程重新进入“Running”状态,参与对对象锁的争夺。
强调一下,Wait()其实起到了Exit()的作用,也就是释放当前所获得的对象锁。只不过Wait()同时又阻塞了自己。
我们还看到Wait()的几个重载方法。其中第2、3个方法给Wait加上了一个时间,如果超时Wait会返回不再阻塞,并且可以根据Wait 方法的返回值,以确定它是否已在超时前重新获取锁。在这种情况下,其实线程并不需要等待其它线程Pulse()唤醒,相当于Sleep一定时间后醒来。第4、5个方法在第2、3个方法的基础上加上exitContent参数,我们暂时不去管它,你可以详细参见这里:http://msdn.microsoft.com/zh-cn/library/79fkfcw1(VS.85).aspx。
Pulse(object):向阻塞线程队列(由于该object而转入WaitSleepJoin状态的所有线程,也就是那些执行了Wait(object)的线程,存放的队列)中第一个线程发信号,该信号通知锁定对象的状态已更改,并且锁的所有者准备释放该锁。收到信号的阻塞线程进入就绪队列中(那些处于Running状态的线程,可以被CPU调用运行的线程在这个队列里),以便它有机会接收对象锁。注意,接受到信号的线程只会从阻塞中被唤醒,并不一定会获得对象锁。
PulseAll(object):与Pulse()不同,阻塞队列中的所有线程都会收到信号,并被唤醒转入Running状态,即进入就绪队列中。至于它们谁会幸运的获得对象锁,那就要看CPU了。
注意:以上所有方法都只能在临界区内被调用,换句话说,只有对象锁的获得者能够正确调用它们,否则会引发SynchronizationLockException异常。 
  好了,有了它们我们就可以完成这样的代码:

using System;
using System.Threading;
using System.Collections;
using System.Linq;
using System.Text;

class MonitorSample
{
//容器,一个只能容纳一块糖的糖盒子。PS:现在MS已经不推荐使用ArrayList,
//支持泛型的List才是应该在程序中使用的,我这里偷懒,不想再去写一个Candy类了。
private ArrayList _candyBox = new ArrayList(1);
private volatile bool _shouldStop = false; //用于控制线程正常结束的标志

/// <summary>
/// 用于结束Produce()和Consume()在辅助线程中的执行
/// </summary>
public void StopThread()
{
   _shouldStop = true;
   //这时候生产者/消费者之一可能因为在阻塞中而没有机会看到结束标志,
   //而另一个线程顺利结束,所以剩下的那个一定长眠不醒,需要我们在这里尝试叫醒它们。
   //不过这并不能确保线程能顺利结束,因为可能我们刚刚发送信号以后,线程才阻塞自己。
   Monitor.Enter(_candyBox);
   try
   {
       Monitor.PulseAll(_candyBox);
   }
   finally
   {
       Monitor.Exit(_candyBox);
   }
}

/// <summary>
/// 生产者的方法
/// </summary>
public void Produce()
{
   while(!_shouldStop)
   {
       Monitor.Enter(_candyBox);
       try
       {
           if (_candyBox.Count==0)
           {
               _candyBox.Add("A candy");
               Console.WriteLine("生产者:有糖吃啦!");
               //唤醒可能现在正在阻塞中的消费者
               Monitor.Pulse(_candyBox);
               Console.WriteLine("生产者:赶快来吃!!");
               //调用Wait方法释放对象上的锁,并使生产者线程状态转为WaitSleepJoin,阻止该线程被CPU调用(跟Sleep一样)
               //直到消费者线程调用Pulse(_candyBox)使该线程进入到Running状态
               Monitor.Wait(_candyBox);
           }
           else //容器是满的
           {
               Console.WriteLine("生产者:糖罐是满的!");
               //唤醒可能现在正在阻塞中的消费者
               Monitor.Pulse(_candyBox);
               //调用Wait方法释放对象上的锁,并使生产者线程状态转为WaitSleepJoin,阻止该线程被CPU调用(跟Sleep一样)
               //直到消费者线程调用Pulse(_candyBox)使生产者线程重新进入到Running状态,此才语句返回
               Monitor.Wait(_candyBox);
           }
       }
       finally
       {
           Monitor.Exit(_candyBox);
       }
       Thread.Sleep(2000);
   }
   Console.WriteLine("生产者:下班啦!");
}

/// <summary>
/// 消费者的方法
/// </summary>
public void Consume()
{
    //即便看到结束标致也应该把容器中的所有资源处理完毕再退出,否则容器中的资源可能就此丢失
   //不过这里_candyBox.Count是有可能读到脏数据的,好在我们这个例子中只有两个线程所以问题并不突出
    //正式环境中,应该用更好的办法解决这个问题。
   while (!_shouldStop || _candyBox.Count > 0) 
   {
       Monitor.Enter(_candyBox);
       try
       {
           if (_candyBox.Count==1)
           {
               _candyBox.RemoveAt(0);
               if (!_shouldStop)
               {
                   Console.WriteLine("消费者:糖已吃完!");
               }
               else
               {
                   Console.WriteLine("消费者:还有糖没吃,马上就完!");
               }
               //唤醒可能现在正在阻塞中的生产者
               Monitor.Pulse(_candyBox);
               Console.WriteLine("消费者:赶快生产!!");
               Monitor.Wait(_candyBox);
           }
           else
           {
               Console.WriteLine("消费者:糖罐是空的!");
               //唤醒可能现在正在阻塞中的生产者
               Monitor.Pulse(_candyBox);
               Monitor.Wait(_candyBox);
           }
       }
       finally
       {
           Monitor.Exit(_candyBox);
       }
       Thread.Sleep(2000);
   }
   Console.WriteLine("消费者:都吃光啦,下次再吃!");
}

static void Main(string[] args)
{
MonitorSample ss = new MonitorSample();
Thread thdProduce = new Thread(new ThreadStart(ss.Produce));
Thread thdConsume = new Thread(new ThreadStart(ss.Consume));
//Start threads.
Console.WriteLine(“开始启动线程,输入回车终止生产者和消费者的工作……\r\n******************************************”);
thdProduce.Start();
Thread.Sleep(2000); //尽量确保生产者先执行
thdConsume.Start();
Console.ReadLine(); //通过IO阻塞主线程,等待辅助线程演示直到收到一个回车
ss.StopThread(); //正常且优雅的结束生产者和消费者线程
Thread.Sleep(1000); //等待线程结束
while (thdProduce.ThreadState != ThreadState.Stopped)
{
ss.StopThread(); //线程还没有结束有可能是因为它本身是阻塞的,尝试使用StopThread()方法中的PulseAll()唤醒它,让他看到结束标志
thdProduce.Join(1000); //等待生产这线程结束
}
while (thdConsume.ThreadState != ThreadState.Stopped)
{
ss.StopThread();
thdConsume.Join(1000); //等待消费者线程结束
}
Console.WriteLine("******************************************\r\n输入回车结束!");
Console.ReadLine();
}
}

可能的几种输出(不是全部可能):

开始启动线程,输入回车终止生产者和消费者的工作……


生产者:有糖吃啦!
生产者:赶快来吃!!

消费者:还有糖没吃,马上就完!
消费者:赶快生产!!
生产者:下班啦!
消费者:都吃光啦,下次再吃!


输入回车结束!

开始启动线程,输入回车终止生产者和消费者的工作……


生产者:有糖吃啦!
生产者:赶快来吃!!
消费者:糖已吃完!
消费者:赶快生产!!

生产者:下班啦!
消费者:都吃光啦,下次再吃!


输入回车结束!

开始启动线程,输入回车终止生产者和消费者的工作……


生产者:有糖吃啦!
生产者:赶快来吃!!
消费者:糖已吃完!
消费者:赶快生产!!
生产者:有糖吃啦!
生产者:赶快来吃!!

消费者:还有糖没吃,马上就完!
消费者:赶快生产!!
生产者:下班啦!
消费者:都吃光啦,下次再吃!


输入回车结束!

有兴趣的话你还可以尝试修改生产者和消费者的启动顺序,尝试下其它的结果(比如糖罐为空)。其实生产者和消费者方法中那个Sleep(2000)也是为了方便手工尝试出不同分支的执行情况,输出中的空行就是我敲入回车让线程中止的时机。

你可能已经发现,除非消费者先于生产者启动,否则我们永远不会看到消费者说“糖罐是空的!”,这是因为消费者在吃糖以后把自己阻塞了,直到生产者生产出糖块后唤醒自己。另一方面,生产者即便先于消费者启动,在这个例子中我们也永远不会看到生产者说“糖罐是满的!”,因为初始糖罐为空且生产者在生产后就把自己阻塞了。

题外话1:
  是不是觉得生产者判断糖罐是满的、消费者检查出糖罐是空的分支有些多余?
  想想,如果糖罐初始也许并不为空,又或者消费者先于生产者执行,那么它们就会派上用场。这毕竟只是一个例子,我们在没有任何限制条件下设计了这个环环相扣的简单场景,所以让这两个分支“显得”有些多余,但大多数真实情况并不如此。
  在实际应用中,生产者往往代表负责从某处简单接收资源的线程,比如来自网络的指令、从服务器返回的查询等等;而消费者线程需要负责解析指令、解析返回的查询结果,然后存储到本地数据库、文件或者呈现给用户等等。消费者线程的任务往往更复杂,执行时间更长,为了提高程序的整体执行效率,消费者线程往往会多于生产者线程,可能3对1,也可能5对2……
  CPU的随机调度,可能会造成各种各样的情况。你基本上是无法预测一段代码在被调用时,与之相关的外部环境是怎样的,所以完备的处理每一个分支是必要的。
  另一方面,即便一个分支的情况不是我们设计中期望发生的,但是由于某种现在无法预见的错误,造成本“不可能”、“不应该”出现的分支得以执行,那么在这个分支的代码可以保障你的业务逻辑可以在错误的异常情况下得以修正,至少你也可以报警避免更大的错误。
  所以总是建议给每个if都写上else分支,这除了让你的代码显得更加仅仅有条、逻辑清晰外,还可能给你带来额外的扩展性和健壮性。就像在前一篇中所提到的,不要因为别人(你所写类的使用者)的“错误”(谁让你给别人这个机会呢?)连累自己!

题外话2:
  你可以用微软的建议用 lock(_candyBox){…} 替代上面代码中的Monitor.Enter(_candyBox);try{…}finally{Monitor.Exit(_candyBox);},这里我不做任何反对。不过在更多时候,你核能会需要在finally里做更多的事情,而不只是Exit那么简单,所以即便用了lock,你还得自己写try/finally。
  如果你的头已经有些晕了,那么马上跳过这个题外话,下面说的跟线程同步毫无关系。这个题外话其实想引申到using。这个C#特有的(其它.net语言没有类似语法)关键字,它会帮你自动调用所有实现了IDisposable接口类上的Dispose()方法。跟lock类似,using(obj) {//do something}等效于一个如下的try/finally语句块:

SS obj = new SS();
try
{
//use obj to do something
}
finally
{
obj.Dispose();
}

微软一厢情愿的希望通过using避免程序员忘记调用Dispose()去释放该类所占用的那些资源,包括托管的和非托管的(磁盘IO、网络IO、数据库连接IO等等),你通常会在关于磁盘操作的类、各种Stream、网络操作相关的类、数据库驱动类上找到这个方法。Dispose()里主要是替你Disconnet()/Close()掉这些资源,但是这些Dispose()方法常常是由微软之外的公司编写的,比如Oracle的.Net驱动。你能确信Oracle的程序员非常了解Dispose()在.net中的重要含义么?回头来说,就算是微软自己的程序员,难道就不会犯错误吗?跟lock中提到的SynRoot实现一样,你根本不知道你所使用类的Dispose()是否是正确的,也无法确保下一个版本的Dispose()不会悄悄的改变……对于这些敏感的资源,自己老老实实去Disconnect()/Close(),再老老实实的去Dispose()。事实上finally需要做的事情也往往不只是一个Dispose()。
  一句话,关于using,坚决反对。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值