在《多线程编程》系列第一篇讲述了如何启动线程,这篇讲述线程之间存在竞争时如何确保同步并且不发生死锁。
线程不同步引出的问题
下面做一个假设,假设有100张票,由两个线程来实现一个售票程序,每次线程运行时首先检查是否还有票未售出,如果有就按照票号从小到大的顺序售出票号最小的票,程序的代码如下:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace StartThread { public class ThreadLock { private Thread threadOne; private Thread threadTwo; private List ticketList; private object objLock = new object(); public ThreadLock() { threadOne = new Thread(new ThreadStart(Run)); threadOne.Name = "Thread_1"; threadTwo = new Thread(new ThreadStart(Run)); threadTwo.Name = "Thread_2"; } public void Start() { ticketList = new List(100); for (int i = 1; i <= 100; i++) { ticketList.Add(i.ToString().PadLeft(3,'0'));//实现3位的票号,如果不足3位数,则以0补足3位 } threadOne.Start(); threadTwo.Start(); } private void Run() { while (ticketList.Count > 0)//① { string ticketNo = ticketList[0];//② Console.WriteLine("{0}:售出一张票,票号:{1}", Thread.CurrentThread.Name, ticketNo); ticketList.RemoveAt(0);//③ Thread.Sleep(1); } } } } |
这段程序的执行效果并不每次都一样,下图是某次运行效果的截图:
从上图可以看出票号为001的号被售出了两次(如果遇上像《无极》中谢霆锋饰演的那种角色,可能又会引出一场《一张票引发的血案》了,呵呵),为什么会出现这种情况呢?
请看代码③处:
ticketList.RemoveAt(0);//③ |
在某个情况有可能线程1恰好运行到此处,从ticketList中取出索引为0的那个元素并将票号输出,不巧的是正好分给线程1执行的时间片已用完,线程1进入休眠状态,线程2从头开始执行,它可以从容地从ticketList中取出索引为0的那个元素并且将其输出,因为线程1执行的时候虽然输出了ticketList中索引为0的那个元素但是来不及将其删除,所以这时候线程2得到的值和上次线程1得到的值一致,这就出现了有些票被售出了两次、有些票可能根本就没有售出的情况。
出现这种情况的根本原因就是多个线程都是对同一资源进行操作所致,所以在多线程编程应尽可能避免这种情况,当然有些情况下确实避免不了这种情况,这就需要对其采用一些手段来确保不会出现这种情况,这就是所谓的线程的同步。
在C#中实现线程的同步有几种方法:lock、Mutex、Monitor、Semaphore、Interlocked和ReaderWriterLock等。同步策略也可以分为同步上下文、同步代码区、手动同步几种方式。
同步上下文
同步上下文的策略主要是依靠SynchronizationAttribute类来实现。例如下面的代码就是一个实现了上下文同步的类的代码:
using System; using System.Collections.Generic; using System.Text; //需要添加对System.EnterpriseServices.dll这个类库的引用采用使用这个dll using System.EnterpriseServices; namespace StartThread { [Synchronization(SynchronizationOption.Required)]//确保创建的对象已经同步 public class SynchronizationAttributeClass { public void Run() { } } } |
所有在同一个上下文域的对象共享同一个锁。这样创建的对象实例属性、方法和字段就具有线程安全性,需要注意的是类的静态字段、属性和方法是不具有线程安全性的。
同步代码区
同步代码区是另外一种策略,它是针对特定部分代码进行同步的一种方法。
lock同步
针对上面的代码,要实现不会出现混乱(两次卖出同一张票或者有些票根本就没有卖出),可以lock关键字来实现,出现问题的部分就是在于判断剩余票数是否大于0,如果大于0则从当前总票数中减去最大的一张票,因此可以对这部分进行处理,代码如下:
private void Run() { while (ticketList.Count > 0)//① { lock (objLock) { if (ticketList.Count > 0) { string ticketNo = ticketList[0];//② Console.WriteLine("{0}:售出一张票,票号:{1}", Thread.CurrentThread.Name, ticketNo); ticketList.RemoveAt(0);//③ Thread.Sleep(1); } } } } |
经过这样处理之后系统的运行结果就会正常。效果如下:
总的来说,lock语句是一种有效的、不跨越多个方法的小代码块同步的做法,也就是使用lock语句只能在某个方法的部分代码之间,不能跨越方法。
Monitor类
针对上面的代码,如果使用Monitor类来同步的话,代码则是如下效果:
private void Run() { while (ticketList.Count > 0)//① { Monitor.Enter(objLock); if (ticketList.Count > 0) { string ticketNo = ticketList[0];//② Console.WriteLine("{0}:售出一张票,票号:{1}", Thread.CurrentThread.Name, ticketNo); ticketList.RemoveAt(0);//③ Thread.Sleep(1); } Monitor.Exit(objLock); } } |
当然这段代码最终运行的效果也和使用lock关键字来同步的效果一样。比较之下,大家会发现使用lock关键字来保持同步的差别不大:”lock (objLock){“被换成了”Monitor.Enter(objLock);”,”}”被换成了” Monitor.Exit(objLock);”。实际上如果你通过其它方式查看最终生成的IL代码,你会发现使用lock关键字的代码实际上是用Monitor来实现的。
如下代码:
lock (objLock){ //同步代码 } 实际上是相当于: try{ Monitor.Enter(objLock); //同步代码 } finally { Monitor.Exit(objLock); } |
我们知道在绝大多数情况下finally中的代码块一定会被执行,这样确保了即使同步代码出现了异常也仍能释放同步锁。
Monitor类除了Enter()和Exit()方法之外,还有Wait()和Pulse()方法。Wait()方法是临时释放当前活得的锁,并使当前对象处于阻塞状态,Pulse()方法是通知处于等待状态的对象可以准备就绪了,它一会就会释放锁。下面我们利用这两个方法来完成一个协同的线程,一个线程负责随机产生数据,一个线程负责将生成的数据显示出来。下面是代码:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace StartThread { public class ThreadWaitAndPluse { private object lockObject; private int number; private Random random; public ThreadWaitAndPluse() { lockObject = new object(); random = new Random(); } //显示生成数据的线程要执行的方法 public void ThreadMethodOne() { Monitor.Enter(lockObject);//获取对象锁 Console.WriteLine("当前进入的线程:" + Thread.CurrentThread.GetHashCode()); for (int i = 0; i < 5; i++) { Monitor.Wait(lockObject);//释放对象锁,并阻止当前线程 Console.WriteLine("WaitAndPluse1:工作"); Console.WriteLine("WaitAndPluse1:得到了数据,number=" + number + ",Thread ID=" + Thread.CurrentThread.GetHashCode()); //通知其它等待锁的对象状态已经发生改变,当这个对象释放锁之后等待锁的对象将会活得锁 Monitor.Pulse(lockObject); } Console.WriteLine("退出当前线程:" + Thread.CurrentThread.GetHashCode()); Monitor.Exit(lockObject);//释放对象锁 } //生成随机数据线程要执行的方法 public void ThreadMethodTwo() { Monitor.Enter(lockObject);//获取对象锁 Console.WriteLine("当前进入的线程:" + Thread.CurrentThread.GetHashCode()); for (int i = 0; i < 5; i++) { //通知其它等待锁的对象状态已经发生改变,当这个对象释放锁之后等待锁的对象将会活得锁 Monitor.Pulse(lockObject); Console.WriteLine("WaitAndPluse2:工作"); number =random.Next(DateTime.Now.Millisecond);//生成随机数 Console.WriteLine("WaitAndPluse2:生成了数据,number=" + number + ",Thread ID=" + Thread.CurrentThread.GetHashCode()); Monitor.Wait(lockObject);//释放对象锁,并阻止当前线程 } Console.WriteLine("退出当前线程:" + Thread.CurrentThread.GetHashCode()); Monitor.Exit(lockObject);//释放对象锁 } public static void Main() { ThreadWaitAndPluse demo=new ThreadWaitAndPluse(); Thread t1 = new Thread(new ThreadStart(demo.ThreadMethodOne)); t1.Start(); Thread t2 = new Thread(new ThreadStart(demo.ThreadMethodTwo)); t2.Start(); Console.ReadLine(); } } } |
执行上面的代码在大部分情况下会看到如下所示的结果:
一般情况下会看到上面的结果,原因是t1的Start()方法在先,所以一般会优先活得执行,t1执行后首先获得对象锁,然后在循环中通过 Monitor.Wait(lockObject)方法临时释放对象锁,t1这时处于阻塞状态;这样t2获得对象锁并且得以执行,t2进入循环后通过Monitor.Pulse(lockObject)方法通知等待同一个对象锁的t1准备好,然后在生成随机数之后临时释放对象锁;接着t1获得了对象锁,执行输出t2生成的数据,之后t1通过 Monitor.Wait(lockObject)通知t2准备就绪,并在下一个循环中通过 Monitor.Wait(lockObject)方法临时释放对象锁,就这样t1和t2交替执行,得到了上面的结果。
当然在某些情况下,可能还会看到如下的结果:
至于为什么会产生这个结果,原因其实很简单,尽管t1.Start()出现在t2.Start()之前,但是并不能就认为t1一定会比t2优先执行(尽管可能在大多数情况下是),还要考虑线程调度问题,使用了多线程之后就会使代码的执行顺序变得复杂起来。在某种情况下t1和t2对锁的使用产生了冲突,形成了死锁,也就出现了如上图所示的情况,为了避免这种情况可以通过让t2延时一个合适的时间
手控同步
手控同步是指使用不同的同步类来创建自己的同步机制。使用这种策略要求手动地为不同的域或者方法同步。
ReaderWriterLock
ReaderWriterLock支持单个写线程和多个读线程的锁。在任一特定时刻允许多个线程同时进行读操作或者一个线程进行写操作,使用ReaderWriterLock来进行读写同步比使用监视的方式(如Monitor)效率要高。
下面是一个例子,在例子中使用了两个读线程和一个写线程,代码如下:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace StartThread { public class ReadWriteLockDemo { private int number; private ReaderWriterLock rwl; private Random random; public ReadWriteLockDemo() { rwl = new ReaderWriterLock(); random = new Random(); } /// /// 读线程要执行的方法 /// public void Read() { Thread.Sleep(10);//暂停,确保写线程优先执行 for (int i = 0; i < 5; i++) { rwl.AcquireReaderLock(Timeout.Infinite); Console.WriteLine("Thread" + Thread.CurrentThread.GetHashCode() + "读出数据,number=" + number); Thread.Sleep(500); rwl.ReleaseReaderLock(); } } /// /// 写线程要执行的方法 /// public void Write() { for (int i = 0; i < 5; i++) { rwl.AcquireWriterLock(Timeout.Infinite); number = random.Next(DateTime.Now.Millisecond); Thread.Sleep(100); Console.WriteLine("Thread" + Thread.CurrentThread.GetHashCode() + "写入数据,number=" + number); rwl.ReleaseWriterLock(); } } public static void Main() { ReadWriteLockDemo rwld=new ReadWriteLockDemo(); Thread reader1 = new Thread(new ThreadStart(rwld.Read)); Thread reader2 = new Thread(new ThreadStart(rwld.Read)); reader1.Start(); reader2.Start(); Thread writer1 = new Thread(new ThreadStart(rwld.Write)); writer1.Start(); Console.ReadLine(); } } } |
程序的执行效果如下:
WaitHandle
WaitHandle类是一个抽线类,有多个类直接或者间接继承自WaitHandle类,类图如下:
在WaitHandle类中SignalAndWait、WaitAll、WaitAny及WaitOne这几个方法都有重载形式,其中除WaitOne之外都是静态的。WaitHandle方法常用作同步对象的基类。WaitHandle对象通知其他的线程它需要对资源排他性的访问,其他的线程必须等待,直到WaitHandle不再使用资源和等待句柄没有被使用。
WaitHandle方法有多个Wait的方法,这些方法的区别如下:
WaitAll:等待指定数组中的所有元素收到信号。
WaitAny:等待指定数组中的任一元素收到信号。
WaitOne:当在派生类中重写时,阻塞当前线程,直到当前的 WaitHandle 收到信号。
这些wait方法阻塞线程直到一个或者更多的同步对象收到信号。
下面的是一个MSDN中的例子,讲的是一个计算过程,最终的计算结果为第一项+第二项+第三项,在计算第一、二、三项时需要使用基数来进行计算。在代码中使用了线程池也就是ThreadPool来操作,这里面涉及到计算的顺序的先后问题,通过WaitHandle及其子类可以很好地解决这个问题。
代码如下:
using System; using System.Collections.Generic; using System.Text; using System.Threading; namespace StartThread { //下面的代码摘自MSDN,笔者做了中文代码注释 //周公 public class EventWaitHandleDemo { double baseNumber, firstTerm, secondTerm, thirdTerm; AutoResetEvent[] autoEvents; ManualResetEvent manualEvent; //产生随机数的类. Random random; static void Main() { EventWaitHandleDemo ewhd = new EventWaitHandleDemo(); Console.WriteLine("Result = {0}.", ewhd.Result(234).ToString()); Console.WriteLine("Result = {0}.", ewhd.Result(55).ToString()); Console.ReadLine(); } //构造函数 public EventWaitHandleDemo() { autoEvents = new AutoResetEvent[] { new AutoResetEvent(false), new AutoResetEvent(false), new AutoResetEvent(false) }; manualEvent = new ManualResetEvent(false); } //计算基数 void CalculateBase(object stateInfo) { baseNumber = random.NextDouble(); //指示基数已经算好. manualEvent.Set(); } //计算第一项 void CalculateFirstTerm(object stateInfo) { //生成随机数 double preCalc = random.NextDouble(); //等待基数以便计算. manualEvent.WaitOne(); //通过preCalc和baseNumber计算第一项. firstTerm = preCalc * baseNumber *random.NextDouble(); //发出信号指示计算完成. autoEvents[0].Set(); } //计算第二项 void CalculateSecondTerm(object stateInfo) { double preCalc = random.NextDouble(); manualEvent.WaitOne(); secondTerm = preCalc * baseNumber *random.NextDouble(); autoEvents[1].Set(); } //计算第三项 void CalculateThirdTerm(object stateInfo) { double preCalc = random.NextDouble(); manualEvent.WaitOne(); thirdTerm = preCalc * baseNumber *random.NextDouble(); autoEvents[2].Set(); } //计算结果 public double Result(int seed) { random = new Random(seed); //同时计算 ThreadPool.QueueUserWorkItem(new WaitCallback(CalculateFirstTerm)); ThreadPool.QueueUserWorkItem(new WaitCallback(CalculateSecondTerm)); ThreadPool.QueueUserWorkItem(new WaitCallback(CalculateThirdTerm)); ThreadPool.QueueUserWorkItem(new WaitCallback(CalculateBase)); //等待所有的信号. WaitHandle.WaitAll(autoEvents); //重置信号,以便等待下一次计算. manualEvent.Reset(); //返回计算结果 return firstTerm + secondTerm + thirdTerm; } } } |
程序的运行结果如下:
Result = 0.355650523270459. Result = 0.125205692112756. |
当然因为引入了随机数,所以每次计算结果并不相同,这里要讲述的是它们之间的控制。首先在 Result(int seed)方法中讲计算基数、第一项、第二项及第三项的方法放到线程池中,要计算第一二三项时首先要确定基数,这些方法通过manualEvent.WaitOne()暂时停止执行,于是计算基数的方法首先执行,计算出基数之后通过manualEvent.Set()方法通知计算第一二三项的方法开始,在这些方法完成计算之后通过autoEvents数组中的AutoResetEvent元素的Set()方法发出信号,标识执行完毕。这样WaitHandle.WaitAll(autoEvents)这一步可以继续执行,从而得到执行结果。
在上面代码中的WaitHandle的其它子类限于篇幅不在这里一一举例讲解,它们在使用了多少有些相似之处(毕竟是一个爹、从一个抽象类继承下来的嘛)。