天天看點

C#中的線程(二)線程同步第二部分:線程同步基礎

Keywords:C# 線程

Author: Joe Albahari

Translator: Swanky Wu

下面的表格列展了.NET對協調或同步線程動作的可用的工具:

簡易阻止方法

構成

目的

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_Sleeping_And_Spinning">Sleep</a>

阻止給定的時間周期

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_Join">Join</a>

等待另一個線程完成

鎖系統

跨程序?

速度

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_Locking">lock</a>

確定隻有一個線程通路某個資源或某段代碼。

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_Mutex">Mutex</a>

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_MutexSingleAppInstance">是</a>

中等

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_Semaphore">Semaphore</a>

確定不超過指定數目的線程通路某個資源或某段代碼。

信号系統

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_AutoResetEvent">EventWaitHandle</a>

允許線程等待直到它受到了另一個線程發出信号。

<a href="http://www.cnblogs.com/txw1958/admin/EditPosts.aspx?opt=1#_CrossProcessEventWaitHandle">是</a>

允許一個線程等待直到自定義阻止條件得到滿足。

非阻止同步系統*

完成簡單的非阻止原子操作。

是(記憶體共享情況下)

非常快

允許安全的非阻止在鎖之外使用個别字段。

阻止的條件已得到滿足

操作逾時(如果timeout被指定了)

更确切地說,Thread.Sleep放棄了占用CPU,請求不在被配置設定時間直到給定的時間經過。Thread.Sleep(0)放棄CPU的時間剛剛夠其它在時間片隊列裡的活動線程(如果有的話)被執行。

線程類同時也提供了一個SpinWait方法,它使用輪詢CPU而非放棄CPU時間的方式,保持給定的疊代次數進行“無用地繁忙”。50疊代可能等同于停頓大約一微秒,雖然這将取決于CPU的速度和負載。從技術上講,SpinWait并不是一個阻止的方法:一個處于spin-waiting的線程的ThreadState不是WaitSleepJoin狀态,并且也不會被其它的線程過早的中斷(Interrupt)。SpinWait很少被使用,它的作用是等待一個在極短時間(可能小于一微秒)内可準備好的可預期的資源,而不用調用Sleep方法阻止線程而浪費CPU時間。不過,這種技術的優勢隻有在多處理器計算機:對單一處理器的電腦,直到輪詢的線程結束了它的時間片之前,一個資源沒有機會改變狀态,這有違它的初衷。并且調用SpinWait經常會花費較長的時間這本身就浪費了CPU時間。

線程可以等待某個确定的條件來明确輪詢使用一個輪詢的方式,比如:

或者:

阻止和輪詢組合使用可以産生一些變換:

x越大,CPU效率越高,折中方案是增大潛伏時間,任何20ms的花費是微不足道的,除非循環中的條件是極其複雜的。

你可以通過Join方法阻止線程直到另一個線程結束:

Join方法也接收一個使用毫秒或用TimeSpan類的逾時參數,當Join逾時是傳回false,如果線程已終止,則傳回true 。Join所帶的逾時參數非常像Sleep方法,實際上下面兩行代碼幾乎差不多:

(他們的差別明顯在于單線程的應用程式域與COM互操作性,源于先前描述Windows資訊汲取部分:在阻止時,Join保持資訊汲取,Sleep暫停資訊汲取。)

鎖實作互斥的通路,被用于確定在同一時刻隻有一個線程可以進入特殊的代碼片段,考慮下面的類:

下面用lock來修正這個問題:

C#的lock 語句實際上是調用Monitor.Enter和Monitor.Exit,中間夾雜try-finally語句的簡略版,下面是實際發生在之前例子中的Go方法:

在同一個對象上,在調用第一個之前Monitor.Enter而先調用了Monitor.Exit将引發異常。

Monitor 也提供了TryEnter方法來實作一個逾時功能——也用毫秒或TimeSpan,如果獲得了鎖傳回true,反之沒有獲得傳回false,因為逾時了。TryEnter也可以沒有逾時參數,“測試”一下鎖,如果鎖不能被擷取的話就立刻逾時。

