多線程程式設計(2):線程的同步
在《多線程程式設計》系列第一篇講述了如何啟動線程,這篇講述線程之間存在競争時如何確定同步并且不發生死鎖。
線程不同步引出的問題
下面做一個假設,假設有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<string> 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<string>(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類來實作。例如下面的代碼就是一個實作了上下文同步的類的代碼:
//需要添加對System.EnterpriseServices.dll這個類庫的引用采用使用這個dll
using System.EnterpriseServices;
[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)
}
經過這樣處理之後系統的運作結果就會正常。效果如下:
總的來說,lock語句是一種有效的、不跨越多個方法的小代碼塊同步的做法,也就是使用lock語句隻能在某個方法的部分代碼之間,不能跨越方法。
Monitor類
針對上面的代碼,如果使用Monitor類來同步的話,代碼則是如下效果:
Monitor.Enter(objLock);
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()方法是通知處于等待狀态的對象可以準備就緒了,它一會就會釋放鎖。下面我們利用這兩個方法來完成一個協同的線程,一個線程負責随機産生資料,一個線程負責将生成的資料顯示出來。下面是代碼:
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()
Console.WriteLine("WaitAndPluse2:工作");
number =random.Next(DateTime.Now.Millisecond);//生成随機數
Console.WriteLine("WaitAndPluse2:生成了資料,number=" + number + ",Thread ID=" + Thread.CurrentThread.GetHashCode());
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)效率要高。
下面是一個例子,在例子中使用了兩個讀線程和一個寫線程,代碼如下:
public class ReadWriteLockDemo
private ReaderWriterLock rwl;
public ReadWriteLockDemo()
rwl = new ReaderWriterLock();
/// <summary>
/// 讀線程要執行的方法
/// </summary>
public void Read()
Thread.Sleep(10);//暫停,確定寫線程優先執行
rwl.AcquireReaderLock(Timeout.Infinite);
Console.WriteLine("Thread" + Thread.CurrentThread.GetHashCode() + "讀出資料,number="+ number);
Thread.Sleep(500);
rwl.ReleaseReaderLock();
/// 寫線程要執行的方法
public void Write()
rwl.AcquireWriterLock(Timeout.Infinite);
number = random.Next(DateTime.Now.Millisecond);
Thread.Sleep(100);
Console.WriteLine("Thread" + Thread.CurrentThread.GetHashCode() + "寫入資料,number="+ number);
rwl.ReleaseWriterLock();
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();
程式的執行效果如下:
WaitHandle
WaitHandle類是一個抽線類,有多個類直接或者間接繼承自WaitHandle類,類圖如下:
在WaitHandle類中SignalAndWait、WaitAll、WaitAny及WaitOne這幾個方法都有重載形式,其中除WaitOne之外都是靜态的。WaitHandle方法常用作同步對象的基類。WaitHandle對象通知其他的線程它需要對資源排他性的通路,其他的線程必須等待,直到WaitHandle不再使用資源和等待句柄沒有被使用。
WaitHandle方法有多個Wait的方法,這些方法的差別如下:
WaitAll:等待指定數組中的所有元素收到信号。
WaitAny:等待指定數組中的任一進制素收到信号。
WaitOne:當在派生類中重寫時,阻塞目前線程,直到目前的 WaitHandle 收到信号。
這些wait方法阻塞線程直到一個或者更多的同步對象收到信号。
下面的是一個MSDN中的例子,講的是一個計算過程,最終的計算結果為第一項+第二項+第三項,在計算第一、二、三項時需要使用基數來進行計算。在代碼中使用了線程池也就是ThreadPool來操作,這裡面涉及到計算的順序的先後問題,通過WaitHandle及其子類可以很好地解決這個問題。
代碼如下:
//下面的代碼摘自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());
ewhd.Result(55).ToString());
//構造函數
public EventWaitHandleDemo()
autoEvents = new AutoResetEvent[]
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)
secondTerm = preCalc * baseNumber *random.NextDouble();
autoEvents[1].Set();
//計算第三項
void CalculateThirdTerm(object stateInfo)
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的其它子類限于篇幅不在這裡一一舉例講解,它們在使用了多少有些相似之處(畢竟是一個爹、從一個抽象類繼承下來的嘛)。
本文轉自周金橋51CTO部落格,原文連結: http://blog.51cto.com/zhoufoxcn/262608,如需轉載請自行聯系原作者