打开APP
userphoto
未登录

开通VIP,畅享免费电子书等14项超值服

开通VIP
c# 线程同步系列(一)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


一个机会,索性把线程同步的问题在C#里面的东西都粗略看了下。

  第一印象,C#关于线程同步的东西好多,保持了C#一贯的大杂烩和四不象风格(Java/Delphi)。临界区跟Java差不多只不过关键字用lock替代了synchronized,然后又用Moniter的Wait/Pulse取代了Object的Wait/Notify,另外又搞出来几个Event……让人甚是不明了。不管那么多,一个一个来吧。

临界区(Critical Section)

  是一段在同一时候只被一个线程进入/执行的代码。为啥要有这个东西?

  1. 是因为这段代码访问了“临界资源”,而这种资源只能同时被互斥地访问。举个例子来说,你的银行账户就是一个互斥资源,一个银行系统里面改变余额(存取)的操作代码就必须用在临界区内。如果你的账户余额是$100,000(如果是真的,那么你就不用再往下看了,还是睡觉去吧),假设有两个人同时给你汇款$50,000。有两个线程分别执行这两笔汇款业务,线程A在获取了你的账户余额后,在它把新余额($150000)储存回数据库以前,操作系统把这个线程暂停转而把CPU的时间片分给另一个线程(是的,这太巧了);那么线程B此时取出的账户余额仍然是$10000,随后线程B幸运的得到的CPU时间把$50000存入你的账户,那么余额变成$150000。而此后某个时候,线程A再次得以执行,它也把“新”余额$150000更新到系统……于是你的$50000就这么凭空消失了。(此段省去常见到一个示例图,请自行想象)
  2. 是因为OS的多任务调度,其实在原因一里面已经提到。如果OS不支持多任务调度,那么线程A/线程B执行更新余额的操作总是一个接一个进行,那么完全不会有上面的问题了。在多线程的世界里,你必须随时做好你的代码执行过程随时失去控制的准备;你需要好好考虑当代码重新执行的时候,是否可以继续正确的执行。一句话,你的程序段在多线程的世界里,你所写的方法并不是“原子性”的操作。

Lock关键字

  C#提供lock关键字实现临界区,MSDN里给出的用法:

Object thisLock = new Object();
lock (thisLock)
{
   // Critical code section
}

  lock实现临界区是通过“对象锁”的方式,注意是“对象”,所以你只能锁定一个引用类型而不能锁定一个值类型。第一个执行该代码的线程,成功获取对这个对象的锁定,进而进入临界区执行代码。而其它线程在进入临界区前也会请求该锁,如果此时第一个线程没有退出临界区,对该对象的锁定并没有解除,那么当前线程会被阻塞,等待对象被释放。

  既然如此,在使用lock时,要注意不同线程是否使用同一个“锁”作为lock的对象。现在回头来看MSDN的这段代码似乎很容易让人误解,容易让人联想到这段代码是在某个方法中存在,以为thisLock是一个局部变量,而局部变量的生命周期是在这个方法内部,所以当不同线程调用这个方法的时候,他们分别请求了不同的局部变量作为锁,那么他们都可以分别进入临界区执行代码。因此在MSDN随后真正的示例中,thisLock实际上是一个 private的类成员变量:

using System;
using System.Threading;

class Account
{
    private Object thisLock = new Object();
    int balance;

    Random r = new Random();

    public Account(int initial)
    {
        balance = initial;
    }

    int Withdraw(int amount)
    {

        // This condition will never be true unless the lock statement
        // is commented out:
        if (balance < 0)
        {
            throw new Exception("Negative Balance");
        }

        // Comment out the next line to see the effect of leaving out
        // the lock keyword:
        lock(thisLock)
        {
            if (balance >= amount)
            {
                Console.WriteLine("Balance before Withdrawal :  " + balance);
                Console.WriteLine("Amount to Withdraw        : -" + amount);
                balance = balance - amount;
                Console.WriteLine("Balance after Withdrawal  :  " + balance);
                return amount;
            }
            else
            {
                return 0; // transaction rejected
            }
        }
    }

