前面說過,每個執行緒都有自己的資源,但是程式碼區是共享的,即每個執行緒都可以執行相同的函式。這可能帶來的問題就是幾個執行緒同時執行一個函式,導致資料的混亂,產生不可預料的結果,因此我們必須避免這種情況的發生。
C#提供了一個關鍵字lock,它可以把一段程式碼定義為互斥段(critical section),互斥段在一個時刻內只允許一個執行緒進入執行,而其他執行緒必須等待。在C#中,關鍵字lock定義如下:
lock(expression) statement_block
expression代表你希望跟蹤的物件,通常是物件引用。
- 如果你想保護一個類的例項,一般地,你可以使用this;
- 如果你想保護一個靜態變數(如互斥程式碼段在一個靜態方法內部),一般使用類名就可以了。
而statement_block就是互斥段的程式碼,這段程式碼在一個時刻內只可能被一個執行緒執行。
下面是一個使用lock關鍵字的典型例子,在註釋裡說明了lock關鍵字的用法和用途。
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
using System; using System.Threading; namespace ThreadSimple { internal class Account { int balance; Random r = new Random(); internal Account(int initial) { balance = initial; } internal int Withdraw(int amount) { if (balance < 0) { //如果balance小於0則丟擲異常 throw new Exception("Negative Balance"); } //下面的程式碼保證在當前執行緒修改balance的值完成之前 //不會有其他執行緒也執行這段程式碼來修改balance的值 //因此,balance的值是不可能小於0 的 lock (this) { Console.WriteLine("Current Thread:"+Thread.CurrentThread.Name); //如果沒有lock關鍵字的保護,那麼可能在執行完if的條件判斷之後 //另外一個執行緒卻執行了balance=balance-amount修改了balance的值 //而這個修改對這個執行緒是不可見的,所以可能導致這時if的條件已經不成立了 //但是,這個執行緒卻繼續執行balance=balance-amount,所以導致balance可能小於0 if (balance >= amount) { Thread.Sleep(5); balance = balance - amount; return amount; } else { return 0; // transaction rejected } } } internal void DoTransactions() { for (int i = 0; i < 100; i++) Withdraw(r.Next(-50, 100)); } } internal class Test { static internal Thread[] threads = new Thread[10]; public static void Main() { Account acc = new Account (0); 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].Name=i.ToString(); for (int i = 0; i < 10; i++) threads[i].Start(); Console.ReadLine(); } } } |
Monitor 類鎖定一個物件
當多執行緒公用一個物件時,也會出現和公用程式碼類似的問題,這種問題就不應該使用lock關鍵字了,這裡需要用到System.Threading中的一個類Monitor,我們可以稱之為監視器,Monitor提供了使執行緒共享資源的方案。
Monitor類可以鎖定一個物件,一個執行緒只有得到這把鎖才可以對該物件進行操作。物件鎖機制保證了在可能引起混亂的情況下一個時刻只有一個執行緒可以訪問這個物件。
Monitor必須和一個具體的物件相關聯,但是由於它是一個靜態的類,所以不能使用它來定義物件,而且它的所有方法都是靜態的,不能使用物件來引用。下面程式碼說明了使用Monitor鎖定一個物件的情形:
1 2 3 4 |
Queue oQueue=new Queue(); Monitor.Enter(oQueue); //現在oQueue物件只能被當前執行緒操縱了 Monitor.Exit(oQueue);//釋放鎖 |
如上所示,當一個執行緒呼叫Monitor.Enter()方法鎖定一個物件時,這個物件就歸它所有了,其它執行緒想要訪問這個物件,只有等待它使用Monitor.Exit()方法釋放鎖。為了保證執行緒最終都能釋放鎖,你可以把Monitor.Exit()方法寫在try-catch-finally結構中的finally程式碼塊裡。
對於任何一個被Monitor鎖定的物件,記憶體中都儲存著與它相關的一些資訊:
- 其一是現在持有鎖的執行緒的引用;
- 其二是一個預備佇列,佇列中儲存了已經準備好獲取鎖的執行緒;
- 其三是一個等待佇列,佇列中儲存著當前正在等待這個物件狀態改變的佇列的引用。
當擁有物件鎖的執行緒準備釋放鎖時,它使用Monitor.Pulse()方法通知等待佇列中的第一個執行緒,於是該執行緒被轉移到預備佇列中,當物件鎖被釋放時,在預備佇列中的執行緒可以立即獲得物件鎖。
下面是一個展示如何使用lock關鍵字和Monitor類來實現執行緒的同步和通訊的例子,也是一個典型的生產者與消費者問題。
這個例程中,生產者執行緒和消費者執行緒是交替進行的,生產者寫入一個數,消費者立即讀取並且顯示(註釋中介紹了該程式的精要所在)。
用到的系統名稱空間如下:
1 2 |
using System; using System.Threading; |
首先,定義一個被操作的物件的類Cell,在這個類裡,有兩個方法:ReadFromCell()和WriteToCell。消費者執行緒將呼叫ReadFromCell()讀取cellContents的內容並且顯示出來,生產者程式將呼叫WriteToCell()方法向cellContents寫入資料。
示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
public class Cell { int cellContents; // Cell物件裡邊的內容 bool readerFlag = false; // 狀態標誌,為true時可以讀取,為false則正在寫入 public int ReadFromCell( ) { lock(this) // Lock關鍵字保證了什麼,請大家看前面對lock的介紹 { if (!readerFlag)//如果現在不可讀取 { try { //等待WriteToCell方法中呼叫Monitor.Pulse()方法 Monitor.Wait(this); } catch (SynchronizationLockException e) { Console.WriteLine(e); } catch (ThreadInterruptedException e) { Console.WriteLine(e); } } Console.WriteLine("Consume: {0}",cellContents); readerFlag = false; //重置readerFlag標誌,表示消費行為已經完成 Monitor.Pulse(this); //通知WriteToCell()方法(該方法在另外一個執行緒中執行,等待中) } return cellContents; } public void WriteToCell(int n) { lock(this) { if (readerFlag) { try { Monitor.Wait(this); } catch (SynchronizationLockException e) { //當同步方法(指Monitor類除Enter之外的方法)在非同步的程式碼區被呼叫 Console.WriteLine(e); } catch (ThreadInterruptedException e) { //當執行緒在等待狀態的時候中止 Console.WriteLine(e); } } cellContents = n; Console.WriteLine("Produce: {0}",cellContents); readerFlag = true; Monitor.Pulse(this); //通知另外一個執行緒中正在等待的ReadFromCell()方法 } } } |
在上面的例程中,同步是通過等待Monitor.Pulse()來完成的。首先生產者生產了一個值,而同一時刻消費者處於等待狀態,直到收到生產者的“脈衝(Pulse)”通知它生產已經完成,此後消費者進入消費狀態,而生產者開始等待消費者完成操作後將呼叫Monitor.Pulese()發出的“脈衝”。
它的執行結果很簡單:
1 2 3 4 5 6 7 8 |
Produce: 1 Consume: 1 Produce: 2 Consume: 2 Produce: 3 Consume: 3 Produce: 20 Consume: 20 |
事實上,這個簡單的例子已經幫助我們解決了多執行緒應用程式中可能出現的大問題,只要領悟瞭解決執行緒間衝突的基本方法,很容易把它應用到比較複雜的程式中去。