Keywords:C# 線程
Source:http://www.albahari.com/threading/
Author: Joe Albahari
Translator: Swanky Wu
Published: http://www.cnblogs.com/txw1958/
Download:http://www.albahari.info/threading/threading.pdf
第二部分:線程同步基礎(chǔ)
同步要領(lǐng)
下面的表格列展了.NET對協(xié)調(diào)或同步線程動作的可用的工具:
簡易阻止方法
鎖系統(tǒng)
(同步的情況下也提夠自動鎖。)
信號系統(tǒng)
非阻止同步系統(tǒng)*
構(gòu)成 |
目的 |
跨進程? |
速度 |
Interlocked* |
完成簡單的非阻止原子操作。 |
是(內(nèi)存共享情況下) |
非常快 |
volatile* |
允許安全的非阻止在鎖之外使用個別字段。 |
非???/p> |
* 代表頁面將轉(zhuǎn)到第四部分
阻止 (Blocking)
當一個線程通過上面所列的方式處于等待或暫停的狀態(tài),被稱為被阻止。一旦被阻止,線程立刻放棄它被分配的CPU時間,將它的ThreadState屬性添加為WaitSleepJoin狀態(tài),不在安排時間直到停止阻止。停止阻止在任意四種情況下發(fā)生(關(guān)掉電腦的電源可不算?。?/p>
當線程通過(不建議)Suspend 方法暫停,不認為是被阻止了。
休眠 和 輪詢
調(diào)用Thread.Sleep阻止當前的線程指定的時間(或者直到中斷):
static void Main() {
Thread.Sleep (0); // 釋放CPU時間片
Thread.Sleep (1000); // 休眠1000毫秒
Thread.Sleep (TimeSpan.FromHours (1)); // 休眠1小時
Thread.Sleep (Timeout.Infinite); // 休眠直到中斷
}
更確切地說,Thread.Sleep放棄了占用CPU,請求不在被分配時間直到給定的時間經(jīng)過。Thread.Sleep(0)放棄CPU的時間剛剛夠其它在時間片隊列里的活動線程(如果有的話)被執(zhí)行。
Thread.Sleep在阻止方法中是唯一的暫停汲取Windows Forms程序的Windows消息的方法,或COM環(huán)境中用于單元模式。這在Windows Forms程序中是一個很大的問題,任何對主UI線程的阻止都將使程序失去相應(yīng)。因此一般避免這樣使用,無論信息汲取是否被“技術(shù)地”暫定與否。由COM遺留下來的宿主環(huán)境更為復雜,在一些時候它決定停止,而卻保持信息的汲取存活。微軟的 Chris Brumm 在他的COM "Chris Brumme"')
線程類同時也提供了一個SpinWait方法,它使用輪詢CPU而非放棄CPU時間的方式,保持給定的迭代次數(shù)進行“無用地繁忙”。50迭代可能等同于停頓大約一微秒,雖然這將取決于CPU的速度和負載。從技術(shù)上講,SpinWait并不是一個阻止的方法:一個處于spin-waiting的線程的ThreadState不是WaitSleepJoin狀態(tài),并且也不會被其它的線程過早的中斷(Interrupt)。SpinWait很少被使用,它的作用是等待一個在極短時間(可能小于一微秒)內(nèi)可準備好的可預(yù)期的資源,而不用調(diào)用Sleep方法阻止線程而浪費CPU時間。不過,這種技術(shù)的優(yōu)勢只有在多處理器計算機:對單一處理器的電腦,直到輪詢的線程結(jié)束了它的時間片之前,一個資源沒有機會改變狀態(tài),這有違它的初衷。并且調(diào)用SpinWait經(jīng)常會花費較長的時間這本身就浪費了CPU時間。
阻止 vs. 輪詢
線程可以等待某個確定的條件來明確輪詢使用一個輪詢的方式,比如:
while (!proceed);
或者:
while (DateTime.Now < nextStartTime);
這是非常浪費CPU時間的:對于CLR和操作系統(tǒng)而言,線程進行了一個重要的計算,所以分配了相應(yīng)的資源!在這種狀態(tài)下的輪詢線程不算是阻止,不像一個線程等待一個EventWaitHandle(一般使用這樣的信號任務(wù)來構(gòu)建)。
阻止和輪詢組合使用可以產(chǎn)生一些變換:
while (!proceed) Thread.Sleep (x); // "輪詢休眠!"
x越大,CPU效率越高,折中方案是增大潛伏時間,任何20ms的花費是微不足道的,除非循環(huán)中的條件是極其復雜的。
除了稍有延遲,這種輪詢和休眠的方式可以結(jié)合的非常好。(但有并發(fā)問題,在第四部分討論)可能它最大的用處在于程序員可以放棄使用復雜的信號結(jié)構(gòu) 來工作了。
使用Join等待一個線程完成
你可以通過Join方法阻止線程直到另一個線程結(jié)束:
class JoinDemo {
static void Main() {
Thread t = new Thread (delegate() { Console.ReadLine(); });
t.Start();
t.Join(); // 等待直到線程完成
Console.WriteLine ("Thread t's ReadLine complete!");
}
}
Join方法也接收一個使用毫秒或用TimeSpan類的超時參數(shù),當Join超時是返回false,如果線程已終止,則返回true 。Join所帶的超時參數(shù)非常像Sleep方法,實際上下面兩行代碼幾乎差不多:
Thread.Sleep (1000);
Thread.CurrentThread.Join (1000);
(他們的區(qū)別明顯在于單線程的應(yīng)用程序域與COM互操作性,源于先前描述Windows信息汲取部分:在阻止時,Join保持信息汲取,Sleep暫停信息汲取。)
鎖和線程安全
鎖實現(xiàn)互斥的訪問,被用于確保在同一時刻只有一個線程可以進入特殊的代碼片段,考慮下面的類:
class ThreadUnsafe {
static int val1, val2;
static void Go() {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}
這不是線程安全的:如果Go方法被兩個線程同時調(diào)用,可能會得到在某個線程中除數(shù)為零的錯誤, 因為val2可能被一個線程設(shè)置為零,而另一個線程剛好執(zhí)行到if和Console.WriteLine語句。
下面用lock來修正這個問題:
class ThreadSafe {
static object locker = new object();
static int val1, val2;
static void Go() {
lock (locker) {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
}
}
在同一時刻只有一個線程可以鎖定同步對象(在這里是locker),任何競爭的的其它線程都將被阻止,直到這個鎖被釋放。如果有大于一個的線程競爭這個鎖,那么他們將形成稱為“就緒隊列”的隊列,以先到先得的方式授權(quán)鎖?;コ怄i有時被稱之對由鎖所保護的內(nèi)容強迫串行化訪問,因為一個線程的訪問不能與另一個重疊。在這個例子中,我們保護了Go方法的邏輯,以及val1 和val2字段的邏輯。
一個等候競爭鎖的線程被阻止將在ThreadState上為WaitSleepJoin狀態(tài)。稍后我們將討論一個線程通過另一個線程調(diào)用Interrupt或Abort方法來強制地被釋放。這是一個相當高效率的技術(shù)可以被用于結(jié)束工作線程。
C#的lock 語句實際上是調(diào)用Monitor.Enter和Monitor.Exit,中間夾雜try-finally語句的簡略版,下面是實際發(fā)生在之前例子中的Go方法:
Monitor.Enter (locker);
try {
if (val2 != 0) Console.WriteLine (val1 / val2);
val2 = 0;
}
finally { Monitor.Exit (locker); }
在同一個對象上,在調(diào)用第一個之前Monitor.Enter而先調(diào)用了Monitor.Exit將引發(fā)異常。
Monitor 也提供了TryEnter方法來實現(xiàn)一個超時功能——也用毫秒或TimeSpan,如果獲得了鎖返回true,反之沒有獲得返回false,因為超時了。TryEnter也可以沒有超時參數(shù),“測試”一下鎖,如果鎖不能被獲取的話就立刻超時。
選擇同步對象
任何對所有有關(guān)系的線程都可見的對象都可以作為同步對象,但要服從一個硬性規(guī)定:它必須是引用類型。也強烈建議同步對象最好私有在類里面(比如一個私有實例字段)防止無意間從外部鎖定相同的對象。服從這些規(guī)則,同步對象可以兼對象和保護兩種作用。比如下面List :
class ThreadSafe {
List <string> list = new List <string>();
void Test() {
lock (list) {
list.Add ("Item 1");
...
一個專門字段是常用的(如在先前的例子中的locker) , 因為它可以精確控制鎖的范圍和粒度。用對象或類本身的類型作為一個同步對象,即:
lock (this) { ... }
或:
lock (typeof (Widget)) { ... } // 保護訪問靜態(tài)
是不好的,因為這潛在的可以在公共范圍訪問這些對象。
鎖并沒有以任何方式阻止對同步對象本身的訪問,換言之,x.ToString()不會由于另一個線程調(diào)用lock(x) 而被阻止,兩者都要調(diào)用lock(x) 來完成阻止工作。
嵌套鎖定
線程可以重復鎖定相同的對象,可以通過多次調(diào)用Monitor.Enter或lock語句來實現(xiàn)。當對應(yīng)編號的Monitor.Exit被調(diào)用或最外面的lock語句完成后,對象那一刻被解鎖。這就允許最簡單的語法實現(xiàn)一個方法的鎖調(diào)用另一個鎖:
static object x = new object();
static void Main() {
lock (x) {
Console.WriteLine ("I have the lock");
Nest();
Console.WriteLine ("I still have the lock");
}
在這鎖被釋放
}
static void Nest() {
lock (x) {
...
}
釋放了鎖?沒有完全釋放!
}
線程只能在最開始的鎖或最外面的鎖時被阻止。
何時進行鎖定
作為一項基本規(guī)則,任何和多線程有關(guān)的會進行讀和寫的字段應(yīng)當加鎖。甚至是極平常的事情——單一字段的賦值操作,都必須考慮到同步問題。在下面的例子中Increment和Assign 都不是線程安全的:
class ThreadUnsafe {
static int x;
static void Increment() { x++; }
static void Assign() { x = 123; }
}
下面是Increment 和 Assign 線程安全的版本:
class ThreadUnsafe {
static object locker = new object();
static int x;
static void Increment() { lock (locker) x++; }
static void Assign() { lock (locker) x = 123; }
}
作為鎖定另一個選擇,在一些簡單的情況下,你可以使用非阻止同步,在第四部分討論(即使像這樣的語句需要同步的原因)。
鎖和原子操作
如果有很多變量在一些鎖中總是進行讀和寫的操作,那么你可以稱之為原子操作。我們假設(shè)x 和 y不停地讀和賦值,他們在鎖內(nèi)通過locker鎖定:
lock (locker) { if (x != 0) y /= x; }
你可以認為x 和 y 通過原子的方式訪問,因為代碼段沒有被其它的線程分開 或 搶占,別的線程改變x 和 y是無效的輸出,你永遠不會得到除數(shù)為零的錯誤,保證了x 和 y總是被相同的排他鎖訪問。
性能考量
鎖定本身是非常快的,一個鎖在沒有堵塞的情況下一般只需幾十納秒(十億分之一秒)。如果發(fā)生堵塞,任務(wù)切換帶來的開銷接近于數(shù)微秒(百萬分之一秒)的范圍內(nèi),盡管在線程重組實際的安排時間之前它可能花費數(shù)毫秒(千分之一秒)。而相反,與此相形見絀的是該使用鎖而沒使用的結(jié)果就是帶來數(shù)小時的時間,甚至超時。
如果耗盡并發(fā),鎖定會帶來反作用,死鎖和爭用鎖,耗盡并發(fā)由于太多的代碼被放置到鎖語句中了,引起其它線程不必要的被阻止。死鎖是兩線程彼此等待被鎖定的內(nèi)容,導致兩者都無法繼續(xù)下去。爭用鎖是兩個線程任一個都可以鎖定某個內(nèi)容,如果“錯誤”的線程獲取了鎖,則導致程序錯誤。
對于太多的同步對象死鎖是非常容易出現(xiàn)的癥狀,一個好的規(guī)則是開始于較少的鎖,在一個可信的情況下涉及過多的阻止出現(xiàn)時,增加鎖的粒度。
線程安全
線程安全的代碼是指在面對任何多線程情況下,這代碼都沒有不確定的因素。線程安全首先完成鎖,然后減少在線程間交互的可能性。
一個線程安全的方法,在任何情況下可以可重入式調(diào)用。通用類型在它們中很少是線程安全的,原因如下:
- 完全線程安全的開發(fā)是重要的,尤其是一個類型有很多字段(在任意多線程上下文中每個字段都有潛在的交互作用)的情況下。
- 線程安全帶來性能損失(要付出的,在某種程度上無論與否類型是否被用于多線程)。
- 一個線程安全類型不一定能使程序使用線程安全,有時參與工作后者可使前者變得冗余。
因此線程安全經(jīng)常只在需要實現(xiàn)的地方來實現(xiàn),為了處理一個特定的多線程情況。
不過,有一些方法來“欺騙”,有龐大和復雜的類安全地運行在多線程環(huán)境中。一種是犧牲粒度包含大段的代碼——甚至在排他鎖中訪問全局對象,迫使在更高的級別上實現(xiàn)串行化訪問。這一策略也很關(guān)鍵,讓非線程安全的對象用于線程安全代碼中,避免了相同的互斥鎖被用于保護對在非線程安全對象的所有的屬性、方法和字段的訪問。
原始類型除外,很少的.NET framework類型實例相比于并發(fā)的只讀訪問,是線程安全的。責任在開放人員實現(xiàn)線程安全代表性地使用互斥鎖。
另一個方式欺騙是通過最小化共享數(shù)據(jù)來最小化線程交互。這是一個很好的途徑,被暗中地用于“弱狀態(tài)”的中間層程序和web服務(wù)器。自多個客戶端請求同時到達,每個請求來自它自己的線程(效力于ASP.NET,Web服務(wù)器或者遠程體系結(jié)構(gòu)),這意味著它們調(diào)用的方法一定是線程安全的。弱狀態(tài)設(shè)計(因伸縮性好而流行)本質(zhì)上限制了交互的能力,因此類不能夠在每個請求間持久保留數(shù)據(jù)。線程交互僅限于可以被選擇創(chuàng)建的靜態(tài)字段,多半是在內(nèi)存里緩存常用數(shù)據(jù)和提供基礎(chǔ)設(shè)施服務(wù),例如認證和審核。
線程安全與.NET Framework類型
鎖定可被用于將非線程安全的代碼轉(zhuǎn)換成線程安全的代碼。好的例子是在.NET framework方面,幾乎所有非初始類型的實例都不是線程安全的,而如果所有的訪問給定的對象都通過鎖進行了保護的話,他們可以被用于多線程代碼中。看這個例子,兩個線程同時為相同的List增加條目,然后枚舉它:
class ThreadSafe {
static List <string> list = new List <string>();
static void Main() {
new Thread (AddItems).Start();
new Thread (AddItems).Start();
}
static void AddItems() {
for (int i = 0; i < 100; i++)
lock (list)
list.Add ("Item " + list.Count);
string[] items;
lock (list) items = list.ToArray();
foreach (string s in items) Console.WriteLine (s);
}
}
在這種情況下,我們鎖定了list對象本身,這個簡單的方案是很好的。如果我們有兩個相關(guān)的list,也許我們就要鎖定一個共同的目標——可能是單獨的一個字段,如果沒有其它的list出現(xiàn),顯然鎖定它自己是明智的選擇。
枚舉.NET的集合也不是線程安全的,在枚舉的時候另一個線程改動list的話,會拋出異常。勝于直接鎖定枚舉過程,在這個例子中,我們首先將項目復制到數(shù)組當中,這就避免了固定住鎖因為我們在枚舉過程中有潛在的耗時。
這里的一個有趣的假設(shè):想象如果List實際上為線程安全的,如何解決呢?代碼會很少!舉例說明,我們說我們要增加一個項目到我們假象的線程安全的list里,如下:
if (!myList.Contains (newItem)) myList.Add (newItem);
無論與否list是否為線程安全的,這個語句顯然不是!整個if語句必須放到一個鎖中,用來保護搶占在判斷有無和增加新的之間。上述的鎖需要用于任何我們需要修改list的地方,比如下面的語句需要被同樣的鎖包括住:
myList.Clear();
來保證它沒有搶占之前的語句,換言之,我們必須鎖定差不多所有非線程安全的集合類們。內(nèi)置的線程安全,顯而易見是浪費時間!
在寫自定義組件的時候,你可能會反對這個觀點——為什么建造線程安全讓它容易的結(jié)果會變的多余呢 ?
有一個爭論:在一個對象包上自定義的鎖僅在所有并行的線程知道、并且使用這個鎖的時候才能工作,而如果鎖對象在更大的范圍內(nèi)的時候,這個鎖對象可能不在這個鎖范圍內(nèi)。最糟糕的情況是靜態(tài)成員在公共類型中出現(xiàn)了,比如,想象靜態(tài)結(jié)構(gòu)在DateTime上,DateTime.Now不是線程安全的,當有2個并發(fā)的調(diào)用可帶來錯亂的輸出或異常,補救方式是在其外進行鎖定,可能鎖定它的類型本身—— lock(typeof(DateTime))來圈住調(diào)用DateTime.Now,這會工作的,但只有所有的程序員同意這樣做的時候。然而這并靠不住,鎖定一個類型被認為是一件非常不好的事情。
由于這些理由,DateTime上的靜態(tài)成員是保證線程安全的,這是一個遍及.NET framework一個普遍模式——靜態(tài)成員是線程安全的,而一個實例成員則不是。從這個模式也能在寫自定義類型時得到一些體會,不要創(chuàng)建一個不能線程安全的難題!
當寫公用組件的時候,好的習慣是不要忘記了線程安全,這意味著要單獨小心處理那些在其中或公共的靜態(tài)成員。
Interrupt 和 Abort
一個被阻止的線程可以通過兩種方式被提前釋放:
這必須通過另外活動的線程實現(xiàn),等待的線程是沒有能力對它的被阻止狀態(tài)做任何事情的。
Interrupt方法
在一個被阻止的線程上調(diào)用Interrupt 方法,將強迫釋放它,拋出ThreadInterruptedException異常,如下:
class Program {
static void Main() {
Thread t = new Thread (delegate() {
try {
Thread.Sleep (Timeout.Infinite);
}
catch (ThreadInterruptedException) {
Console.Write ("Forcibly ");
}
Console.WriteLine ("Woken!");
});
t.Start();
t.Interrupt();
}
}
Forcibly Woken!
中斷一個線程僅僅釋放它的當前的(或下一個)等待狀態(tài):它并不結(jié)束這個線程(當然,除非未處理ThreadInterruptedException異常)。
如果Interrupt被一個未阻止的線程調(diào)用,那么線程將繼續(xù)執(zhí)行直到下一次被阻止時,它拋出ThreadInterruptedException異常。用下面的測試避免這個問題:
if ((worker.ThreadState & ThreadState.WaitSleepJoin) > 0)
worker.Interrupt();
這不是一個線程安全的方式,因為可能被搶占了在if語句和worker.Interrupt間。
隨意中斷線程是危險的,因為任何框架或第三方方法在調(diào)用堆棧時可能會意外地在已訂閱的代碼上收到中斷。這一切將被認為是線程被暫時阻止在一個鎖中或同步資源中,并且所有掛起的中斷將被踢開。如果這個方法沒有被設(shè)計成可以被中斷(沒有適當處理finally塊)的對象可能剩下無用的狀態(tài),或資源不完全地被釋放。
中斷一個線程是安全的,當你知道它確切的在哪的時候。稍后我們討論 信號系統(tǒng),它提供這樣的一種方式。
Abort方法
被阻止的線程也可以通過Abort方法被強制釋放,這與調(diào)用Interrupt相似,除了用ThreadAbortException異常代替了ThreadInterruptedException異常,此外,異常將被重新拋出在catch里(在試圖以有好方式處理異常的時候),直到Thread.ResetAbort在catch中被調(diào)用;在這期間線程的ThreadState為AbortRequested。
在Interrupt 與 Abort 之間最大不同在于它們調(diào)用一個非阻止線程所發(fā)生的事情。Interrupt繼續(xù)工作直到下一次阻止發(fā)生,Abort在線程當前所執(zhí)行的位置(可能甚至不在你的代碼中)拋出異常。終止一個非阻止的線程會帶來嚴重的后果,這在后面的 “終止線程”章節(jié)中將詳細討論。
線程狀態(tài)
圖1: 線程狀態(tài)關(guān)系圖
你可以通過ThreadState屬性獲取線程的執(zhí)行狀態(tài)。圖1將ThreadState列舉為“層”。ThreadState被設(shè)計的很恐怖,它以按位計算的方式組合三種狀態(tài)“層”,每種狀態(tài)層的成員它們間都是互斥的,下面是所有的三種狀態(tài)“層”:
- 運行 (running) / 阻止 (blocking) / 終止 (aborting) 狀態(tài)(圖1顯示)
- 后臺 (background) / 前臺 (foreground) 狀態(tài) (ThreadState.Background)
- 不建議使用的Suspend 方法(ThreadState.SuspendRequested 和 ThreadState.Suspended)掛起的過程
總的來說,ThreadState是按位組合零或每個狀態(tài)層的成員!一個簡單的ThreadState例子:
Unstarted
Running
WaitSleepJoin
Background, Unstarted
SuspendRequested, Background, WaitSleepJoin
(所枚舉的成員有兩個從來沒被用過,至少是當前CLR實現(xiàn)上:StopRequested 和 Aborted。)
還有更加復雜的,ThreadState.Running潛在的值為0 ,因此下面的測試不工作:
if ((t.ThreadState & ThreadState.Running) > 0) ...
你必須用按位與非操作符來代替,或者使用線程的IsAlive屬性。但是IsAlive可能不是你想要的,它在被阻止或掛起的時候返回true(只有在線程未開始或已結(jié)束時它才為true)。
假設(shè)你避開不推薦使用的Suspend 和 Resume方法,你可以寫一個helper方法除去所有除了第一種狀態(tài)層的成員,允許簡單測試計算完成。線程的后臺狀態(tài)可以通過IsBackground 獨立地獲得,所以實際上只有第一種狀態(tài)層擁有有用的信息。
public static ThreadState SimpleThreadState (ThreadState ts)
{
return ts & (ThreadState.Aborted | ThreadState.AbortRequested |
ThreadState.Stopped | ThreadState.Unstarted |
ThreadState.WaitSleepJoin);
}
ThreadState對調(diào)試或程序概要分析是無價之寶,與之不相稱的是多線程的協(xié)同工作,因為沒有一個機制存在:通過判斷ThreadState來執(zhí)行信息,而不考慮ThreadState期間的變化。
等待句柄
lock語句(也稱為Monitor.Enter / Monitor.Exit)是線程同步結(jié)構(gòu)的一個例子。當lock對一段代碼或資源實施排他訪問時, 有些同步任務(wù)是笨拙的或難以實現(xiàn)的,比如說傳輸信號給等待的工作線程開始任務(wù)。
Win32 API擁有豐富的同步系統(tǒng),這在.NET framework以EventWaitHandle, Mutex 和 Semaphore類展露出來。而一些比有些更有用:例如Mutex類,在EventWaitHandle提供唯一的信號功能時,大多會成倍提高lock的效率。
這三個類都依賴于WaitHandle類,盡管從功能上講, 它們相當?shù)牟煌5鼈冏龅氖虑槎加幸粋€共同點,那就是,被“點名”,這允許它們繞過操作系統(tǒng)進程工作,而不是只能在當前進程里繞過線程。
EventWaitHandle有兩個子類:AutoResetEvent 和 ManualResetEvent(不涉及到C#中的事件或委托)。這兩個類都派生自它們的基類:它們僅有的不同是它們用不同的參數(shù)調(diào)用基類的構(gòu)造函數(shù)。
性能方面,使用Wait Handles系統(tǒng)開銷會花費在較小微秒間,不會在它們使用的上下文中產(chǎn)生什么后果。
AutoResetEvent在WaitHandle中是最有用的的類,它連同lock 語句是一個主要的同步結(jié)構(gòu)。
AutoResetEvent
AutoResetEvent就像一個用票通過的旋轉(zhuǎn)門:插入一張票,讓正確的人通過。類名字里的“auto”實際上就是旋轉(zhuǎn)門自動關(guān)閉或“重新安排”后來的人讓其通過。一個線程等待或阻止通過在門上調(diào)用WaitOne方法(直到等到這個“one”,門才開) ,票的插入則由調(diào)用Set方法。如果由許多線程調(diào)用WaitOne,在門前便形成了隊列,一張票可能來自任意某個線程——換言之,任何(非阻止)線程要通過AutoResetEvent對象調(diào)用Set方法來釋放一個被阻止的的線程。
如果Set調(diào)用時沒有任何線程處于等待狀態(tài),那么句柄保持打開直到某個線程調(diào)用了WaitOne 。這個行為避免了在線程起身去旋轉(zhuǎn)門和線程插入票(哦,插入票是非常短的微秒間的事,真倒霉,你將必須不確定地等下去了!)間的競爭。但是在沒人等的時候重復地在門上調(diào)用Set方法不會允許在一隊人都通過,在他們到達的時候:僅有下一個人可以通過,多余的票都被“浪費了"。
WaitOne 接受一個可選的超時參數(shù)——當?shù)却猿瑫r結(jié)束時這個方法將返回false,WaitOne在等待整段時間里也通知離開當前的同步內(nèi)容,為了避免過多的阻止發(fā)生。
Reset方法提供在沒有任何等待或阻止的時候關(guān)閉旋轉(zhuǎn)門——它應(yīng)該是開著的。
AutoResetEvent可以通過2種方式創(chuàng)建,第一種是通過構(gòu)造函數(shù):
EventWaitHandle wh = new AutoResetEvent (false);
如果布爾參數(shù)為真,Set方法在構(gòu)造后立刻被自動的調(diào)用,另一個方法是通過它的基類EventWaitHandle:
EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto);
EventWaitHandle的構(gòu)造器也允許創(chuàng)建ManualResetEvent(用EventResetMode.Manual定義).
在Wait Handle不在需要時候,你應(yīng)當調(diào)用Close方法來釋放操作系統(tǒng)資源。但是,如果一個Wait Handle將被用于程序(就像這一節(jié)的大多例子一樣)的生命周期中,你可以發(fā)點懶省略這個步驟,它將在程序域銷毀時自動的被銷毀。
接下來這個例子,一個線程開始等待直到另一個線程發(fā)出信號。
class BasicWaitHandle {
static EventWaitHandle wh = new AutoResetEvent (false);
static void Main() {
new Thread (Waiter).Start();
Thread.Sleep (1000); // 等一會...
wh.Set(); // OK ——喚醒它
}
static void Waiter() {
Console.WriteLine ("Waiting...");
wh.WaitOne(); // 等待通知
Console.WriteLine ("Notified");
}
}
Waiting... (pause) Notified.
創(chuàng)建跨進程的EventWaitHandle
EventWaitHandle的構(gòu)造器允許以“命名”的方式進行創(chuàng)建,它有能力跨多個進程。名稱是個簡單的字符串,可能會無意地與別的沖突!如果名字使用了,你將引用相同潛在的EventWaitHandle,除非操作系統(tǒng)創(chuàng)建一個新的,看這個例子:
EventWaitHandle wh = new EventWaitHandle (false, EventResetMode.Auto,
"MyCompany.MyApp.SomeName");
如果有兩個程序都運行這段代碼,他們將彼此可以發(fā)送信號,等待句柄可以跨這兩個進程中的所有線程。
任務(wù)確認
設(shè)想我們希望在后臺完成任務(wù),不在每次我們得到任務(wù)時再創(chuàng)建一個新的線程。我們可以通過一個輪詢的線程來完成:等待一個任務(wù),執(zhí)行它,然后等待下一個任務(wù)。這是一個普遍的多線程方案。也就是在創(chuàng)建線程上切分內(nèi)務(wù)操作,任務(wù)執(zhí)行被序列化,在多個工作線程和過多的資源消耗間排除潛在的不想要的操作。
我們必須決定要做什么,但是,如果當新的任務(wù)來到的時候,工作線程已經(jīng)在忙之前的任務(wù)了,設(shè)想這種情形下我們需選擇阻止調(diào)用者直到之前的任務(wù)被完成。像這樣的系統(tǒng)可以用兩個AutoResetEvent對象實現(xiàn):一個“ready”AutoResetEvent,當準備好的時候,它被工作線程調(diào)用Set方法;和“go”AutoResetEvent,當有新任務(wù)的時候,它被調(diào)用線程調(diào)用Set方法。在下面的例子中,一個簡單的string字段被用于決定任務(wù)(使用了volatile 關(guān)鍵字聲明,來確保兩個線程都可以看到相同版本):
class AcknowledgedWaitHandle {
static EventWaitHandle ready = new AutoResetEvent (false);
static EventWaitHandle go = new AutoResetEvent (false);
static volatile string task;
static void Main() {
new Thread (Work).Start();
// 給工作線程發(fā)5次信號
for (int i = 1; i <= 5; i++) {
ready.WaitOne(); // 首先等待,直到工作線程準備好了
task = "a".PadRight (i, 'h'); // 給任務(wù)賦值
go.Set(); // 告訴工作線程開始執(zhí)行!
}
// 告訴工作線程用一個null任務(wù)來結(jié)束
ready.WaitOne(); task = null; go.Set();
}
static void Work() {
while (true) {
ready.Set(); // 指明我們已經(jīng)準備好了
go.WaitOne(); // 等待被踢脫...
if (task == null) return; // 優(yōu)雅地退出
Console.WriteLine (task);
}
}
}
ah
ahh
ahhh
ahhhh
注意我們要給task賦null來告訴工作線程退出。在工作線程上調(diào)用Interrupt 或Abort 效果是一樣的,倘若我們先調(diào)用ready.WaitOne的話。因為在調(diào)用ready.WaitOne后我們就知道工作線程的確切位置,不是在就是剛剛在go.WaitOne語句之前,因此避免了中斷任意代碼的復雜性。調(diào)用 Interrupt 或 Abort需要我們在工作線程中捕捉異常。
生產(chǎn)者/消費者隊列
另一個普遍的線程方案是在后臺工作進程從隊列中分配任務(wù)。這叫做生產(chǎn)者/消費者隊列:在工作線程中生產(chǎn)者入列任務(wù),消費者出列任務(wù)。這和上個例子很像,除了當工作線程正忙于一個任務(wù)時調(diào)用者沒有被阻止之外。
生產(chǎn)者/消費者隊列是可縮放的,因為多個消費者可能被創(chuàng)建——每個都服務(wù)于相同的隊列,但開啟了一個分離的線程。這是一個很好的方式利用多處理器的系統(tǒng) 來限制工作線程的數(shù)量一直避免了極大的并發(fā)線程的缺陷(過多的內(nèi)容切換和資源連接)。
在下面例子里,一個單獨的AutoResetEvent被用于通知工作線程,它只有在用完任務(wù)時(隊列為空)等待。一個通用的集合類被用于隊列,必須通過鎖控制它的訪問以確保線程安全。工作線程在隊列為null任務(wù)時結(jié)束:
using System;
using System.Threading;
using System.Collections.Generic;
class ProducerConsumerQueue : IDisposable {
EventWaitHandle wh = new AutoResetEvent (false);
Thread worker;
object locker = new object();
Queue<string> tasks = new Queue<string>();
public ProducerConsumerQueue() {
worker = new Thread (Work);
worker.Start();
}
public void EnqueueTask (string task) {
lock (locker) tasks.Enqueue (task);
wh.Set();
}
public void Dispose() {
EnqueueTask (null); // 告訴消費者退出
worker.Join(); // 等待消費者線程完成
wh.Close(); // 釋放任何OS資源
}
void Work() {
while (true) {
string task = null;
lock (locker)
if (tasks.Count > 0) {
task = tasks.Dequeue();
if (task == null) return;
}
if (task != null) {
Console.WriteLine ("Performing task: " + task);
Thread.Sleep (1000); // 模擬工作...
}
else
wh.WaitOne(); // 沒有任務(wù)了——等待信號
}
}
}
下面是一個主方法測試這個隊列:
class Test {
static void Main() {
using (ProducerConsumerQueue q = new ProducerConsumerQueue()) {
q.EnqueueTask ("Hello");
for (int i = 0; i < 10; i++) q.EnqueueTask ("Say " + i);
q.EnqueueTask ("Goodbye!");
}
// 使用using語句的調(diào)用q的Dispose方法,
// 它入列一個null任務(wù),并等待消費者完成
}
}
Performing task: Hello
Performing task: Say 1
Performing task: Say 2
Performing task: Say 3
...
...
Performing task: Say 9
Goodbye!
注意我們明確的關(guān)閉了Wait Handle在ProducerConsumerQueue被銷毀的時候,因為在程序的生命周期中我們可能潛在地創(chuàng)建和銷毀許多這個類的實例。
ManualResetEvent
ManualResetEvent是AutoResetEvent變化的一種形式,它的不同之處在于:在線程被WaitOne的調(diào)用而通過的時候,它不會自動地reset,這個過程就像大門一樣——調(diào)用Set打開門,允許任何數(shù)量的已執(zhí)行WaitOne的線程通過;調(diào)用Reset關(guān)閉大門,可能會引起一系列的“等待者”直到下次門打開。
你可以用一個布爾字段"gateOpen" (用 volatile 關(guān)鍵字來聲明)與" 方式結(jié)合——重復地檢查標志,然后讓線程休眠一段時間的方式,來模擬這個過程。
ManualResetEvent有時被用于給一個完成的操作發(fā)送信號,又或者一個已初始化正準備執(zhí)行工作的線程。
互斥(Mutex)
Mutex提供了與C#的lock語句同樣的功能,這使它大多時候變得的冗余了。它的優(yōu)勢在于它可以跨進程工作——提供了一計算機范圍的鎖而勝于程序范圍的鎖。
Mutex是相當快的,而lock 又要比它快上數(shù)百倍,獲取Mutex需要花費幾微秒,獲取lock需花費數(shù)十納秒(假定沒有阻止)。
對于一個Mutex類,WaitOne獲取互斥鎖,當被搶占后時發(fā)生阻止?;コ怄i在執(zhí)行了ReleaseMutex之后被釋放,就像C#的lock語句一樣,Mutex只能從獲取互斥鎖的這個線程上被釋放。
Mutex在跨進程的普遍用處是確保在同一時刻只有一個程序的的實例在運行,下面演示如何使用:
class OneAtATimePlease {
// 使用一個應(yīng)用程序的唯一的名稱(比如包括你公司的URL)
static Mutex mutex = new Mutex (false, "oreilly.com OneAtATimeDemo");
static void Main() {
//等待5秒如果存在競爭——存在程序在
// 進程中的的另一個實例關(guān)閉之后
if (!mutex.WaitOne (TimeSpan.FromSeconds (5), false)) {
Console.WriteLine ("Another instance of the app is running. Bye!");
return;
}
try {
Console.WriteLine ("Running - press Enter to exit");
Console.ReadLine();
}
finally { mutex.ReleaseMutex(); }
}
}
Mutex有個好的特性是,如果程序結(jié)束時而互斥鎖沒通過ReleaseMutex首先被釋放,CLR將自動地釋放Mutex。
Semaphore
Semaphore就像一個夜總會:它有固定的容量,這由保鏢來保證,一旦它滿了就沒有任何人可以再進入這個夜總會,并且在其外會形成一個隊列。然后,當人一個人離開時,隊列頭的人便可以進入了。構(gòu)造器需要至少兩個參數(shù)——夜總會的活動的空間,和夜總會的容量。
Semaphore 的特性與Mutex 和 lock有點類似,除了Semaphore沒有“所有者”——它是不可知線程的,任何在Semaphore內(nèi)的線程都可以調(diào)用Release,而Mutex 和 lock僅有那些獲取了資源的線程才可以釋放它。
在下面的例子中,10個線程執(zhí)行一個循環(huán),在中間使用Sleep語句。Semaphore確保每次只有不超過3個線程可以執(zhí)行Sleep語句:
class SemaphoreTest {
static Semaphore s = new Semaphore (3, 3); // Available=3; Capacity=3
static void Main() {
for (int i = 0; i < 10; i++) new Thread (Go).Start();
}
static void Go() {
while (true) {
s.WaitOne();
Thread.Sleep (100); // 每次只有3個線程可以到達這里
s.Release();
}
}
}
WaitAny, WaitAll 和 SignalAndWait
除了Set 和 WaitOne方法外,在類WaitHandle中還有一些用來創(chuàng)建復雜的同步過程的靜態(tài)方法。
WaitAny, WaitAll 和 SignalAndWait使跨多個可能為不同類型的等待句柄變得容易。
SignalAndWait可能是最有用的了:他在某個WaitHandle上調(diào)用WaitOne,并在另一個WaitHandle上自動地調(diào)用Set。你可以在一對EventWaitHandle上裝配兩個線程,而讓它們在某個時間點“相遇”,這馬馬虎虎地合乎規(guī)范。AutoResetEvent 或ManualResetEvent都無法使用這個技巧。第一個線程像這樣:
WaitHandle.SignalAndWait (wh1, wh2);
同時第二個線程做相反的事情:
WaitHandle.SignalAndWait (wh2, wh1);
WaitHandle.WaitAny等待一組等待句柄任意一個發(fā)出信號,WaitHandle.WaitAll等待所有給定的句柄發(fā)出信號。與票據(jù)旋轉(zhuǎn)門的例子類似,這些方法可能同時地等待所有的旋轉(zhuǎn)門——通過在第一個打開的時候(WaitAny情況下),或者等待直到它們所有的都打開(WaitAll情況下)。
WaitAll 實際上是不確定的值,因為這與單元模式線程——從COM體系遺留下來的問題,有著奇怪的聯(lián)系。WaitAll 要求調(diào)用者是一個多線程單元——剛巧是單元模式最適合——尤其是在 Windows Forms程序中,需要執(zhí)行任務(wù)像與剪切板結(jié)合一樣庸俗!
幸運地是,在等待句柄難使用或不適合的時候,.NET framework提供了更先進的信號結(jié)構(gòu)——Monitor.Wait 和 Monitor.Pulse。
同步環(huán)境
與手工的鎖定相比,你可以進行說明性的鎖定,用衍生自ContextBoundObject 并標以Synchronization特性的類,它告訴CLR自動執(zhí)行鎖操作,看這個例子:
using System;
using System.Threading;
using System.Runtime.Remoting.Contexts;
[Synchronization]
public class AutoLock : ContextBoundObject {
public void Demo() {
Console.Write ("Start...");
Thread.Sleep (1000); // 我們不能搶占到這
Console.WriteLine ("end"); // 感謝自動鎖!
}
}
public class Test {
public static void Main() {
AutoLock safeInstance = new AutoLock();
new Thread (safeInstance.Demo).Start(); // 并發(fā)地
new Thread (safeInstance.Demo).Start(); // 調(diào)用Demo
safeInstance.Demo(); // 方法3次
}
}
Start... end
Start... end
Start... end
CLR確保了同一時刻只有一個線程可以執(zhí)行 safeInstance中的代碼。它創(chuàng)建了一個同步對象來完成工作,并在每次調(diào)用safeInstance的方法和屬性時在其周圍只能夠行鎖定。鎖的作用域——這里是safeInstance對象,被稱為同步環(huán)境。
那么,它是如何工作的呢?Synchronization特性的命名空間:System.Runtime.Remoting.Contexts是一個線索。ContextBoundObject可以被認為是一個“遠程”對象,這意味著所有方法的調(diào)用是被監(jiān)聽的。讓這個監(jiān)聽稱為可能,就像我們的例子AutoLock,CLR自動的返回了一個具有相同方法和屬性的AutoLock對象的代理對象,它扮演著一個中間者的角色。總的來說,監(jiān)聽在每個方法調(diào)用時增加了數(shù)微秒的時間。
自動同步不能用于靜態(tài)類型的成員,和非繼承自 ContextBoundObject(例如:Windows Form)的類。
鎖在內(nèi)部以相同的方式運作,你可能期待下面的例子與之前的有一樣的結(jié)果:
[Synchronization]
public class AutoLock : ContextBoundObject {
public void Demo() {
Console.Write ("Start...");
Thread.Sleep (1000);
Console.WriteLine ("end");
}
public void Test() {
new Thread (Demo).Start();
new Thread (Demo).Start();
new Thread (Demo).Start();
Console.ReadLine();
}
public static void Main() {
new AutoLock().Test();
}
}
(注意我們放入了Console.ReadLine語句。)因為在同一時刻的同一個此類的對象中只有一個線程可以執(zhí)行代碼,三個新線程將保持被阻止在Demo 放中,直到Test 方法完成,需要等待ReadLine來完成。因此我們以與之前的有相同結(jié)果而告終,但是只有在按完Enter鍵之后。這是一個線程安全的手段,差不多足夠能在類中排除任何有用的多線程!
此外,我們?nèi)晕唇鉀Q之前描述的一個問題:如果AutoLock是一個集合類,比如說,我們?nèi)匀恍枰粋€像下面一樣的鎖,假設(shè)運行在另一個類里:
if (safeInstance.Count > 0) safeInstance.RemoveAt (0);
除非使用這代碼的類本身是一個同步的ContextBoundObject!
同步環(huán)境可以擴展到超過一個單獨對象的區(qū)域。默認地,如果一個同步對象被實例化從在另一段代碼之內(nèi),它們擁有共享相同的同步環(huán)境(換言之,一個大鎖!)。這個行為可以由改變Synchronization特性的構(gòu)造器的參數(shù)來指定。使用SynchronizationAttribute類定義的常量之一:
常量 |
含義 |
NOT_SUPPORTED |
相當于不使用同步特性 |
SUPPORTED |
如果從另一個同步對象被實例化,則合并已存在的同步環(huán)境,否則只剩下非同步。 |
REQUIRED (默認) |
如果從另一個同步對象被實例化,則合并已存在的同步環(huán)境,否則創(chuàng)建一個新的同步環(huán)境。 |
REQUIRES_NEW |
總是創(chuàng)建新的同步環(huán)境 |
所以如果SynchronizedA的實例被實例化于SynchronizedB的對象中,如果SynchronizedB像下面這樣聲明的話,它們將有分離的同步環(huán)境:
[Synchronization (SynchronizationAttribute.REQUIRES_NEW)]
public class SynchronizedB : ContextBoundObject { ...
越大的同步環(huán)境越容易管理,但是減少機會對有用的并發(fā)。換個有限的角度,分離的同步環(huán)境會造成死鎖,看這個例子:
[Synchronization]
public class Deadlock : ContextBoundObject {
public DeadLock Other;
public void Demo() { Thread.Sleep (1000); Other.Hello(); }
void Hello() { Console.WriteLine ("hello"); }
}
public class Test {
static void Main() {
Deadlock dead1 = new Deadlock();
Deadlock dead2 = new Deadlock();
dead1.Other = dead2;
dead2.Other = dead1;
new Thread (dead1.Demo).Start();
dead2.Demo();
}
}
因為每個Deadlock的實例在Test內(nèi)創(chuàng)建——一個非同步類,每個實例將有它自己的同步環(huán)境,因此,有它自己的鎖。當它們彼此調(diào)用的時候,不會花太多時間就會死鎖(確切的說是一秒!)。如果Deadlock 和 Test是由不同開發(fā)團隊來寫的,這個問題特別容易發(fā)生。別指望Test知道如何產(chǎn)生的錯誤,更別指望他們來解決它了。在死鎖顯而易見的情況下,這與使用明確的鎖的方式形成鮮明的對比。
可重入性問題
線程安全方法有時候也被稱為可重入式的,因為在它執(zhí)行的時候可以被搶占部分線路,在另外的線程調(diào)用也不會帶來壞效果。從某個意義上講,術(shù)語線程安全 和 可重入式的是同義的或者是貼義的。
不過在自動鎖方式上,如果Synchronization的參數(shù)可重入式的 為true的話,可重入性會有潛在的問題:
[Synchronization(true)]
同步環(huán)境的鎖在執(zhí)行離開上下文時被臨時地釋放。在之前的例子里,這將能預(yù)防死鎖的發(fā)生;很明顯很需要這樣的功能。然而一個副作用是,在這期間,任何線程都可以自由的調(diào)用在目標對象(“重進入”的同步上下文)的上任何方法,而非常復雜的多線程中試圖避免不釋放資源是排在首位的。這就是可重入性的問題。
因為[Synchronization(true)]作用于類級別,這特性打開了對于非上下文的方法訪問,由于可重入性問題使它們混入類的調(diào)用。
雖然可重入性是危險的,但有些時候它是不錯的選擇。比如:設(shè)想一個在其內(nèi)部實現(xiàn)多線程同步的類,將邏輯工作線程運行在不同的語境中。在沒有可重入性問題的情況下,工作線程在它們彼此之間或目標對象之間可能被無理地阻礙。
這凸顯了自動同步的一個基本弱點:超過適用的大范圍的鎖定帶來了其它情況沒有帶來的巨大麻煩。這些困難:死鎖,可重入性問題和被閹割的并發(fā),使另一個更簡單的方案——手動的鎖定變得更為合適。