在多線程代碼中,多個線程可能會訪問一些公共的資源(變量、方法邏輯等等),這些公共資源稱為臨界區(共享區);臨界區的資源是不安全,所以需要通過線程同步對多個訪問臨界區的線程進行控制。
同樣,有些時候我們需要多個線程按照特定的順序執行,這時候,我們也需要進行線程同步。
下面,我們就看看C#中通過lock和Monitor進行線程同步。
lock關鍵字
lock是一種非常簡單而且經常使用的線程同步方式,lock 關鍵字將語句塊標記為臨界區。 lock 確保當一個線程位於代碼的臨界區時,另一個線程不能進入臨界區。如果其他線程試圖進入鎖定的代碼,則它將一直等待,直到該對象被釋放。
下面看一個簡單的例子:
namespace LockTest { class PrintNum { private object lockObj = new object(); public void PrintOddNum() { lock (lockObj) { Console.WriteLine("Print Odd numbers:"); for (int i = 0; i < 10; i++) { if(i%2 != 0) Console.Write(i); Thread.Sleep(100); } Console.WriteLine(); } } } class Program { static void Main(string[] args) { PrintNum printNum = new PrintNum(); for (int i = 0; i < 3; i++) { Thread temp = new Thread(new ThreadStart(printNum.PrintOddNum)); temp.Start(); } Console.Read(); } } }
這段代碼比較容易理解,我們通過lock關鍵字把打印奇數的邏輯包在了臨界區中,這樣就可以保證同時只用一個線程執行臨界區中的邏輯,代碼打印如下:

使用lock的注意點
lock關鍵字的使用還是比較簡單的,但是使用lock的時候還是有一些需要注意的地方。lock關鍵字可以鎖住任何object類型及其派生類,但是盡量不要用public 類型的,否則實例將超出代碼的控制范圍。根據MSDN,常見的結構 lock (this)、lock (typeof (MyType)) 和 lock ("myLock") 違反此准則:
- 如果實例可以被公共訪問,將出現 lock (this) 問題。
- 如果 MyType 可以被公共訪問,將出現 lock (typeof (MyType)) 問題。
- 由於進程中使用同一字符串的任何其他代碼將共享同一個鎖,所以出現 lock("myLock") 問題。
下面舉個例子看看lock(this)的問題,假如我們把PrintOddNum中改成lock(this),並且在主線程中使用lock (printNum)。
namespace LockTest { class PrintNum { private object lockObj = new object(); public void PrintOddNum() { lock (this) { Console.WriteLine("Print Odd numbers:"); for (int i = 0; i < 10; i++) { if (i % 2 != 0) Console.Write(i); Thread.Sleep(100); } Console.WriteLine(); } } } class Program { static void Main(string[] args) { PrintNum printNum = new PrintNum(); for (int i = 0; i < 3; i++) { Thread temp = new Thread(new ThreadStart(printNum.PrintOddNum)); temp.Start(); } lock (printNum) { Thread.Sleep(5000); Console.WriteLine("Main thread will delay 5 seconds"); } Console.Read(); } } }
代碼的輸出可能如下,因為Main函數和PrintNum類型中都對printNum對象進行了加鎖,所以當主線程獲得了互斥鎖之后,其他子線程都被block住了,沒有辦法執行PrintOddNum方法了。

