一.多線程的概念
Windows是一個多任務的系統,如果你使用的是windows 2000及其以上版本,你可以通過任務管理器查看當前系統運行的程序和進程。什么是進程呢?當一個程序開始運行時,它就是一個進程,進程所指包括運行中的程序和程序所使用到的內存和系統資源。而一個進程又是由多個線程所組成的,線程是程序中的一個執行流,每個線程都有自己的專有寄存器(棧指針、程序計數器等),但代碼區是共享的,即不同的線程可以執行同樣的函數。多線程是指程序中包含多個執行流,即在一個程序中可以同時運行多個不同的線程來執行不同的任務,也就是說允許單個程序創建多個並行執行的線程來完成各自的任務。瀏覽器就是一個很好的多線程的例子,在瀏覽器中你可以在下載JAVA小應用程序或圖象的同時滾動頁面,在訪問新頁面時,播放動畫和聲音,打印文件等。
多線程的好處在於可以提高CPU的利用率——任何一個程序員都不希望自己的程序很多時候沒事可干,在多線程程序中,一個線程必須等待的時候,CPU可以運行其它的線程而不是等待,這樣就大大提高了程序的效率。
然而我們也必須認識到線程本身可能影響系統性能的不利方面,以正確使用線程:
- 線程也是程序,所以線程需要占用內存,線程越多占用內存也越多
- 多線程需要協調和管理,所以需要CPU時間跟蹤線程
- 線程之間對共享資源的訪問會相互影響,必須解決競用共享資源的問題
- 線程太多會導致控制太復雜,最終可能造成很多Bug
基於以上認識,我們可以一個比喻來加深理解。假設有一個公司,公司里有很多各司其職的職員,那么我們可以認為這個正常運作的公司就是一個進程,而公司里的職員就是線程。一個公司至少得有一個職員吧,同理,一個進程至少包含一個線程。在公司里,你可以一個職員干所有的事,但是效率很顯然是高不起來的,一個人的公司也不可能做大;一個程序中也可以只用一個線程去做事,事實上,一些過時的語言如fortune,basic都是如此,但是象一個人的公司一樣,效率很低,如果做大程序,效率更低——事實上現在幾乎沒有單線程的商業軟件。公司的職員越多,老板就得發越多的薪水給他們,還得耗費大量精力去管理他們,協調他們之間的矛盾和利益;程序也是如此,線程越多耗費的資源也越多,需要CPU時間去跟蹤線程,還得解決諸如死鎖,同步等問題。總之,如果你不想你的公司被稱為“皮包公司”,你就得多幾個員工;如果你不想讓你的程序顯得稚氣,就在你的程序里引入多線程吧!
本文將對C#編程中的多線程機制進行探討,通過一些實例解決對線程的控制,多線程間通訊等問題。為了省去創建GUI那些繁瑣的步驟,更清晰地逼近線程的本質,下面所有的程序都是控制台程序,程序最后的Console.ReadLine()是為了使程序中途停下來,以便看清楚執行過程中的輸出。
好了,廢話少說,讓我們來體驗一下多線程的C#吧!
二.操縱一個線程
任何程序在執行時,至少有一個主線程,下面這段小程序可以給讀者一個直觀的印象:
//SystemThread.cs using System; using System.Threading; namespace ThreadTest { class RunIt { [STAThread] static void Main(string[] args) { Thread.CurrentThread.Name="System Thread";//給當前線程起名為"System Thread" Console.WriteLine(Thread.CurrentThread.Name+"'Status:"+Thread.CurrentThread.ThreadState); Console.ReadLine(); } } }
編譯執行后你看到了什么?是的,程序將產生如下輸出:
System Thread's Status:Running
在這里,我們通過Thread類的靜態屬性CurrentThread獲取了當前執行的線程,對其Name屬性賦值“System Thread”,最后還輸出了它的當前狀態(ThreadState)。所謂靜態屬性,就是這個類所有對象所公有的屬性,不管你創建了多少個這個類的實例,但是類的靜態屬性在內存中只有一個。很容易理解CurrentThread為什么是靜態的——雖然有多個線程同時存在,但是在某一個時刻,CPU只能執行其中一個。
就像上面程序所演示的,我們通過Thread類來創建和控制線程。注意到程序的頭部,我們使用了如下命名空間:
1 |
下面我們就動手來創建一個線程,使用Thread類創建線程時,只需提供線程入口即可。線程入口使程序知道該讓這個線程干什么事,在C#中,線程入口是通過ThreadStart代理(delegate)來提供的,你可以把ThreadStart理解為一個函數指針,指向線程要執行的函數,當調用Thread.Start()方法后,線程就開始執行ThreadStart所代表或者說指向的函數。
打開你的VS.net,新建一個控制台應用程序(Console Application),下面這些代碼將讓你體味到完全控制一個線程的無窮樂趣!
//ThreadTest.cs using System; using System.Threading; namespace ThreadTest { public class Alpha { public void Beta() { while (true) { Console.WriteLine("Alpha.Beta is running in its own thread."); } } }; public class Simple { public static int Main() { Console.WriteLine("Thread Start/Stop/Join Sample"); Alpha oAlpha = new Alpha(); file://這里創建一個線程,使之執行Alpha類的Beta()方法 Thread oThread = new Thread(new ThreadStart(oAlpha.Beta)); oThread.Start(); while (!oThread.IsAlive); Thread.Sleep(1); oThread.Abort(); oThread.Join(); Console.WriteLine(); Console.WriteLine("Alpha.Beta has finished"); try { Console.WriteLine("Try to restart the Alpha.Beta thread"); oThread.Start(); } catch (ThreadStateException) { Console.Write("ThreadStateException trying to restart Alpha.Beta. "); Console.WriteLine("Expected since aborted threads cannot be restarted."); Console.ReadLine(); } return 0; } } }
這段程序包含兩個類Alpha和Simple,在創建線程oThread時我們用指向Alpha.Beta()方法的初始化了ThreadStart代理(delegate)對象,當我們創建的線程oThread調用oThread.Start()方法啟動時,實際上程序運行的是Alpha.Beta()方法:
Alpha oAlpha = new Alpha(); Thread oThread = new Thread(new ThreadStart(oAlpha.Beta)); oThread.Start();
然后在Main()函數的while循環中,我們使用靜態方法Thread.Sleep()讓主線程停了1ms,這段時間CPU轉向執行線程oThread。然后我們試圖用Thread.Abort()方法終止線程oThread,注意后面的oThread.Join(),Thread.Join()方法使主線程等待,直到oThread線程結束。你可以給Thread.Join()方法指定一個int型的參數作為等待的最長時間。之后,我們試圖用Thread.Start()方法重新啟動線程oThread,但是顯然Abort()方法帶來的后果是不可恢復的終止線程,所以最后程序會拋出ThreadStateException異常。
程序最后得到的結果將如下圖:
在這里我們要注意的是其它線程都是依附於Main()函數所在的線程的,Main()函數是C#程序的入口,起始線程可以稱之為主線程,如果所有的前台線程都停止了,那么主線程可以終止,而所有的后台線程都將無條件終止。而所有的線程雖然在微觀上是串行執行的,但是在宏觀上你完全可以認為它們在並行執行。
讀者一定注意到了Thread.ThreadState這個屬性,這個屬性代表了線程運行時狀態,在不同的情況下有不同的值,於是我們有時候可以通過對該值的判斷來設計程序流程。ThreadState在各種情況下的可能取值如下:
- Aborted:線程已停止
- AbortRequested:線程的Thread.Abort()方法已被調用,但是線程還未停止
- Background:線程在后台執行,與屬性Thread.IsBackground有關
- Running:線程正在正常運行
- Stopped:線程已經被停止
- StopRequested:線程正在被要求停止
- Suspended:線程已經被掛起(此狀態下,可以通過調用Resume()方法重新運行)
- SuspendRequested:線程正在要求被掛起,但是未來得及響應
- Unstarted:未調用Thread.Start()開始線程的運行
- WaitSleepJoin:線程因為調用了Wait(),Sleep()或Join()等方法處於封鎖狀態
上面提到了Background狀態表示該線程在后台運行,那么后台運行的線程有什么特別的地方呢?其實后台線程跟前台線程只有一個區別,那就是后台線程不妨礙程序的終止。一旦一個進程所有的前台線程都終止后,CLR(通用語言運行環境)將通過調用任意一個存活中的后台進程的Abort()方法來徹底終止進程。
當線程之間爭奪CPU時間時,CPU按照是線程的優先級給予服務的。在C#應用程序中,用戶可以設定5個不同的優先級,由高到低分別是Highest,AboveNormal,Normal,BelowNormal,Lowest,在創建線程時如果不指定優先級,那么系統默認為ThreadPriority.Normal。給一個線程指定優先級
,我們可以使用如下代碼:
//設定優先級為最低 myThread.Priority=ThreadPriority.Lowest;
通過設定線程的優先級,我們可以安排一些相對重要的線程優先執行,例如對用戶的響應等等。
現在我們對怎樣創建和控制一個線程已經有了一個初步的了解,下面我們將深入研究線程實現中比較典型的的問題,並且探討其解決方法。
三.線程的同步和通訊——生產者和消費者
假設這樣一種情況,兩個線程同時維護一個隊列,如果一個線程對隊列中添加元素,而另外一個線程從隊列中取用元素,那么我們稱添加元素的線程為生產者,稱取用元素的線程為消費者。生產者與消費者問題看起來很簡單,但是卻是多線程應用中一個必須解決的問題,它涉及到線程之間的同步和通訊問題。
前面說過,每個線程都有自己的資源,但是代碼區是共享的,即每個線程都可以執行相同的函數。但是多線程環境下,可能帶來的問題就是幾個線程同時執行一個函數,導致數據的混亂,產生不可預料的結果,因此我們必須避免這種情況的發生。C#提供了一個關鍵字lock,它可以把一段代碼定義為互斥段(critical section),互斥段在一個時刻內只允許一個線程進入執行,而其他線程必須等待。在C#中,關鍵字lock定義如下:
lock(expression) statement_block
expression代表你希望跟蹤的對象,通常是對象引用。一般地,如果你想保護一個類的實例,你可以使用this;如果你希望保護一個靜態變量(如互斥代碼段在一個靜態方法內部),一般使用類名就可以了。而statement_block就是互斥段的代碼,這段代碼在一個時刻內只可能被一個線程執行。
下面是一個使用lock關鍵字的典型例子,我將在注釋里向大家說明lock關鍵字的用法和用途:
//lock.cs using System; using System.Threading; internal class Account { int balance; Random r = new Random(); internal Account(int initial) { balance = initial; } internal int Withdraw(int amount) { if (balance < 0) { file://如果balance小於0則拋出異常 throw new Exception("Negative Balance"); } //下面的代碼保證在當前線程修改balance的值完成之前 //不會有其他線程也執行這段代碼來修改balance的值 //因此,balance的值是不可能小於0的 lock (this) { Console.WriteLine("Current Thread:"+Thread.CurrentThread.Name); file://如果沒有lock關鍵字的保護,那么可能在執行完if的條件判斷之后 file://另外一個線程卻執行了balance=balance-amount修改了balance的值 file://而這個修改對這個線程是不可見的,所以可能導致這時if的條件已經不成立了 file://但是,這個線程卻繼續執行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(); } }
而多線程公用一個對象時,也會出現和公用代碼類似的問題,這種問題就不應該使用lock關鍵字了,這里需要用到System.Threading中的一個類Monitor,我們可以稱之為監視器,Monitor提供了使線程共享資源的方案。
Monitor類可以鎖定一個對象,一個線程只有得到這把鎖才可以對該對象進行操作。對象鎖機制保證了在可能引起混亂的情況下一個時刻只有一個線程可以訪問這個對象。Monitor必須和一個具體的對象相關聯,但是由於它是一個靜態的類,所以不能使用它來定義對象,而且它的所有方法都是靜態的,不能使用對象來引用。下面代碼說明了使用Monitor鎖定一個對象的情形:
......
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類來實現線程的同步和通訊的例子,也是一個典型的生產者與消費者問題。這個例程中,生產者線程和消費者線程是交替進行的,生產者寫入一個數,消費者立即讀取並且顯示,我將在注釋中介紹該程序的精要所在。用到的系統命名空間如下:
using System; using System.Threading;
首先,我們定義一個被操作的對象的類Cell,在這個類里,有兩個方法:ReadFromCell()和WriteToCell。消費者線程將調用ReadFromCell()讀取cellContents的內容並且顯示出來,生產者進程將調用WriteToCell()方法向cellContents寫入數據。
public class Cell { int cellContents; // Cell對象里邊的內容 bool readerFlag = false; // 狀態標志,為true時可以讀取,為false則正在寫入 public int ReadFromCell( ) { lock(this) // Lock關鍵字保證了什么,請大家看前面對lock的介紹 { if (!readerFlag)//如果現在不可讀取 { try { file://等待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; file://重置readerFlag標志,表示消費行為已經完成 Monitor.Pulse(this); file://通知WriteToCell()方法(該方法在另外一個線程中執行,等待中) } return cellContents; } public void WriteToCell(int n) { lock(this) { if (readerFlag) { try { Monitor.Wait(this); } catch (SynchronizationLockException e) { file://當同步方法(指Monitor類除Enter之外的方法)在非同步的代碼區被調用 Console.WriteLine(e); } catch (ThreadInterruptedException e) { file://當線程在等待狀態的時候中止 Console.WriteLine(e); } } cellContents = n; Console.WriteLine("Produce: {0}",cellContents); readerFlag = true; Monitor.Pulse(this); file://通知另外一個線程中正在等待的ReadFromCell()方法 } } }
下面定義生產者CellProd和消費者類CellCons,它們都只有一個方法ThreadRun(),以便在Main()函數中提供給線程的ThreadStart代理對象,作為線程的入口。
public class CellProd { Cell cell; // 被操作的Cell對象 int quantity = 1; // 生產者生產次數,初始化為1 public CellProd(Cell box, int request) { //構造函數 cell = box; quantity = request; } public void ThreadRun( ) { for(int looper=1; looper<=quantity; looper++) cell.WriteToCell(looper); file://生產者向操作對象寫入信息 } } public class CellCons { Cell cell; int quantity = 1; public CellCons(Cell box, int request) { cell = box; quantity = request; } public void ThreadRun( ) { int valReturned; for(int looper=1; looper<=quantity; looper++) valReturned=cell.ReadFromCell( );//消費者從操作對象中讀取信息 } }
然后在下面這個類MonitorSample的Main()函數中我們要做的就是創建兩個線程分別作為生產者和消費者,使用CellProd.ThreadRun()方法和CellCons.ThreadRun()方法對同一個Cell對象進行操作。
public class MonitorSample { public static void Main(String[] args) { int result = 0; file://一個標志位,如果是0表示程序沒有出錯,如果是1表明有錯誤發生 Cell cell = new Cell( ); //下面使用cell初始化CellProd和CellCons兩個類,生產和消費次數均為20次 CellProd prod = new CellProd(cell, 20); CellCons cons = new CellCons(cell, 20); Thread producer = new Thread(new ThreadStart(prod.ThreadRun)); Thread consumer = new Thread(new ThreadStart(cons.ThreadRun)); //生產者線程和消費者線程都已經被創建,但是沒有開始執行 try { producer.Start( ); consumer.Start( ); producer.Join( ); consumer.Join( ); Console.ReadLine(); } catch (ThreadStateException e) { file://當線程因為所處狀態的原因而不能執行被請求的操作 Console.WriteLine(e); result = 1; } catch (ThreadInterruptedException e) { file://當線程在等待狀態的時候中止 Console.WriteLine(e); result = 1; } //盡管Main()函數沒有返回值,但下面這條語句可以向父進程返回執行結果 Environment.ExitCode = result; } }
大家可以看到,在上面的例程中,同步是通過等待Monitor.Pulse()來完成的。首先生產者生產了一個值,而同一時刻消費者處於等待狀態,直到收到生產者的“脈沖(Pulse)”通知它生產已經完成,此后消費者進入消費狀態,而生產者開始等待消費者完成操作后將調用Monitor.Pulese()發出的“脈沖”。它的執行結果很簡單:
Produce: 1
Consume: 1
Produce: 2
Consume: 2
Produce: 3
Consume: 3
...
...
Produce: 20
Consume: 20
事實上,這個簡單的例子已經幫助我們解決了多線程應用程序中可能出現的大問題,只要領悟了解決線程間沖突的基本方法,很容易把它應用到比較復雜的程序中去。
關於JOIN()的一點理解
Thread.Join()在MSDN中的解釋很模糊:Blocks the calling thread until a thread terminates
有兩個主要問題:1.什么是the calling thread?
2.什么是a thread?
首先來看一下有關的概念: 我們執行一個.exe文件實際上就是開啟了一個進程,同時開啟了至少一個線程,
但是真正干活的是線程,就好比一個Team有好幾個人,但是真正干活的是人不是Team.
具體到代碼來說,以Console Application為例:程序Test.exe從Main函數開始運行,實際上是有一個線程
在執行Main函數,我們稱作MainThread.假如我們在Main函數中聲明了一個Thread,稱作NewThread,並且調用了
NewThread.Start()的方法,那么 MainThread在處理Main函數里面的代碼時遇到NewThread.Start()時,就會
去調用NewThread.
基於上面的討論,我們可以得出結論:在我們剛才的例子中the calling thread就是MainThread,而a thread
指的洽洽就是MainThread調用的NewThread線程。
現在回到MSDN的解釋,我們可以這么翻譯:當NewThread調用Join方法的時候,MainThread就被停止執行,
直到NewThread線程執行完畢。這樣就好理解了吧O(∩_∩)O哈哈~
好了,前面分析完了,現在來看測試用例吧:
using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; namespace Test { class TestThread { private static void ThreadFuncOne() { for (int i = 0; i < 10; i++) { Console.WriteLine(Thread.CurrentThread.Name +" i = " + i); } Console.WriteLine(Thread.CurrentThread.Name + " has finished"); } static void Main(string[] args) { Thread.CurrentThread.Name = "MainThread"; Thread newThread = new Thread(new ThreadStart(TestThread.ThreadFuncOne)); newThread.Name = "NewThread"; for (int j = 0; j < 20; j++) { if (j == 10) { newThread.Start(); newThread.Join(); } else { Console.WriteLine(Thread.CurrentThread.Name + " j = " + j); } } Console.Read(); } } }
下面是測試的結果:
結論:從測試中我們可以很清楚的看到MainThread在NewThread.Join被調用后被阻塞,直到NewThread
執行完畢才繼續執行。
關於 Monitor.Wait()和Pulse()的知識
1.Monitor.Wait方法
當線程調用 Wait 時,它釋放對象的鎖並進入對象的等待隊列,對象的就緒隊列中的下一個線程(如果有)獲取鎖並擁有對對象的獨占使用。Wait()就是交出鎖的使用權,使線程處於阻塞狀態,直到再次獲得鎖的使用權。
2.Monitor.Pulse方法
當前線程調用此方法以便向隊列中的下一個線程發出鎖的信號。接收到脈沖后,等待線程就被移動到就緒隊列中。在調用 Pulse 的線程釋放鎖后,就緒隊列中的下一個線程(不一定是接收到脈沖的線程)將獲得該鎖。pulse()並不會使當前線程釋放鎖。
簡述:
共用同一lock對象兩線程不能只調用Wait(),Wait這個方法反而放棄了鎖的使用權,同時阻塞當前線程,線程就直接休眠(進入WaitSleepJoin狀態),同時在主線程中Join這個work線程時,也就一直不能返回了。線程將一直阻塞。
讓我們首先看看MSDN對Monitor.Wait的解釋(鏈接見注釋):
釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖。...
該解釋的確很粗糙,很難理解。讓我們來看看它下面的備注:
同步的對象包含若干引用,其中包括對當前擁有鎖的線程的引用、對就緒隊列的引用和對等待隊列的引用。
這個多少還給了點東西,現在我們腦海中想像這么一幅圖畫:
- Assembly code
-
|- 擁有鎖的線程 lockObj->|- 就緒隊列(ready queue) |- 等待隊列(wait queue)
當一個線程嘗試着lock一個同步對象的時候,該線程就在就緒隊列中排隊。一旦沒人擁有該同步對象,就緒隊列中的線程就可以占有該同步對象。這也是我們平時最經常用的lock方法。
為了其他的同步目的,占有同步對象的線程也可以暫時放棄同步對象,並把自己流放到等待隊列中去。這就是Monitor.Wait。由於該線程放棄了同步對象,其他在就緒隊列的排隊者就可以進而擁有同步對象。
比起就緒隊列來說,在等待隊列中排隊的線程更像是二等公民:他們不能自動得到同步對象,甚至不能自動升艙到就緒隊列。而Monitor.Pulse的作用就是開一次門,使得一個正在等待隊列中的線程升艙到就緒隊列;相應的Monitor.PulseAll則打開門放所有等待隊列中的線程到就緒隊列。
比如下面的程序:
class Program { staticvoid Main(string[] args) { new Thread(A).Start(); new Thread(B).Start(); new Thread(C).Start(); Console.ReadLine(); } staticobject lockObj = newobject(); staticvoid A() { lock (lockObj) //進入就緒隊列 { Thread.Sleep(1000); Monitor.Pulse(lockObj); Monitor.Wait(lockObj); //自我流放到等待隊列 } Console.WriteLine("A exit..."); } staticvoid B() { Thread.Sleep(500); lock (lockObj) //進入就緒隊列 { Monitor.Pulse(lockObj); } Console.WriteLine("B exit..."); } staticvoid C() { Thread.Sleep(800); lock (lockObj) //進入就緒隊列 { } Console.WriteLine("C exit..."); } }
從時間線上來分析:
Assembly code
T 線程A ---0
lock( lockObj ) ---1 { //... 線程B 線程C ---2 //... lock( lockObj ) lock( lockObj ) ---3 //... { { ---4 //... //... ---5 //... //... ---6 Monitor.Pulse //... ---7 Monitor.Wait //... ---8 //... Monitor.Pulse ---9 //... } } ---10 } 時間點0,假設線程A先得到了同步對象,它就登記到同步對象lockObj的“擁有者引用”中。時間點3,線程B和C要求擁有同步對象,他們將在“就緒隊列”排隊: |--(擁有鎖的線程) A | 3 lockObj--|--(就緒隊列) B,C | |--(等待隊列) 時間點7,線程A用Pulse發出信號,允許第一個正在"等待隊列"中的線程進入到”就緒隊列“。但由於就緒隊列是空的,什么事也沒有發生。時間點8,線程A用Wait放棄同步對象,並把自己放入"等待隊列"。B,C已經在就緒隊列中,因此其中的一個得以獲得同步對象(假定是B)。B成了同步 對象的擁有者。C現在還是候補委員,可以自動獲得空缺。而A則被關在門外,不能自動獲得空缺。 |--(擁有鎖的線程) B | 8 lockObj--|--(就緒隊列) C | |--(等待隊列) A 時間點9,線程B用Pulse發出信號開門,第一個被關在門外的A被允許放入到就緒隊列,現在C和A都成了候補委員,一旦同步對象空閑,都有機會得它。 |--(擁有鎖的線程) B | 9 lockObj--|--(就緒隊列) C,A | |--(等待隊列) 時間點10,線程B退出Lock區塊,同步對象閑置,就緒隊列隊列中的C或A就可以轉正為擁有者(假設C得到了同步對象)。 |--(擁有鎖的線程) C | 10 lockObj--|--(就緒隊列) A | |--(等待隊列) 隨后C也退出Lock區塊,同步對象閑置,A就重新得到了同步對象,並從Monitor.Wait中返回... 最終的執行結果就是: B exit... C exit... A exit...
由於Monitor.Wait的暫時放棄和Monitor.Pulse的開門機制,我們可以用Monitor來實現更豐富的同步機制,比如一個事件機(ManualResetEvent):
- C# code
class MyManualEvent
{
privateobject lockObj =newobject();
privatebool hasSet =false;
publicvoid Set()
{
lock (lockObj)
{
hasSet =true;
Monitor.PulseAll(lockObj);
}
}
publicvoid WaitOne()
{
lock (lockObj)
{
while (!hasSet)
{
Monitor.Wait(lockObj);
}
}
}
}
class Program
{
static MyManualEvent myManualEvent =new MyManualEvent();
staticvoid Main(string[] args)
{
ThreadPool.QueueUserWorkItem(WorkerThread, "A");
ThreadPool.QueueUserWorkItem(WorkerThread, "B");
Console.WriteLine("Press enter to signal the green light");
Console.ReadLine();
myManualEvent.Set(); ThreadPool.QueueUserWorkItem(WorkerThread, "C");
Console.ReadLine();
}
staticvoid WorkerThread(object state)
{
myManualEvent.WaitOne();
Console.WriteLine("Thread {0} got the green light...", state);
}
}
我們看到了該玩具MyManualEvent實現了類庫中的ManulaResetEvent的功能,但卻更加的輕便 - 類庫的ManulaResetEvent使用了操作系統內核事件機制,負擔比較大(不算競態時間,ManulaResetEvent是微秒級,而lock是幾十納秒級)。
例子的WaitOne中先在lock的保護下判斷是否信號綠燈,如果不是則進入等待。因此可以有多個線程(比如例子中的AB)在等待隊列中排隊。
當調用Set的時候,在lock的保護下信號轉綠,並使用PulseAll開門放狗,將所有排在等待隊列中的線程放入就緒隊列,A或B(比如A)於是可以重新獲得同步對象,從Monitor.Wait退出,並隨即退出lock區塊,WaitOne返回。隨后B或A(比如B)重復相同故事,並從WaitOne返回。
線程C在myManualEvent.Set()后才執行,它在WaitOne中確信信號燈早已轉綠,於是可以立刻返回並得以執行隨后的命令。
該玩具MyManualEvent可以用在需要等待初始化的場合,比如多個工作線程都必須等到初始化完成后,接到OK信號后才能開工。該玩具MyManualEvent比起ManulaResetEvent有很多局限,比如不能跨進程使用,但它演示了通過基本的Monitor命令組合,達到事件機的作用。
現在是回答朋友們的疑問的時候了:
Q: Lock關鍵字不是有獲取鎖、釋放鎖的功能... 為什么還需要執行Pulse?
A: 因為Wait和Pulse另有用途。
Q: 用lock 就不要用monitor了(?)
A: lock只是Monitor.Enter和Monitor.Exit,用Monitor的方法,不僅能用Wait,還可以用帶超時的Monitor.Enter重載。
Q: Monitor.Wait完全沒必要 (?)
A: Wait和Pulse另有用途。
Q: 什么Pulse和Wait方法必須從同步的代碼塊內調用?
A: 因為Wait的本意就是“[暫時]釋放對象上的鎖並阻止當前線程,直到它重新獲取該鎖”,沒有獲得就談不到釋放。
我們知道lock實際上一個語法糖糖,C#編譯器實際上把他展開為Monitor.Enter和Monitor.Exit,即:
- C# code
-
lock(lockObj) { //...} ////相當於(.Net4以前):Monitor.Enter(lockObj); try { //...} finally { Monitor.Exit(lockObj); }
但是,這種實現邏輯至少理論上有一個錯誤:當Monitor.Enter(lockObj);剛剛完成,還沒有進入try區的時候,有可能從其他線程發出了Thread.Abort等命令,使得該線程沒有機會進入try...finally。也就是說lockObj沒有辦法得到釋放,有可能造成程序死鎖。這也是Thread.Abort一般被認為是邪惡的原因之一。
DotNet4開始,增加了Monitor.Enter(object,ref bool)重載。而C#編譯器會把lock展開為更安全的Monitor.Enter(object,ref bool)和Monitor.Exit:
- C# code
-
lock(lockObj) { //...} ////相當於(DotNet 4):bool lockTaken =false; try { Monitor.Enter(lockObj,ref lockTaken); // } finally { if (lockTaken) Monitor.Exit(lockObj); }
現在Monitor.TryEnter在try的保護下,“加鎖”成功意味着“放鎖”將得到finally的保護。