    public void DoTransactions()
    {
        for (int i = 0; i < 100; i++)
        {
            Withdraw(r.Next(1, 100));
        }
    }
}

class Test
{
    static void Main()
    {
        Thread[] threads = new Thread[10];
        Account acc = new Account(1000);
        for (int i = 0; i < 10; i++)
        {
            Thread t = new Thread(new ThreadStart(acc.DoTransactions));
            threads[i] = t;
        }
        for (int i = 0; i < 10; i++)
        {
            threads[i].Start();
        }
    }
}

  这个例子中,Account对象只有一个,所以临界区所请求的“锁”是唯一的,因此用类的成员变量是可以实现互斥意图的,其实用大家通常喜欢的 lock(this)也未尝不可,也即请求这个Account实例本身作为锁。但是如果在某种情况你的类实例并不唯一或者一个类的几个方法之间都必须要互斥,那么就要小心了。必须牢记一点,所有因为同一互斥资源而需要互斥的操作,必须请求“同一把锁”才有效。

  假设这个Account类并不只有一个Withdraw方法修改balance,而是用Withdraw()来特定执行取款操作,另有一个 Deposit()方法专门执行存款操作。很显然这两个方法必须是互斥执行的,所以这两个方法中所用到的锁也必须一致;不能一个用thisLock,另一个重新用一个private Object thisLock1 = new Object()。再进一步,其实这个操作场景下各个互斥区存在的目的是因为有“Balance”这个互斥资源,所有有关Balance的地方应该都是互斥的(如果你不介意读取操作读到的是脏数据的话,当然也可以不用)。

题外话:
  
这么看来其实用 Balance本身作为锁也许更为符合“逻辑”,lock住需要互斥的资源本身不是更好理解么?不过这里Balance是一个值类型,你并不能直接对它 lock(你可能需要用到volatile关键字,它能在单CPU的情况下确保只有一个线程修改一个变量)。

Lock使用的建议

  关于使用Lock微软给出的一些建议。你能够在MSDN上找到这么一段话:

  通常,应避免锁定 public 类型,否则实例将超出代码的控制范围。常见的结构 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 违反此准则:
  1.如果实例可以被公共访问,将出现 lock (this) 问题。
  2.如果 MyType 可以被公共访问,将出现 lock (typeof (MyType)) 问题。
   3.由于进程中使用同一字符串的任何其他代码将共享同一个锁,所以出现 lock("myLock") 问题。
  4.最佳做法是定义 private 对象来锁定, 或 private static 对象变量来保护所有实例所共有的数据。

  lock(this)的问题我是这么理解:

  1. 处于某种原因Account在整个程序空间内不是唯一,那么不同Account实例的相应方法就不可能互斥,因为他们请求的是不同Accout实例内部的不同的锁。这时候微软示例中的private Object thisLock仍然也避免不了这个问题,而需要使用private static Object thisLock来解决问题,因为static变量是所有类实例共享的。
  2. 猜想就算Account只有一个实例,但是如果在程序内部被多个处理不同任务的线程访问,那么Account实例可能会被某段代码直接作为锁锁定;这相当于你自己锁定了自己,而别人在不告诉你的情况下也可以能锁定你。这些情况都是你在写Account这个类的时候并没有办法作出预测的,所以你的 Withdraw代码可能被挂起,在多线程的复杂情况下也容易造成死锁。不管怎样,你写这段代码的时候肯定不会期待外部的代码跟你使用了同一把锁吧?这样很危险。另外,从面向对象来说,这等于把方法内部的东西隐式的暴露出去。为了实现互斥,专门建立不依赖系this的代码机制总是好的;thisLock,专事专用,是个好习惯。

   MyType的问题跟lock(this)差不多理解,不过比lock(this)更严重。因为Lock(typeof(MyType))锁定住的对象范围更为广泛,由于一个类的所有实例都只有一个类对象(就是拥有Static成员的那个对象实例),锁定它就锁定了该对象的所有实例。同时 lock(typeof(MyType))是个很缓慢的过程,并且类中的其他线程、甚至在同一个应用程序域中运行的其他程序都可以访问该类型对象,因此,它们都有可能锁定类对象,完全阻止你代码的执行,导致你自己代码的挂起或者死锁。

  至于lock("myLock"),是因为在.NET中字符串会被暂时存放。如果两个变量的字符串内容相同的话,.NET会把暂存的字符串对象分配给该变量。所以如果有两个地方都在使用lock(“my lock”)的话,它们实际锁住的是同一个对象。