所以說,最好定義 private 對象 或 private static 對象進行上鎖,從而保護所有實例所共有的數據。
lock的本質
lock關鍵字其實是一個語法糖,如果過查看IL代碼,會發現lock 調用塊開始位置為Monitor::Enter,塊結束位置為Monitor::Exit。
為了保證Exit方法肯定會被調用,還專門用了一個try/finally語句塊,這樣即使代碼出現了異常,也能保證Monitor::Exit能夠被調用到。
.try { IL_0003: ldarg.0 IL_0004: ldfld object LockTest.PrintNum::lockObj IL_0009: dup IL_000a: stloc.2 IL_000b: ldloca.s '<>s__LockTaken0' IL_000d: call void [mscorlib]System.Threading.Monitor::Enter(object, bool&) IL_0012: nop IL_0013: nop IL_0014: ldstr "Print Odd numbers:" IL_0019: call void [mscorlib]System.Console::WriteLine(string) …… IL_0052: leave.s IL_0064 } // end .try finally { IL_0054: ldloc.1 IL_0055: ldc.i4.0 IL_0056: ceq IL_0058: stloc.3 IL_0059: ldloc.3 IL_005a: brtrue.s IL_0063 IL_005c: ldloc.2 IL_005d: call void [mscorlib]System.Threading.Monitor::Exit(object) IL_0062: nop IL_0063: endfinally } // end handler
那么下面我們就看看如何通過Monitor來進行線程同步。
Monitor類型
Monitor類通過互斥鎖來進行對共享區的同步,當一個線程進入共享區時,會取得互斥鎖的控制權,其他線程則必須等待。
前面了解到了,lock關鍵字就是一個語法糖,實際上lock使用的就是Monitor類型的Enter和Exit方法。很多情況下lock就可以滿足需求了,但是當我們需要更進一步的線程同步時,就需要使用Monitor類型了。
下面看看Monitor類型的主要方法:
-
public static void Enter(object obj);
- 在指定對象上獲取互斥鎖
-
public static void Exit(object obj);
- 釋放指定對象上的互斥鎖
-
public static void Pulse(object obj);
- 通知等待隊列中的線程鎖定對象狀態的更改
-
public static bool TryEnter(object obj);
- 試圖獲取指定對象的互斥鎖,如果獲得了互斥鎖就返回true;否則返回false
- TryEnter(Object, Int32)形式,表示在指定的毫秒數內嘗試獲取指定對象上的互斥鎖
-
public static bool Wait(object obj);
- 釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖
對於Enter和Exit,就不進行更多的介紹了,下面看看Pulse、Wait和TryEnter的使用。
Pulse和Wait
上面對Pulse和Wait方法的介紹還是很抽象的,下面進一步了解Pulse和Wait。
- Wait:當線程調用 Wait 時,它釋放對象的鎖並進入等待隊列。對象的就緒隊列中的下一個線程(如果有)獲取鎖並擁有對對象的獨占使用。所有調用 Wait 的線程都將留在等待隊列中,直到它們接收到由鎖的所有者發送的 Pulse 或 PulseAll 的信號為止。
- Pulse:只有鎖的當前所有者可以使用 Pulse 向等待對象發出信號。如果發送了 Pulse,則只影響位於等待隊列最前面的線程。如果發送了 PulseAll,則將影響正等待該對象的所有線程。接收到信號后,一個或多個線程將離開等待隊列而進入就緒隊列。 在調用 Pulse 的線程釋放鎖后,就緒隊列中的下一個線程(不一定是接收到脈沖的線程)將獲得該鎖。
使用注意事項:
- 在使用Enter和Exit方法的時候,建議像lock的IL代碼一樣,使用try/finally語句塊對Enter和Exit進行包裝。
- Pulse 、PulseAll 和 Wait 方法必須從同步的代碼塊內調用。
- 在使用Pulse/Wait進行線程同步的時候,一定要牢記,Monitor 類不對指示 Pulse 方法已被調用的狀態進行維護。 因此,如果在沒有等待線程時調用 Pulse,則下一個調用 Wait 的線程將阻止,似乎 Pulse 從未被調用過。 如果兩個線程正在使用 Pulse 和 Wait 交互,則可能導致死鎖。
下面看一個例子,模擬一個回合制的對打游戲,超人大戰蜘蛛俠,通過Pulse/Wait,保證兩人交替出招。
namespace MointorTest { class GamePlayer { public string PlayerName { get; set; } public string EnemyName { get; set; } } class Program { private static object monitorObj = new object(); private static int bloodAttack = 0; static void Main(string[] args) { GamePlayer spiderMan = new GamePlayer { PlayerName = "Spider Man", EnemyName = "Super Man" }; Thread spiderManThread = new Thread(new ParameterizedThreadStart(GameAttack)); GamePlayer superMan = new GamePlayer { PlayerName = "Super Man", EnemyName = "Spider Man" }; Thread superManThread = new Thread(new ParameterizedThreadStart(GameAttack)); spiderManThread.Start(spiderMan); superManThread.Start(superMan); spiderManThread.Join(); superManThread.Join(); Console.WriteLine("Game Over"); Console.Read(); } public static void GameAttack(object param) { GamePlayer gamePlayer = (GamePlayer)param; try { Monitor.Enter(monitorObj); int blood = 100; Random ran = new Random(); while (blood > 0 && bloodAttack >= 0) { blood -= bloodAttack; if (blood > 0) { bloodAttack = ran.Next(100); Console.WriteLine("{0}'s blood is {1}, attack {2} {3}", gamePlayer.PlayerName, blood, gamePlayer.EnemyName, bloodAttack); } else { Console.WriteLine("{0} is dead!!!", gamePlayer.PlayerName); bloodAttack = -1; } Thread.Sleep(1000); Monitor.Pulse(monitorObj); Monitor.Wait(monitorObj); } } finally { Monitor.PulseAll(monitorObj); Monitor.Exit(monitorObj); } } } }
代碼的輸出為下,注意在finally語句塊中加入了"Monitor.PulseAll(monitorObj);",這樣可以確保最后一次在等待隊列中的線程可以順利執行到最后。