任何對所有有關系的線程都可見的對象都可以作為同步對象,但要服從一個硬性規定:它必須是引用類型。也強烈建議同步對象最好私有在類裡面(比如一個私有執行個體字段)防止無意間從外部鎖定相同的對象。服從這些規則,同步對象可以兼對象和保護兩種作用。比如下面List :

一個專門字段是常用的(如在先前的例子中的locker) , 因為它可以精确控制鎖的範圍和粒度。用對象或類本身的類型作為一個同步對象,即:

或:

是不好的,因為這潛在的可以在公共範圍通路這些對象。

鎖并沒有以任何方式阻止對同步對象本身的通路,換言之,x.ToString()不會由于另一個線程調用lock(x) 而被阻止,兩者都要調用lock(x) 來完成阻止工作。

線程可以重複鎖定相同的對象,可以通過多次調用Monitor.Enter或lock語句來實作。當對應編号的Monitor.Exit被調用或最外面的lock語句完成後,對象那一刻被解鎖。這就允許最簡單的文法實作一個方法的鎖調用另一個鎖:

線程隻能在最開始的鎖或最外面的鎖時被阻止。

作為一項基本規則,任何和多線程有關的會進行讀和寫的字段應當加鎖。甚至是極平常的事情——單一字段的指派操作,都必須考慮到同步問題。在下面的例子中Increment和Assign 都不是線程安全的:

下面是Increment 和 Assign 線程安全的版本:

如果有很多變量在一些鎖中總是進行讀和寫的操作,那麼你可以稱之為原子操作。我們假設x 和 y不停地讀和指派,他們在鎖内通過locker鎖定:

你可以認為x 和 y 通過原子的方式通路,因為代碼段沒有被其它的線程分開 或 搶占,别的線程改變x 和 y是無效的輸出,你永遠不會得到除數為零的錯誤,保證了x 和 y總是被相同的排他鎖通路。

鎖定本身是非常快的,一個鎖在沒有堵塞的情況下一般隻需幾十納秒(十億分之一秒)。如果發生堵塞,任務切換帶來的開銷接近于數微秒(百萬分之一秒)的範圍内,盡管線上程重組實際的安排時間之前它可能花費數毫秒(千分之一秒)。而相反,與此相形見绌的是該使用鎖而沒使用的結果就是帶來數小時的時間,甚至逾時。

如果耗盡并發,鎖定會帶來反作用,死鎖和争用鎖,耗盡并發由于太多的代碼被放置到鎖語句中了,引起其它線程不必要的被阻止。死鎖是兩線程彼此等待被鎖定的内容,導緻兩者都無法繼續下去。争用鎖是兩個線程任一個都可以鎖定某個内容,如果“錯誤”的線程擷取了鎖,則導緻程式錯誤。

對于太多的同步對象死鎖是非常容易出現的症狀,一個好的規則是開始于較少的鎖,在一個可信的情況下涉及過多的阻止出現時,增加鎖的粒度。

線程安全的代碼是指在面對任何多線程情況下,這代碼都沒有不确定的因素。線程安全首先完成鎖,然後減少線上程間互動的可能性。

一個線程安全的方法,在任何情況下可以可重入式調用。通用類型在它們中很少是線程安全的,原因如下:

完全線程安全的開發是重要的,尤其是一個類型有很多字段(在任意多線程上下文中每個字段都有潛在的互動作用)的情況下。

線程安全帶來性能損失(要付出的,在某種程度上無論與否類型是否被用于多線程)。

一個線程安全類型不一定能使程式使用線程安全,有時參與工作後者可使前者變得備援。

是以線程安全經常隻在需要實作的地方來實作,為了處理一個特定的多線程情況。

不過,有一些方法來“欺騙”,有龐大和複雜的類安全地運作在多線程環境中。一種是犧牲粒度包含大段的代碼——甚至在排他鎖中通路全局對象,迫使在更高的級别上實作串行化通路。這一政策也很關鍵,讓非線程安全的對象用于線程安全代碼中,避免了相同的互斥鎖被用于保護對在非線程安全對象的所有的屬性、方法和字段的通路。