.NET集合类对lock的支持

  在多线程环境中,常会碰到的互斥资源应该就是一些容器/集合。因此.NET在一些集合类中(比如 ArrayList,HashTable,Queue,Stack,包括新增的支持泛型的List)已经提供了一个供lock使用的对象 SyncRoot。

  在.Net1.1中大多数集合类的SyncRoot属性只有一行代码:return this,这样和lock(集合的当前实例)是一样的。不过ArrayList中的SyncRoot有所不同(这个并不是我反编译的,我并没有验证这个说法):

get

  if(this._syncRoot==null)
  {
    Interlocked.CompareExchange(refthis._syncRoot,newobject(),null);
  }
  returnthis._syncRoot;
}

题外话:
  上面反编译的 ArrayList的代码,引出了个Interlocked类,即互锁操作,用以对某个内存位置执行的简单原子操作。举例来说在大多数计算机上,增加变量操作不是一个原子操作,需要执行下列步骤:

  1. 将实例变量中的值加载到寄存器中。
  2. 增加或减少该值。
  3. 在实例变量中存储该值。

  线程可能会在执行完前两个步骤后被夺走CPU时间,然后由另一个线程执行所有三个步骤。当第一个线程重新再开始执行时,它改写实例变量中的值,造成第二个线程执行增减操作的结果丢失。这根我们上面提到的银行账户余额的例子是一个道理,不过是更微观上的体现。我们使用该类提供了的 Increment和Decrement方法就可以避免这个问题。
  另外,Interlocked类上提供了其它一些能保证对相关变量的操作是原子性的方法。如Exchange()可以保证指定变量的值交换操作的原子性,Read()保证在32位操作系统中对64位变量的原子读取。而这里使用的 CompareExchange方法组合了两个操作:保证了比较和交换操作按原子操作执行。此例中CompareExchange方法将当前 syncRoot和null做比较,如果相等,就用new object()替换SyncRoot。
  在现代处理器中,Interlocked 类的方法经常可以由单个指令来实现,因此它们的执行性能非常高。虽然Interlocked没有直接提供锁定或者发送信号的能力,但是你可以用它编写锁和信号,从而编写出高效的非阻止并发的应用程序。但是这需要复杂的低级别编程能力,因此大多数情况下使用lock或其它简单锁是更好的选择。

 

  看到这里是不是已经想给微软一耳光了?一边教导大家不要用lock(this),一边竟然在基础类库中大量使用……呵呵,我只能说据传从.Net2.0开始SyncRoot已经是会返回一个单独的类了,想来大约应该跟ArrayList那种实现差不多,有兴趣的可以反编译验证下。

  这里想说,代码是自己的写的,最好减少自己代码对外部环境的依赖,事实证明即便是.Net基础库也不是那么可靠。自己能想到的问题,最好自己写代码去处理,需要锁就自己声明一个锁;不再需要一个资源那么自己代码去Dispose掉(如果是实现IDisposable接口的)……不要想着什么东西系统已经帮你做了。你永远无法保证你的类将会在什么环境下被使用,你也无法预见到下一版的Framework是否偷偷改变了实现。当你代码莫名其妙不 Work的时候,你是很难找出由这些问题引发的麻烦。只有你代码足够的独立(这里没有探讨代码耦合度的问题),才能保证它足够的健壮;别人代码的修改(哪怕是你看来“不当”的修改),造成你的Code无法工作不是总有些可笑么(我还想说“苍蝇不叮无缝的蛋”“不要因为别人的错误连累自己”)?

  一些集合类中还有一个方法是和同步相关的:Synchronized,该方法返回一个集合的内部类,该类是线程安全的,因为他的大部分方法都用 lock来进行了同步处理(你会不会想那么SyncRoot显得多余?别急。)。比如,Add方法会类似于:

