C#中的多線程 - 同步基礎
C#中的多線程?- 同步基礎
1同步概要
在第?1?部分:基礎知識中,我們描述了如何在線程上啟動任務、配置線程以及雙向傳遞數據。同時也說明了局部變量對于線程來說是私有的,以及引用是如何在線程之間共享,允許其通過公共字段進行通信。
下一步是同步(synchronization):為期望的結果協調線程的行為。當多個線程訪問同一個數據時,同步尤其重要,但是這是一件非常容易搞砸的事情。
同步構造可以分為以下四類:
- 簡單的阻塞方法
- 這些方法會使當前線程等待另一個線程結束或是自己等待一段時間。Sleep、Join與Task.Wait都是簡單的阻塞方法。
- 鎖構造
- 鎖構造能夠限制每次可以執行某些動作或是執行某段代碼的線程數量。排它鎖構造是最常見的,它每次只允許一個線程執行,從而可以使得參與競爭的線程在訪問公共數據時不會彼此干擾。標準的排它鎖構造是lock(Monitor.Enter/Monitor.Exit)、Mutex與?SpinLock。非排它鎖構造是Semaphore、SemaphoreSlim以及讀寫鎖。
- 信號構造
- 信號構造可以使一個線程暫停,直到接收到另一個線程的通知,避免了低效的輪詢?。有兩種經常使用的信號設施:事件等待句柄(event?wait?handle?)和Monitor類的Wait?/?Pluse方法。Framework?4.0?加入了CountdownEvent與Barrier類。
- 非阻塞同步構造
- 非阻塞同步構造通過調用處理器指令來保護對公共字段的訪問。CLR?與?C#?提供了下列非阻塞構造:Thread.MemoryBarrier?、Thread.VolatileRead、Thread.VolatileWrite、volatile關鍵字以及Interlocked類。
阻塞這個概念對于前三類來說都非常重要,接下來我們簡要的剖析下它。
1.1阻塞
當線程的執行由于某些原因被暫停,比如調用Sleep等待一段時間,或者通過Join或EndInvoke方法等待其它線程結束時,則認為此線程被阻塞(blocked)。被阻塞的線程會立即出讓(yields)其處理器時間片,之后不再消耗處理器時間,直到阻塞條件被滿足。可以通過線程的ThreadState屬性來檢查一個線程是否被阻塞:
bool?blocked?=?(someThread.ThreadState?&?ThreadState.WaitSleepJoin)?!=?0;
(上面例子中線程狀態可能在進行狀態判斷和依據狀態進行操作之間發生改變,因此這段代碼僅可用于調試診斷的場景。)
當一個線程被阻塞或是解除阻塞時,操作系統會進行上下文切換(context?switch),這會帶來幾微秒的額外時間開銷。
阻塞會在以下?4?種情況下解除(電源按鈕可不能算╮(╯▽╰)╭):
- 阻塞條件被滿足
- 操作超時(如果指定了超時時間)
- 通過Thread.Interrupt中斷
- 通過Thread.Abort中止
通過Suspend方法(已過時,不應該再使用)暫停線程的執行不被認為是阻塞。
1.2阻塞?vs?自旋
有時線程必須暫停,直到特定條件被滿足。信號構造和鎖構造可以通過在條件被滿足前阻塞線程來實現。但是還有一種更為簡單的方法:線程可以通過自旋(spinning)來等待條件被滿足。例如:
while?(!proceed);
一般來說,這會非常浪費處理器時間:因為對?CLR?和操作系統來說,這個線程正在執行重要的計算,就給它分配了相應的資源。
有時會組合使用阻塞與自旋:
while?(!proceed)?
Thread.Sleep?(10);
盡管并不優雅,但是這比僅使用自旋更高效(一般來說)。然而這樣也可能會出現問題,這是由proceed標識上的并發問題引起的。正確的使用和鎖構造和信號構造可以避免這個問題。
自旋在等待的條件很快(大致幾微秒)就能被滿足的情況下更高效,因為它避免了上下文切換帶來的額外開銷。.NET?Framework?提供了專門的方法和類型來輔助實現自旋,在第?5?部分會講到。
1.3線程狀態
可以通過線程的ThreadState屬性來查詢線程狀態,它會返回一個ThreadState類型的按位方式組合的枚舉值,其中包含了三“層”信息。然而大多數值都是冗余的、無用的或者過時不建議使用的。下圖是其中一“層”信息:
下面的代碼可以提取線程狀態中最有用的?4?個值:?Unstarted、Running、WaitSleepJoin和Stopped:
public?static?ThreadState?SimpleThreadState?(ThreadState?ts){ ??return?ts?&?(ThreadState.Unstarted?| ???????????????ThreadState.WaitSleepJoin?| ???????????????ThreadState.Stopped);}
ThreadState屬性在進行調試診斷時有用,但不適合用來進行同步,因為線程狀態可能在判斷狀態和依據狀態進行操作之間發生改變。
2鎖
排它鎖用于確保同一時間只允許一個線程執行指定的代碼段。主要的兩個排它鎖構造是lock和Mutex(互斥體)。其中lock更快,使用也更方便。而Mutex的優勢是它可以跨進程的使用。
在這一節里,我們從介紹lock構造開始,然后介紹Mutex和信號量(semaphore)(用于非排它場景)。稍后在第?4?部分會介紹讀寫鎖(reader?/?writer?lock)。
Framework?4.0?加入了SpinLock結構體,可以用于高并發場景。
讓我們從下邊這個類開始:
class?ThreadUnsafe{ ??static?int?_val1?=?1,?_val2?=?1; ??static?void?Go() ??{ ????if?(_val2?!=?0)?Console.WriteLine?(_val1?/?_val2); ????_val2?=?0; ??}}
這個類不是線程安全的:如果Go方法同時被兩個線程調用,可能會產生除數為零錯誤,因為可能在一個線程剛好執行完if的判斷語句但還沒執行Console.WriteLine語句時,_val2就被另一個線程設置為零。
下邊使用lock解決這個問題:
class?ThreadSafe{ ??static?readonly?object?_locker?=?new?object(); ??static?int?_val1,?_val2; ??static?void?Go() ??{ ????lock?(_locker) ????{ ??????if?(_val2?!=?0)?Console.WriteLine?(_val1?/?_val2); ??????_val2?=?0; ????} ??}}
同一時間只有一個線程可以鎖定同步對象(這里指_locker),并且其它競爭鎖的線程會被阻塞,直到鎖被釋放。如果有多個線程在競爭鎖,它們會在一個“就緒隊列(ready?queue)”中排隊,并且遵循先到先得的規則(需要說明的是,Windows?系統和?CLR?的差別可能導致這個隊列在有時會不遵循這個規則)。因為一個線程的訪問不能與另一個線程相重疊,排它鎖有時也被這樣描述:它強制對鎖保護的內容進行順序(serialized)訪問。在這個例子中,我們保護的是Go方法的內部邏輯,還有_val1與_val2字段。
在競爭鎖時被阻塞的線程,它的線程狀態是WaitSleepJoin。在中斷與中止中,我們會描述如何通過其它線程強制釋放被阻塞的線程,這是一種可以用于結束線程的重型技術(譯者注:這里指它們應該被作為在沒有其它更為優雅的辦法時的最后手段)。
鎖構造比較Permalink
構造 | 用途 | 跨進程 | 開銷* |
lock?(Monitor.Enter/Monitor.Exit) | 確保同一時間只有一個線程可以訪問資源或代碼 | - | 20ns |
Mutex | ? | 1000ns | |
SemaphoreSlim?(Framework?4.0?中加入) | 確保只有不超過指定數量的線程可以并發訪問資源或代碼 | - | 200ns |
Semaphore | ? | 1000ns | |
ReaderWriterLockSlim?(Framework?3.5?中加入) | 允許多個讀線程和一個寫線程共存 | - | 40ns |
ReaderWriterLock?(已過時) | - | 100ns |
*?時間代表在同一線程上一次進行加鎖和釋放鎖(假設沒有阻塞)的開銷,在?Intel?Core?i7?860?上測得。
2.1Monitor.Enter?與?Monitor.Exit
C#?的lock語句是一個語法糖,它其實就是使用了try?/?finally來調用Monitor.Enter與Monitor.Exit方法。下面是在之前示例中的Go方法內部所發生的事情(簡化的版本):
Monitor.Enter?(_locker);try{ ??if?(_val2?!=?0)?Console.WriteLine?(_val1?/?_val2); ??_val2?=?0;}finally?{?Monitor.Exit?(_locker);?}
如果在同一個對象上沒有先調用Monitor.Enter就調用Monitor.Exit會拋出一個異常。
lockTaken?重載
剛剛所描述的就是?C#?1.0、2.0?和?3.0?的編譯器翻譯lock語句產生的代碼。
然而它有一個潛在的缺陷。考慮這樣的情況:在Monitor.Enter的實現內部或者在Monitor.Enter與try中間有異常被拋出(可能是因為在線程上調用了Abort,或者有OutOfMemoryException異常被拋出),這時不一定能夠獲得鎖。如果獲得了鎖,那么該鎖就不會被釋放,因為不可能執行到try?/?finally內,這會導致鎖泄漏。
為了避免這種危險,CLR?4.0?的設計者為Monitor.Enter添加了下面的重載:
public?static?void?Enter?(object?obj,?ref?bool?lockTaken);
當(且僅當)Enter方法拋出異常,鎖沒有能夠獲得時,lockTaken為false。
下邊是正確的使用方式(這就是?C#?4.0?對于lock語句的翻譯):
bool?lockTaken?=?false;try{ ??Monitor.Enter?(_locker,?ref?lockTaken); ??//?你的代碼...}finally?{?if?(lockTaken)?Monitor.Exit?(_locker);?}
TryEnter
Monitor還提供了一個TryEnter方法,允許以毫秒或是TimeSpan方式指定超時時間。如果獲得了鎖,該方法會返回true,而如果由于超時沒有獲得鎖,則會返回false。TryEnter也可以以無參數的形式進行調用,這是對鎖進行“測試”,如果不能立即獲得鎖就會立即返回false。
類似于Enter方法,該方法在?CLR?4.0?中也被重載來接受lockTaken參數。
2.2選擇同步對象
對所有參與同步的線程可見的任何對象都可以被當作同步對象使用,但有一個硬性規定:同步對象必須為引用類型。同步對象一般是私有的(因為這有助于封裝鎖邏輯),并且一般是一個實例或靜態字段。同步對象也可以就是其要保護的對象,如下面例子中的_list字段:
class?ThreadSafe{ ??List?<string>?_list?=?new?List?<string>(); ??void?Test() ??{ ????lock?(_list) ????{ ??????_list.Add?("Item?1"); ??????//?...
一個只被用來加鎖的字段(例如前面例子中的_locker)可以精確控制鎖的作用域與粒度。對象自己(this),甚至是其類型都可以被當作同步對象來使用:
lock?(this)?{?...?}//?或者:lock?(typeof?(Widget))?{?...?}????//?保護對靜態資源的訪問
這種方式的缺點在于并沒有對鎖邏輯進行封裝,從而很難避免死鎖與過多的阻塞。同時類型上的鎖也可能會跨越應用程序域(application?domain)邊界(在同一進程內)。
你也可以在被?lambda?表達式或匿名方法所捕獲的局部變量上加鎖。
鎖在任何情況下都不會限制對同步對象本身的訪問。換句話說,x.ToString()不會因為其它線程調用lock(x)而阻塞,兩個線程都要調用lock(x)才能使阻塞發生。
2.3何時加鎖
簡單的原則是,需要在訪問任意可寫的共享字段(any?writable?shared?field)時加鎖。即使是最簡單的操作,例如對一個字段的賦值操作,都必須考慮同步。在下面的類中,Increment與Assign方法都不是線程安全的:
class?ThreadUnsafe{ ??static?int?_x; ??static?void?Increment()?{?_x++;?} ??static?void?Assign()????{?_x?=?123;?}}
以下是線程安全的版本:
class?ThreadSafe{ ??static?readonly?object?_locker?=?new?object(); ??static?int?_x; ??static?void?Increment()?{?lock?(_locker)?_x++;?} ??static?void?Assign()????{?lock?(_locker)?_x?=?123;?}}
在非阻塞同步(nonblocking?synchronization)中,我們會解釋這種需求是如何產生的,以及在這些場景下內存屏障(memory?barrier,內存柵欄,內存柵障)和Interlocked類如何提供替代方法進行鎖定。
2.4鎖與原子性
如果一組變量總是在相同的鎖內進行讀寫,就可以稱為原子的(atomically)讀寫。假定字段x與y總是在對locker對象的lock內進行讀取與賦值:
lock?(locker)?{?if?(x?!=?0)?y?/=?x;?}
可以說x和y是被原子的訪問的,因為上面的代碼塊無法被其它的線程分割或搶占。如果被其它線程分割或搶占,x和y就可能被別的線程修改導致計算結果無效。而現在?x和y總是在相同的排它鎖中進行訪問,因此不會出現除數為零的錯誤。
在lock鎖內拋出異常將打破鎖的原子性,考慮如下代碼:
decimal?_savingsBalance,?_checkBalance;void?Transfer?(decimal?amount){ ??lock?(_locker) ??{ ????_savingsBalance?+=?amount; ????_checkBalance?-=?amount?+?GetBankFee(); ??}}
如果GetBankFee()方法內拋出異常,銀行可能就要損失錢財了。在這個例子中,我們可以通過更早的調用GetBankFee()來避免這個問題。對于更復雜情況,解決方案是在catch或finally中實現“回滾(rollback)”邏輯。
指令原子性是一個相似但不同的概念:?如果一條指令可以在?CPU?上不可分割地執行,那么它就是原子的。(見非阻塞同步)
2.5嵌套鎖
線程可以用嵌套(重入)的方式重對相同的對象進行加鎖:
lock?(locker) ??lock?(locker) ????lock?(locker) ????{ ???????//?...????}
或者:
Monitor.Enter?(locker);?
Monitor.Enter?(locker);?
Monitor.Enter?(locker);
Monitor.Exit?(locker);
Monitor.Exit?(locker);
Monitor.Exit?(locker);
在這樣的場景中,只有當最外層的lock語句退出或是執行了匹配數目的Monitor.Exit語句時,對象才會被解鎖。
嵌套鎖可以用于在鎖中調用另一個方法(也使用了同一對象來鎖定):
static?readonly?object?_locker?=?new?object();
static?void?Main(){ ??lock?(_locker) ??{ ?????AnotherMethod(); ?????//??這里依然擁有鎖,因為鎖是可重入的??}}static?void?AnotherMethod(){
lock?(_locker)?{?Console.WriteLine?("Another?method");?}}
線程只會在第一個(最外層)lock處阻塞。
2.6死鎖
當兩個線程等待的資源都被對方占用時,它們都無法執行,這就產生了死鎖。演示死鎖最簡單的方法就是使用兩個鎖:
object?locker1?=?new?object();object?locker2?=?new?object();new?Thread?(()?=>?{ ????????????????????lock?(locker1) ????????????????????{ ??????????????????????Thread.Sleep?(1000); ??????????????????????lock?(locker2);??????//?死鎖????????????????????} ??????????????????}).Start();lock?(locker2){ ??Thread.Sleep?(1000); ??lock?(locker1);??????????????????????????//?死鎖}
更復雜的死鎖鏈可能由三個或更多的線程創建。
在標準環境下,CLR?不會像SQL?Server一樣自動檢測和解決死鎖。除非你指定了鎖定的超時時間,否則死鎖會造成參與的線程無限阻塞。(在SQL?CLR?集成宿主環境中,死鎖能夠被自動檢測,并在其中一個線程上拋出可捕獲的異常。)
死鎖是多線程中最難解決的問題之一,尤其是在有很多關聯對象的時候。這個困難在根本上在于無法確定調用方(caller)已經擁有了哪些鎖。
你可能會鎖定類x中的私有字段a,而并不知道調用方(或者調用方的調用方)已經鎖住了類y中的字段b。同時,另一個線程正在執行順序相反的操作,這樣就創建了死鎖。諷刺的是,這個問題會由于(良好的)面向對象的設計模式而加劇,因為這類模式建立的調用鏈直到運行時才能確定。
流行的建議:“以一致的順序對對象加鎖以避免死鎖”,盡管它對于我們最初的例子有幫助,但是很難應用到剛才所描述的場景。更好的策略是:如果發現在鎖區域中的對其它類的方法調用最終會引用回當前對象,就應該小心,同時考慮是否真的需要對其它類的方法調用加鎖(往往是需要的,但是有時也會有其它選擇)。更多的依靠聲明方式(declarative)與數據并行(data?parallelism)、不可變類型(immutable?types)與非阻塞同步構造(?nonblocking?synchronization?constructs),可以減少對鎖的需要。
有另一種思路來幫助理解這個問題:當你在擁有鎖的情況下訪問其它類的代碼,對于鎖的封裝就存在潛在的泄露。這不是?CLR?或?.NET?Framework?的問題,而是因為鎖本身的局限性。鎖的問題在許多研究項目中被分析,包括軟件事務內存(Software?Transactional?Memory)。
另一個死鎖的場景是:如果已擁有一個鎖,在調用Dispatcher.Invoke(在?WPF?程序中)或是Control.Invoke(在?Windows?Forms?程序中)時,如果?UI?恰好要運行等待同一個鎖的另一個方法,就會在這里發生死鎖。這通常可以通過調用BeginInvoke而不是Invoke來簡單的修復。或者,可以在調用Invoke之前釋放鎖,但是如果是調用方獲得的鎖,那么這種方法可能并不會起作用。我們在富客戶端應用與線程親和中來解釋Invoke和BeginInvoke。
2.7性能
鎖是非常快的,在一個?2010?時代的計算機上,沒有競爭的情況下獲取并釋放鎖一般只需?20?納秒。如果存在競爭,產生的上下文切換會把開銷增加到微秒的級別,并且線程被重新調度前可能還會等待更久的時間。如果需要鎖定的時間很短,那么可以使用自旋鎖(SpinLock)來避免上下文切換的開銷。
如果獲取鎖后保持的時間太長而不釋放,就會降低并發度,同時也會加大死鎖的風險。
2.8互斥體(Mutex)
互斥體類似于?C#?的lock,不同在于它是可以跨越多個進程工作。換句話說,Mutex可以是機器范圍(computer-wide)的,也可以是程序范圍(application-wide)的。
沒有競爭的情況下,獲取并釋放Mutex需要幾微秒的時間,大約比lock慢?50?倍。
使用Mutex類時,可以調用WaitOne方法來加鎖,調用ReleaseMutex方法來解鎖。關閉或銷毀Mutex會自動釋放鎖。與lock語句一樣,Mutex只能被獲得該鎖的線程釋放。
跨進程Mutex的一種常見的應用就是確保只運行一個程序實例。下面演示了這是如何實現的:
class?OneAtATimePlease{ ??static?void?Main() ??{ ????//?命名的?Mutex?是機器范圍的,它的名稱需要是唯一的????//?比如使用公司名+程序名,或者也可以用?URL????using?(var?mutex?=?new?Mutex?(false,?"oreilly.com?OneAtATimeDemo")) ????{ ??????//?可能其它程序實例正在關閉,所以可以等待幾秒來讓其它實例完成關閉 ??????if?(!mutex.WaitOne?(TimeSpan.FromSeconds?(3),?false)) ??????{ ????????Console.WriteLine?("Another?app?instance?is?running.?Bye!"); ????????return; ??????} ??????RunProgram(); ????} ??} ??static?void?RunProgram() ??{ ????Console.WriteLine?("Running.?Press?Enter?to?exit"); ????Console.ReadLine(); ??}}
如果在終端服務(Terminal?Services)下運行,機器范圍的Mutex默認僅對于運行在相同終端服務器會話的應用程序可見。要使其對所有終端服務器會話可見,需要在其名字前加上Global\。
2.9信號量(Semaphore)
信號量類似于一個夜總會:它具有一定的容量,并且有保安把守。一旦滿員,就不允許其他人進入,這些人將在外面排隊。當有一個人離開時,排在最前頭的人便可以進入。這種構造最少需要兩個參數:夜總會中當前的空位數以及夜總會的總容量。
容量為?1?的信號量與Mutex和lock類似,所不同的是信號量沒有“所有者”,它是線程無關(thread-agnostic)的。任何線程都可以在調用Semaphore上的Release方法,而對于Mutex和lock,只有獲得鎖的線程才可以釋放。
SemaphoreSlim是?Framework?4.0?加入的輕量級的信號量,功能與Semaphore相似,不同之處是它對于并行編程的低延遲需求做了優化。在傳統的多線程方式中也有用,因為它支持在等待時指定取消標記?(cancellation?token)。但它不能跨進程使用。
在Semaphore上調用WaitOne或Release會產生大概?1?微秒的開銷,而SemaphoreSlim產生的開銷約是其四分之一。
信號量在有限并發的需求中有用,它可以阻止過多的線程同時執行特定的代碼段。在下面的例子中,五個線程嘗試進入一個只允許三個線程進入的夜總會:
class?TheClub{ ??static?SemaphoreSlim?_sem?=?new?SemaphoreSlim?(3);????//?容量為?3 ??static?void?Main() ??{ ????for?(int?i?=?1;?i?<=?5;?i++)?new?Thread?(Enter).Start?(i); ??} ??static?void?Enter?(object?id) ??{ ?????_sem.Wait();//?同時只能有??
Thread.Sleep?(1000?*?(int)?id);???????????????//?3個線程??
_sem.Release(); ??}}
如果Sleep語句被替換為密集的磁盤?I/O?操作,由于Semaphore限制了過多的并發硬盤活動,就可能改善整體性能。
類似于Mutex,命名的Semaphore也可以跨進程使用。
3線程安全
說一個程序或方法是線程安全(?thread-safe)的,是指它在任意的多線程場景中都不存在不確定性。線程安全主要是通過鎖以及減少線程交互來實現。
一般的類型很少有完全線程安全的,原因如下:
- 完全線程安全的開發負擔很重,特別是如果一個類型有很多字段的情況(在任意多線程并發的情況下每個字段都有交互的潛在可能)。
- 線程安全可能會損失性能(某種程度上,無論類型是否實際被用于多線程都會增加損耗)。
- 線程安全的類型并不能確保使用該類型的程序也是線程安全的,為了實現程序線程安全所涉及的工作經常會使得類型線程安全成為多余。
因此線程安全通常只會在需要時再實現,只為了處理特定的多線程場景。
然而,有些方法可以用來“作弊”?,使龐大和復雜的類在多線程環境中安全運行。一種方法是犧牲粒度,將大段代碼甚至是訪問的整個對象封裝在一個排它鎖內,從而保證在高層上能進行順序訪問。事實上,如果我們希望在多線程環境中使用線程不安全的第三方代碼(或大多數?Framework?的類型)時,這種策略是十分有用的。它僅僅是簡單的使用了相同的排它鎖,來保護對非線程安全對象上所有屬性、方法和字段的訪問。這種解決方案適用于對象的方法都能夠快速執行的場景(否則會導致大量的阻塞)。
除基本類型外,很少有?.NET?Framework?的類型能在比并發讀取更高的需求下保證其實例成員是線程安全的。實現線程安全的責任就落在了開發人員身上,一般就是使用排它鎖。(命名空間System.Collections.Concurrent中的類型是個例外,它們是線程安全的數據結構。)
另一種“作弊”的方法是通過最小化共享數據來減少線程交互。這是一種優秀的方法,隱式的用于“?無狀態(stateless)”的中間層程序和網頁服務器中。由于多個客戶端請求可以同時到達,服務端方法就必須是線程安全的。無狀態設計(因可伸縮性(scalability)好而流行)在本質上限制了交互的可能性,因為類并不需要持久化請求之間的數據。線程交互僅限于靜態字段,比如在內存中緩存通用數據,或者提供認證和審計這樣的基礎服務時需要考慮。
實現線程安全的最后一種方式是使用自動鎖機制(automatic?locking?regime)。如果繼承?ContextBoundObject?類并使用?Synchronization?特性,.NET?Framework?就可以實現這種機制。當該對象上的方法或屬性被調用時,一個對象范圍(object-wide)的鎖就會自動作用于整個方法或屬性的調用。盡管這樣降低了實現線程安全的負擔,但是也有它的問題:它很可能造成死鎖、降低并發度并引起并非有意的重入。正是由于這些原因,手動加鎖通常是更好的選擇(直到有更好用的自動鎖機制出現)。
3.1線程安全與?.NET?Framework?類型
鎖可以用來將線程不安全的代碼轉換為線程安全的代碼。.NET?Framework?就是一個好例子:幾乎所有的非基本類型的實例成員都不是線程安全的(對于比只讀訪問更高的需求),然而如果對指定對象的所有訪問都通過鎖進行保護,它們就可以被用于多線程代碼中。例如,兩個線程同時向同一個List中添加對象,然后枚舉它:
class?ThreadSafe{ ??static?List?<string>?_list?=?new?List?<string>(); ??static?void?Main() ??{ ????new?Thread?(AddItem).Start(); ????new?Thread?(AddItem).Start(); ??} ??static?void?AddItem() ??{ ????lock?(_list)?_list.Add?("Item?"?+?_list.Count); ????string[]?items; ????lock?(_list)?items?=?_list.ToArray(); ????foreach?(string?s?in?items)?Console.WriteLine?(s); ??}}
在這個例子中,我們使用_list對象本身來加鎖。如果有兩個關聯的List,就需要選擇一個公共對象來加鎖(可以使用其中一個List對象,然而更好的方式是使用一個獨立的字段)。
枚舉?.NET?的集合也不是線程安全的,因為如果在枚舉的過程中集合被修改則會拋出異常。在這個例子中,我們并沒有將整個枚舉過程加鎖,而是首先將其中的對象復制到一個數組中。如果我們要進行的枚舉可能很耗時,那么可以通過上述方式避免過長時間鎖定。(另一種解決方案是使用讀寫鎖(reader?/?writer?lock))
對線程安全的對象加鎖
有時也需要對線程安全的對象加鎖,為了舉例說明,假設?Framework?的List類是線程安全的,我們要給它添加一個條目:
if?(!_list.Contains?(newItem))?_list.Add?(newItem);
無論List本身是否線程安全,上面的語句都不是線程安全的!為了防止if條件判斷執行后,在實際添加條目之前,被其它線程搶占修改了_list,整個if所包含的代碼都需要封裝在一個鎖中。并且在所有要修改_list的地方都要使用這個鎖。例如,下面的語句也需要封裝在相同的鎖中:
_list.Clear();
這也是為了確保了它不會在前面語句的執行過程中搶先執行。換句話說,我們不得不像對于非線程安全的集合一樣鎖定線程安全的集合(這使得對于List類是線程安全的假設變得多余)。
在高并發的環境下,對集合的訪問加鎖可能會產生大量阻塞,為此?Framework?4.0?提供了線程安全的隊列、棧和字典。
靜態成員
將對對象的訪問封裝在一個自定義鎖中的方式,只有當所有參與并發的線程都知道并使用這個鎖時才能起作用。然而如果需要加鎖的邏輯有更大范圍那就不是這么簡單了。最糟糕的情況就是public類型中的靜態成員。比如,我們假設DateTime結構體上的靜態屬性DateTime.Now不是線程安全的,即兩個并發線程調用會導致錯誤的輸出或是異常。使用外部加鎖進行修正的唯一方法就是在調用DateTime.Now之前對類型本身加鎖:lock(typeof(DateTime))。這僅適用于所有的程序員都接受這樣做(這不太可能)。此外,對類型加鎖也有其自身的問題。
因此,DateTime結構體的靜態成員都經過細致的處理,來保證它是線程安全的。這在?.NET?Framework?中是一個通用模式:靜態成員是線程安全的,而實例成員則不是。編寫類型讓別人使用時,遵守這種模式就不會令別人感到困惑和遇到難以解決的線程安全問題。換句話說,保證靜態成員的線程安全,就不會妨礙你的類型的使用者實現線程安全。
靜態方法的線程安全是必須由明確的編碼實現的,不是說把方法寫成靜態的就能自動實現線程安全!
只讀線程安全
使類型對于并發只讀訪問是線程安全的會很有益,這意味著使用者可以避免使用排它鎖。許多?.NET?Framework?類型都遵循這一原則:例如集合對于并發讀是線程安全的。
自己遵循這一愿則也很簡單:如果我們希望一個類型對于并發只讀訪問是線程安全的,那么不要在使用者期望是只讀的方法內修改字段(也不要加鎖后修改)。例如,在集合的ToArray()方法的實現中,也許會從壓緊(compacting)集合的內部結構開始。然而,這會導致使用者認為是只讀的操作并非線程安全。
只讀線程安全也是枚舉器與可枚舉類型分離的原因之一:兩個線程可以在一個集合上同時進行枚舉,因為它們會分別獲得單獨的枚舉器。
如果缺乏文檔,在認為一個方法是只讀前一定要謹慎。一個很好的例子是Random類:當調用Random.Next()時,它會更新私有的種子(seed)值。因此,或者對Random類的使用加鎖,或者每個線程使用單獨的實例。
3.2應用服務器中的線程安全
應用服務器需要使用多線程來處理多個客戶端的同時請求。WCF、ASP.NET?以及?Web?Services?應用都是隱式多線程的。使用?TCP?或?HTTP?之類網絡通道的遠程(Remoting)服務應用程序也是如此。這意味著服務端編程必須考慮線程安全,考慮在處理客戶端請求的線程間是否存在交互的可能。幸運的是,這種交互的可能性不大,一般服務端類要不然是無狀態的(無字段),要不然就有為每個客戶端或每個請求創建單獨對象實例的激活模型。交互通常僅在靜態字段上出現,有時是用于在內存中緩存數據庫數據來提高性能。
例如,有一個查詢數據庫的RetrieveUser方法:
//?User?是一個自定義類型,包含用戶數據的字段internal?User?RetrieveUser?(int?id)?{?...?}
如果對這個方法的調用很頻繁,可以通過在一個靜態Dictionary中緩存查詢結果來提高性能。下邊是一個考慮了線程安全的方案:
static?class?UserCache{ ??static?Dictionary?<int,?User>?_users?=?new?Dictionary?<int,?User>(); ??internal?static?User?GetUser?(int?id) ??{ ????User?u?=?null; ????lock?(_users) ??????if?(_users.TryGetValue?(id,?out?u)) ????????return?u; ????u?=?RetrieveUser?(id);???//?從數據庫獲取數據????lock?(_users)?_users?[id]?=?u; ????return?u; ??}}
至少必須要在讀取和更新字典時加鎖來保證線程安全。在這個例子中,在加鎖的便捷和性能之間進行了平衡。我們的設計略有一些效率問題:如果兩個線程同時使用未緩存過數據的id調用這個方法,RetrieveUser就可能被調用兩次,并且其中一次對字典的更新是不必要的。對整個方法加鎖可以避免這一問題,但會導致更糟的效率:整個緩存在調用RetrieveUser的期間都會被加鎖,在這段時間內,其它需要這樣獲取用戶信息的線程都會被阻塞。
3.3富客戶端應用與線程親和
(譯者注:這里的?thread?affinity?譯為線程親和,是指?UI?控件與線程的一種“綁定”關系,而不是通常理解中的線程與?CPU?核心的綁定關系。)
WPF?與?Windows?Forms?庫都遵循基于線程親和的模型。盡管它們有各自的實現,但是原理非常相似。
富客戶端的構成主要基于DependencyObject(WPF?中)或是Control(Windows?Forms?中)。這些對象具有線程親和性(thread?affinity),意思是只有創建它們的線程才能訪問其成員。違反這一原則會引起不可預料的行為,或是拋出異常。
這樣的好處是訪問?UI?對象時并不需要加鎖。而壞處是,如果希望調用在另一線程?Y?上創建的對象?X?的成員,就必須將請求封送(marshal)到線程?Y?。通過下列方法顯式實現:
- WPF?中:在其Dispatcher對象上調用Invoke或BeginInvoke。
- Windows?Forms?中:調用Control對象上的Invoke或BeginInvoke。
Invoke和BeginInvoke都接受一個委托,代表我們希望在目標控件上運行的的方法。Invoke是同步工作的:調用方在封送的委托執行完成前會被阻塞;BeginInvoke是異步工作的:調用方立即返回,封送請求被加入隊列(使用與處理鍵盤、鼠標、定時器事件相同的消息隊列)。
假定窗體包含一個名為txtMessage的文本框,我們希望使用一個工作線程更新其內容,下面是?WPF?的示例:
public?partial?class?MyWindow?:?Window{ ??public?MyWindow() ??{ ????InitializeComponent(); ????new?Thread?(Work).Start(); ??} ??void?Work() ??{ ????Thread.Sleep?(5000);???????????//?模擬耗時任務????UpdateMessage?("The?answer"); ??} ??void?UpdateMessage?(string?message) ??{ ????Action?action?=?()?=>?txtMessage.Text?=?message; ????Dispatcher.Invoke?(action); ??}}
Windows?Forms?的代碼類似,所不同的是我們調用Form的Invoke方法:
void?UpdateMessage?(string?message){ ??Action?action?=?()?=>?txtMessage.Text?=?message; ??this.Invoke?(action);}
Framework?提供了兩個構造來簡化這個過程:
- BackgroundWorker
- 任務延續(Task?continuations)
工作線程?vs?UI?線程
我們可以認為富客戶端應用程序中有兩種不同的線程類別:UI?線程和工作線程。UI?線程創建(并“占有”)?UI?元素,工作線程則不會;工作線程通常執行長時間任務,例如獲取數據。
大多數的富客戶端應用只有一個?UI?線程(它也是應用程序的主線程),它再去根據需要創建工作線程,可以直接創建或者使用BackgroundWorker。這些工作線程可以將代碼封送回主?UI?線程來更新控件或報告工作進度。
那么,應用程序何時會需要多個?UI?線程呢?主要的應用場景是:如果應用具有多個頂級窗口,每個頂級窗口都是被稱為單文檔界面(Single?Document?Interface,SDI)的程序,例如?Microsoft?Word。每個?SDI?窗口通常會在任務欄上顯示為獨立的應用程序,并且與其它的?SDI?窗口在功能上基本隔離。通過為每一個這樣的窗口設置獨立的?UI?線程,可以使應用程序有更好的響應。
3.4不可變對象
不可變對象的狀態不能被修改,無論通過外部還是內部。不可變對象的字段通常被聲明為只讀的,并且在構造過程中就會被初始化好。
不變性(immutability?)是函數式編程的一個標志:不是修改對象,而是使用不同的屬性創建一個新的對象。LINQ?就遵循這種模式。不變性在多線程中也很有價值,它可以通過消除(或是最小化)寫入的可能來避免共享可寫狀態的問題。
使用不可變對象的一個模式是:封裝一組相關的字段來最小化鎖定的時長。下面的例子中,假設有兩個字段:
int?_percentComplete;string?_statusMessage;
我們希望對其進行原子的讀?/?寫操作。除了加鎖之外,也可以定義如下的不可變類:
class?ProgressStatus????//?代表某活動進度{ ??public?readonly?int?PercentComplete; ??public?readonly?string?StatusMessage; ??//?這個類可能還有其它很多字段... ??public?ProgressStatus?(int?percentComplete,?string?statusMessage) ??{ ????PercentComplete?=?percentComplete; ????StatusMessage?=?statusMessage; ??}}
然后,我們可以定義一個該類型的字段,以及一個用于加鎖的對象:
readonly?object?_statusLocker?=?new?object();ProgressStatus?_status;
現在,我們就可以讀?/?寫該類型的值,而僅需要為一次賦值加鎖:
var?status?=?new?ProgressStatus?(50,?"Working?on?it");//?想象一下我們曾經在這要處理多少個字段?... //?...lock?(_statusLocker)?_status?=?status;????//?非常短暫的鎖
要讀取該對象,首先獲取該對象的一個副本(在鎖內),然后就可以讀取其值而不需要繼續占有鎖:
ProgressStatus?statusCopy;lock?(_locker?ProgressStatus)?
statusCopy?=?_status;???//?也是一個短暫的鎖
int?pc?=?statusCopy.PercentComplete;
string?msg?=?statusCopy.StatusMessage;//?...
(譯者注:上面代碼有誤,lock中應該是_statusLocker。這里的statusCopy也不是真正的副本,而僅僅相當于_status的別名,這么做是為了通過刷新處理器緩存,獲取_status當前的一致狀態。)
技術上講,最后兩行代碼的線程安全,是因為之前的lock進行了隱式的內存屏障(memory?barrier)。
需要注意的是,這種無鎖(譯者注:這里不是說完全沒有用到鎖,而是指鎖非常短暫)的方法可以避免一組相關字段出現不一致的情況。但它無法阻止后續的操作修改數據,因此,通常需要使用鎖。在第?5?部分中,將會看到使用不變性來簡化多線程的更多示例,包括PLINQ。
可以根據先前的值安全的創建一個新的ProgressStatus對象(例如:在其上可以“增加”PercentComplete的值),而僅需鎖定一行代碼。實際上不使用鎖都可以,我們可以顯式使用內存屏障、Interlocked.CompareExchange還有自旋等待。這種高級技術將在稍后的并行編程中講解。
原文鏈接:?http://blog.gkarch.com/threading/part2.html
posted on 2018-08-11 13:46 micwin 閱讀(...) 評論(...) ?編輯 收藏