TryEnter避免死等
當我們使用lock的時候,沒有獲得互斥鎖的線程會一直等待,知道該線程獲得互斥鎖為止。這樣就產生了線程死等的現象。
但是,在Monitor類型中,有了一個TryEnter(Object, Int32)方法,線程會嘗試等待一段時間來獲取互斥鎖,如果超時仍未獲得互斥鎖,那么該方法就會返回false。
下面看一個例子:
namespace MointorTest { class Program { private static object monitorObj = new object(); static void Main(string[] args) { Thread firstThread = new Thread(new ThreadStart(TryEnterTest)); firstThread.Name = "firstThread"; Thread secondThread = new Thread(new ThreadStart(TryEnterTest)); secondThread.Name = "secondThread"; firstThread.Start(); secondThread.Start(); Console.Read(); } public static void TryEnterTest() { if (!Monitor.TryEnter(monitorObj, 5000)) { Console.WriteLine("Thread {0} wait 5 seconds, didn't get the lock", Thread.CurrentThread.Name); Console.WriteLine("Thread {0} completed!", Thread.CurrentThread.Name); return; } try { Monitor.Enter(monitorObj); Console.WriteLine("Thread {0} get the lock and will run 10 seconds", Thread.CurrentThread.Name); Thread.Sleep(10000); Console.WriteLine("Thread {0} completed!", Thread.CurrentThread.Name); } finally { Monitor.Exit(monitorObj); } } } }
代碼的輸出為下,secondThread首先獲得了互斥鎖,並且會執行10秒鍾;然后firstThread會等待5秒鍾,仍然獲取互斥鎖失敗。

為了對比演示,也可以把代碼中"Thread.Sleep(10000);"換成"Thread.Sleep(2000);",這樣就可以看到等待5秒鍾,並且獲取互斥鎖成功的輸出了。
例子:通過Monitor實現互斥Queue
為了進一步熟悉Monitor的使用,下面看一個互斥Queue的例子,producer和consumer可以通過多線程的方式訪問互斥Queue。
namespace BlockingQueue { class BlockingQueue<T> { private object lockObj = new object(); public int QueueSize { get; set; } private Queue<T> queue; public BlockingQueue() { this.queue = new Queue<T>(this.QueueSize); } public bool EnQueue(T item) { lock (lockObj) { while (this.queue.Count() >= this.QueueSize) { Monitor.Wait(lockObj); } this.queue.Enqueue(item); Console.WriteLine("---> 0000" + item.ToString()); Monitor.PulseAll(lockObj); } return true; } public bool DeQueue(out T item) { lock (lockObj) { while (this.queue.Count() == 0) { if (!Monitor.Wait(lockObj, 3000)) { item = default(T); return false; }; } item = this.queue.Dequeue(); Console.WriteLine(" 0000" + item + " <---"); Monitor.PulseAll(lockObj); } return true; } } class Program { static void Main(string[] args) { BlockingQueue<string> bQueue = new BlockingQueue<string>(); bQueue.QueueSize = 3; Random ran = new Random(); //producer new Thread( () => { for (int i = 0; i < 5; i++) { Thread.Sleep(ran.Next(1000)); bQueue.EnQueue(i.ToString()); } Console.WriteLine("producer quit!"); }).Start(); //producer new Thread( () => { for (int i = 5; i < 10; i++) { Thread.Sleep(ran.Next(1000)); bQueue.EnQueue(i.ToString()); } Console.WriteLine("producer quit!"); }).Start(); //consumer new Thread( () => { while (true) { Thread.Sleep(ran.Next(1000)); string item = string.Empty; if (!bQueue.DeQueue(out item)) { break; }; } Console.WriteLine("consumer quit!"); }).Start(); Console.Read(); } } }
代碼的輸出為,例子中設置了BlockingQueue的size為3。
同時,在DeQueue方法中使用了"public static bool Wait(object obj, int millisecondsTimeout)"方法,這個方法將釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖;如果超過指定的超時間隔,則線程進入就緒隊列。

總結
本文介紹了C#中如何通過lock和Monitor進行線程同步,如果僅僅是進行臨界區的保護,那么我們可以簡單的使用lock關鍵字,lock關鍵字是Monitor的一種語法糖。
所有lock能做的,Monitor都能做,Monitor能做的,lock不一定能做,Monitor提供了一些額外的功能:
- 通過TryEnter(Object, Int32)方法可以設置一個超時時間,避免線程死等
- 通過Monitor.Wait()和Monitor.Pulse(),可以進行更細致的線程同步控制
下一篇將介紹一下如何通過同步句柄(WaitHandle)來進行線程同步。