另一個方式欺騙是通過最小化共享資料來最小化線程互動。這是一個很好的途徑,被暗中地用于“弱狀态”的中間層程式和web伺服器。自多個用戶端請求同時到達,每個請求來自它自己的線程(效力于ASP.NET,Web伺服器或者遠端體系結構),這意味着它們調用的方法一定是線程安全的。弱狀态設計(因伸縮性好而流行)本質上限制了互動的能力,是以類不能夠在每個請求間持久保留資料。線程互動僅限于可以被選擇建立的靜态字段,多半是在記憶體裡緩存常用資料和提供基礎設施服務,例如認證和稽核。

在這種情況下,我們鎖定了list對象本身,這個簡單的方案是很好的。如果我們有兩個相關的list,也許我們就要鎖定一個共同的目标——可能是單獨的一個字段,如果沒有其它的list出現,顯然鎖定它自己是明智的選擇。

枚舉.NET的集合也不是線程安全的,在枚舉的時候另一個線程改動list的話,會抛出異常。勝于直接鎖定枚舉過程,在這個例子中,我們首先将項目複制到數組當中,這就避免了固定住鎖因為我們在枚舉過程中有潛在的耗時。

這裡的一個有趣的假設:想象如果List實際上為線程安全的,如何解決呢?代碼會很少!舉例說明,我們說我們要增加一個項目到我們假象的線程安全的list裡,如下:

無論與否list是否為線程安全的,這個語句顯然不是!整個if語句必須放到一個鎖中,用來保護搶占在判斷有無和增加新的之間。上述的鎖需要用于任何我們需要修改list的地方,比如下面的語句需要被同樣的鎖包包覆:

來保證它沒有搶占之前的語句,換言之,我們必須鎖定差不多所有非線程安全的集合類們。内置的線程安全,顯而易見是浪費時間!

在寫自定義元件的時候,你可能會反對這個觀點——為什麼建造線程安全讓它容易的結果會變的多餘呢 ?

有一個争論:在一個對象包上自定義的鎖僅在所有并行的線程知道、并且使用這個鎖的時候才能工作,而如果鎖對象在更大的範圍内的時候,這個鎖對象可能不在這個鎖範圍内。最糟糕的情況是靜态成員在公共類型中出現了,比如,想象靜态結構在DateTime上,DateTime.Now不是線程安全的,當有2個并發的調用可帶來錯亂的輸出或異常,補救方式是在其外進行鎖定,可能鎖定它的類型本身—— lock(typeof(DateTime))來圈住調用DateTime.Now,這會工作的,但隻有所有的程式員同意這樣做的時候。然而這并靠不住,鎖定一個類型被認為是一件非常不好的事情。

由于這些理由,DateTime上的靜态成員是保證線程安全的,這是一個遍及.NET framework一個普遍模式——靜态成員是線程安全的,而一個執行個體成員則不是。從這個模式也能在寫自定義類型時得到一些體會,不要建立一個不能線程安全的難題!

當寫公用元件的時候,好的習慣是不要忘記了線程安全,這意味着要單獨小心處理那些在其中或公共的靜态成員。

一個被阻止的線程可以通過兩種方式被提前釋放:

這必須通過另外活動的線程實作,等待的線程是沒有能力對它的被阻止狀态做任何事情的。

在一個被阻止的線程上調用Interrupt 方法,将強迫釋放它,抛出ThreadInterruptedException異常,如下:

Forcibly Woken!

中斷一個線程僅僅釋放它的目前的(或下一個)等待狀态:它并不結束這個線程(當然,除非未處理ThreadInterruptedException異常)。

如果Interrupt被一個未阻止的線程調用,那麼線程将繼續執行直到下一次被阻止時,它抛出ThreadInterruptedException異常。用下面的測試避免這個問題:

随意中斷線程是危險的,因為任何架構或第三方方法在調用堆棧時可能會意外地在已訂閱的代碼上收到中斷。這一切将被認為是線程被暫時阻止在一個鎖中或同步資源中,并且所有挂起的中斷将被踢開。如果這個方法沒有被設計成可以被中斷(沒有适當處理finally塊)的對象可能剩下無用的狀态,或資源不完全地被釋放。