public override void Add(objectkey,objectvalue) 

  lock(this._table.SyncRoot)
  {
    this._table.Add(key,value);
  } 
}

  不过即便是这个Synchronized集合,在对它进行遍历时,仍然不是一个线程安全的过程。当你遍历它时,其他线程仍可以修改该它(Add、Remove),可能会导致诸如下标越界之类的异常;就算不出错,你也可能读到脏数据。若要在遍历过程中保证线程安全,还必须在整个遍历过程中锁定集合,我想这才是SynRoot存在的目的吧:

Queue myCollection = newQueue();
lock(myCollection.SyncRoot)
{
  foreach(ObjectiteminmyCollection)
  { 
    //Insert your code here.
  } 
}

  提供SynRoot是为了把这个已经“线程安全”的集合内部所使用的“锁”暴露给你,让你和它内部的操作使用同一把锁,这样才能保证在遍历过程互斥掉其它操作,保证你在遍历的同时没有可以修改。另一个可以替代的方法,是使用集合上提供的静态ReadOnly()方法,来返回一个只读的集合,并对它进行遍历,这个返回的只读集合是线程安全的。

  到这里似乎关于集合同步的方法似乎已经比较清楚了,不过如果你是一个很迷信MS基础类库的人,那么这次恐怕又会失望了。微软决定所有从那些自 Framwork 3.0以来加入的支持泛型的集合中,如List,取消掉创建同步包装器的能力,也就是它们不再有Synchronized,IsSynchronized 也总会返回false;而ReadOnly这个静态方法也变为名为AsReadOnly的实例方法。作为替代,MS建议你仍然使用lock关键字来锁定整个集合。

  至于List之类的泛型集合SyncRoot是怎样实现的,MSDN是这样描述的“在 List<(Of <(T>)>) 的默认实现中,此属性始终返回当前实例。”,赶紧去吐血吧!

自己的SyncRoot

还是上面提过的老话,靠自己,以不变应万变:

public class MySynchronizedList
{
  private readonly object syncRoot = new object();
  private readonly List<intlist = new List<int>();

  public object SyncRoot
  {
    get{return this.syncRoot;}
  }

  public void Add(int i)
  {
    lock(syncRoot)
    {
      list.Add(i);
    }
  }

  //...
}

自已写一个类,用自己的syncRoot封装一个线程安全的容器。

临界区&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):  释放对象上的锁并阻塞当前线程,直到它重新获取该锁。
    1. 这里的阻塞是指当前线程进入“WaitSleepJoin”状态,此时CPU不再会分配给这种状态的线程CPU时间片,这其实跟在线程上调用 Sleep()时的状态一样。这时,线程不会参与对该锁的分配争夺。
    2. 要打破这种状态,需要其它拥有该对象锁的线程,调用下面要讲到的Pulse()来唤醒。不过这与,Sleep()不同,只有那些因为该对象锁阻塞的线程才会被唤醒。此时,线程重新进入“Running”状态,参与对对象锁的争夺。
    3. 强调一下,Wait()其实起到了Exit()的作用,也就是释放当前所获得的对象锁。只不过Wait()同时又阻塞了自己。
    4. 我们还看到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,坚决反对。

  就到这里吧,好累~

本站仅提供存储服务,所有内容均由用户发布,如发现有害或侵权内容,请点击举报
打开APP,阅读全文并永久保存 查看更多类似文章
猜你喜欢
类似文章
【热】打开小程序,算一算2024你的财运
(六)线程--分别用lock以及Interlocked和Monitor类实现线程的临界区操作(互斥)(示例下载)
pthread
浅谈进程同步和互斥的概念
C#线程同步(4)
只会用wait和notify?30分钟案例告诉你有更好得选择
C# 线程同步之Monitor
更多类似文章 >>
生活服务
热点新闻
分享 收藏 导长图 关注 下载文章
绑定账号成功
后续可登录账号畅享VIP特权!
如果VIP功能使用有故障,
可点击这里联系客服!

联系客服