被阻止的線程也可以通過Abort方法被強制釋放,這與調用Interrupt相似,除了用ThreadAbortException異常代替了ThreadInterruptedException異常,此外,異常将被重新抛出在catch裡(在試圖以有好方式處理異常的時候),直到Thread.ResetAbort在catch中被調用;在這期間線程的ThreadState為AbortRequested。

C#中的線程(二)線程同步第二部分:線程同步基礎

圖1: 線程狀态關系圖

你可以通過ThreadState屬性擷取線程的執行狀态。圖1将ThreadState列舉為“層”。ThreadState被設計的很恐怖,它以按位計算的方式組合三種狀态“層”,每種狀态層的成員它們間都是互斥的,下面是所有的三種狀态“層”:

運作 (running) / 阻止 (blocking) / 終止 (aborting) 狀态(圖1顯示)

背景 (background) / 前台 (foreground) 狀态 (ThreadState.Background)

總的來說,ThreadState是按位組合零或每個狀态層的成員!一個簡單的ThreadState例子:

(所枚舉的成員有兩個從來沒被用過,至少是目前CLR實作上:StopRequested 和 Aborted。)

還有更加複雜的,ThreadState.Running潛在的值為0 ,是以下面的測試不工作:

你必須用按位與非操作符來代替,或者使用線程的IsAlive屬性。但是IsAlive可能不是你想要的,它在被阻止或挂起的時候傳回true(隻有線上程未開始或已結束時它才為true)。

ThreadState對調試或程式概要分析是無價之寶,與之不相稱的是多線程的協同工作,因為沒有一個機制存在:通過判斷ThreadState來執行資訊,而不考慮ThreadState期間的變化。

這三個類都依賴于WaitHandle類,盡管從功能上講, 它們相當的不同。但它們做的事情都有一個共同點,那就是,被“點名”,這允許它們繞過作業系統程序工作,而不是隻能在目前程序裡繞過線程。

性能方面,使用Wait Handles系統開銷會花費在較小微秒間,不會在它們使用的上下文中産生什麼後果。

AutoResetEvent在WaitHandle中是最有用的的類,它連同lock 語句是一個主要的同步結構。

如果Set調用時沒有任何線程處于等待狀态,那麼句柄保持打開直到某個線程調用了WaitOne 。這個行為避免了線上程起身去旋轉門和線程插入票(哦,插入票是非常短的微秒間的事,真倒黴,你将必須不确定地等下去了!)間的競争。但是在沒人等的時候重複地在門上調用Set方法不會允許在一隊人都通過,在他們到達的時候:僅有下一個人可以通過,多餘的票都被“浪費了"。

Reset方法提供在沒有任何等待或阻止的時候關閉旋轉門——它應該是開着的。

AutoResetEvent可以通過2種方式建立,第一種是通過構造函數:

如果布爾參數為真,Set方法在構造後立刻被自動的調用,另一個方法是通過它的基類EventWaitHandle:

在Wait Handle不在需要時候,你應當調用Close方法來釋放作業系統資源。但是,如果一個Wait Handle将被用于程式(就像這一節的大多例子一樣)的生命周期中,你可以發點懶省略這個步驟,它将在程式域銷毀時自動的被銷毀。

接下來這個例子,一個線程開始等待直到另一個線程發出信号。

Waiting... (pause) Notified.

EventWaitHandle的構造器允許以“命名”的方式進行建立,它有能力跨多個程序。名稱是個簡單的字元串,可能會無意地與别的沖突!如果名字使用了,你将引用相同潛在的EventWaitHandle,除非作業系統建立一個新的,看這個例子:

如果有兩個程式都運作這段代碼,他們将彼此可以發送信号,等待句柄可以跨這兩個程序中的所有線程。

設想我們希望在背景完成任務,不在每次我們得到任務時再建立一個新的線程。我們可以通過一個輪詢的線程來完成:等待一個任務,執行它,然後等待下一個任務。這是一個普遍的多線程方案。也就是在建立線程上切分内務操作,任務執行被序列化,在多個工作線程和過多的資源消耗間排除潛在的不想要的操作。

ah

ahh

ahhh

ahhhh

另一個普遍的線程方案是在背景工作程序從隊列中配置設定任務。這叫做生産者/消費者隊列:在工作線程中生産者入列任務,消費者出列任務。這和上個例子很像,除了當工作線程正忙于一個任務時調用者沒有被阻止之外。

生産者/消費者隊列是可縮放的,因為多個消費者可能被建立——每個都服務于相同的隊列,但開啟了一個分離的線程。這是一個很好的方式利用多處理器的系統 來限制工作線程的數量一直避免了極大的并發線程的缺陷(過多的内容切換和資源連接配接)。

下面是一個主方法測試這個隊列:

Performing task: Hello

Performing task: Say 1

Performing task: Say 2

Performing task: Say 3

...

Performing task: Say 9

Goodbye!

注意我們明确的關閉了Wait Handle在ProducerConsumerQueue被銷毀的時候,因為在程式的生命周期中我們可能潛在地建立和銷毀許多這個類的執行個體。

ManualResetEvent有時被用于給一個完成的操作發送信号,又或者一個已初始化正準備執行工作的線程。

Mutex是相當快的,而lock 又要比它快上數百倍,擷取Mutex需要花費幾微秒,擷取lock需花費數十納秒(假定沒有阻止)。

對于一個Mutex類,WaitOne擷取互斥鎖,當被搶占後時發生阻止。互斥鎖在執行了ReleaseMutex之後被釋放,就像C#的lock語句一樣,Mutex隻能從擷取互斥鎖的這個線程上被釋放。

Mutex有個好的特性是,如果程式結束時而互斥鎖沒通過ReleaseMutex首先被釋放,CLR将自動地釋放Mutex。

Semaphore就像一個夜總會:它有固定的容量,這由保镖來保證,一旦它滿了就沒有任何人可以再進入這個夜總會,并且在其外會形成一個隊列。然後,當人一個人離開時,隊列頭的人便可以進入了。構造器需要至少兩個參數——夜總會的活動的空間,和夜總會的容量。

Semaphore 的特性與Mutex 和 lock有點類似,除了Semaphore沒有“所有者”——它是不可知線程的,任何在Semaphore内的線程都可以調用Release,而Mutex 和 lock僅有那些擷取了資源的線程才可以釋放它。

在下面的例子中,10個線程執行一個循環,在中間使用Sleep語句。Semaphore確定每次隻有不超過3個線程可以執行Sleep語句:

除了Set 和 WaitOne方法外,在類WaitHandle中還有一些用來建立複雜的同步過程的靜态方法。

WaitAny, WaitAll 和 SignalAndWait使跨多個可能為不同類型的等待句柄變得容易。

SignalAndWait可能是最有用的了:他在某個WaitHandle上調用WaitOne,并在另一個WaitHandle上自動地調用Set。你可以在一對EventWaitHandle上裝配兩個線程,而讓它們在某個時間點“相遇”,這馬馬虎虎地合乎規範。AutoResetEvent 或ManualResetEvent都無法使用這個技巧。第一個線程像這樣:

同時第二個線程做相反的事情:

WaitHandle.WaitAny等待一組等待句柄任意一個發出信号,WaitHandle.WaitAll等待所有給定的句柄發出信号。與票據旋轉門的例子類似,這些方法可能同時地等待所有的旋轉門——通過在第一個打開的時候(WaitAny情況下),或者等待直到它們所有的都打開(WaitAll情況下)。

與手工的鎖定相比,你可以進行說明性的鎖定,用衍生自ContextBoundObject 并标以Synchronization特性的類,它告訴CLR自動執行鎖操作,看這個例子:

Start... end

CLR確定了同一時刻隻有一個線程可以執行 safeInstance中的代碼。它建立了一個同步對象來完成工作,并在每次調用safeInstance的方法和屬性時在其周圍隻能夠行鎖定。鎖的作用域——這裡是safeInstance對象,被稱為同步環境。

那麼,它是如何工作的呢?Synchronization特性的命名空間:System.Runtime.Remoting.Contexts是一個線索。ContextBoundObject可以被認為是一個“遠端”對象,這意味着所有方法的調用是被監聽的。讓這個監聽稱為可能,就像我們的例子AutoLock,CLR自動的傳回了一個具有相同方法和屬性的AutoLock對象的代理對象,它扮演着一個中間者的角色。總的來說,監聽在每個方法調用時增加了數微秒的時間。

自動同步不能用于靜态類型的成員,和非繼承自 ContextBoundObject(例如:Windows Form)的類。

鎖在内部以相同的方式運作,你可能期待下面的例子與之前的有一樣的結果:

(注意我們放入了Console.ReadLine語句。)因為在同一時刻的同一個此類的對象中隻有一個線程可以執行代碼,三個新線程将保持被阻止在Demo 放中,直到Test 方法完成,需要等待ReadLine來完成。是以我們以與之前的有相同結果而告終,但是隻有在按完Enter鍵之後。這是一個線程安全的手段,差不多足夠能在類中排除任何有用的多線程!

此外,我們仍未解決之前描述的一個問題:如果AutoLock是一個集合類,比如說,我們仍然需要一個像下面一樣的鎖,假設運作在另一個類裡:

除非使用這代碼的類本身是一個同步的ContextBoundObject!

同步環境可以擴充到超過一個單獨對象的區域。預設地,如果一個同步對象被執行個體化從在另一段代碼之内,它們擁有共享相同的同步環境(換言之,一個大鎖!)。這個行為可以由改變Synchronization特性的構造器的參數來指定。使用SynchronizationAttribute類定義的常量之一:

常量

含義

NOT_SUPPORTED

相當于不使用同步特性

SUPPORTED

如果從另一個同步對象被執行個體化,則合并已存在的同步環境,否則隻剩下非同步。

REQUIRED

(預設)

如果從另一個同步對象被執行個體化,則合并已存在的同步環境,否則建立一個新的同步環境。

REQUIRES_NEW

總是建立新的同步環境

是以如果SynchronizedA的執行個體被執行個體化于SynchronizedB的對象中,如果SynchronizedB像下面這樣聲明的話,它們将有分離的同步環境:

越大的同步環境越容易管理,但是減少機會對有用的并發。換個有限的角度,分離的同步環境會造成死鎖,看這個例子:

因為每個Deadlock的執行個體在Test内建立——一個非同步類,每個執行個體将有它自己的同步環境,是以,有它自己的鎖。當它們彼此調用的時候,不會花太多時間就會死鎖(确切的說是一秒!)。如果Deadlock 和 Test是由不同開發團隊來寫的,這個問題特别容易發生。别指望Test知道如何産生的錯誤,更别指望他們來解決它了。在死鎖顯而易見的情況下,這與使用明确的鎖的方式形成鮮明的對比。

不過在自動鎖方式上,如果Synchronization的參數可重入式的 為true的話,可重入性會有潛在的問題:

同步環境的鎖在執行離開上下文時被臨時地釋放。在之前的例子裡,這将能預防死鎖的發生;很明顯很需要這樣的功能。然而一個副作用是,在這期間,任何線程都可以自由的調用在目标對象(“重進入”的同步上下文)的上任何方法,而非常複雜的多線程中試圖避免不釋放資源是排在首位的。這就是可重入性的問題。

因為[Synchronization(true)]作用于類級别,這特性打開了對于非上下文的方法通路,由于可重入性問題使它們混入類的調用。

雖然可重入性是危險的,但有些時候它是不錯的選擇。比如:設想一個在其内部實作多線程同步的類,将邏輯工作線程運作在不同的語境中。在沒有可重入性問題的情況下,工作線程在它們彼此之間或目标對象之間可能被無理地阻礙。

這凸顯了自動同步的一個基本弱點:超過适用的大範圍的鎖定帶來了其它情況沒有帶來的巨大麻煩。這些困難:死鎖,可重入性問題和被閹割的并發,使另一個更簡單的方案——手動的鎖定變得更為合适。

繼續閱讀