多線程內容大致分兩部分,其一是異步操作,可通過專用,線程池,Task,Parallel,PLINQ等,而這裡又涉及工作線程與IO線程;其二是線程同步問題,鄙人現在學習與探究的是線程同步問題。
通過學習《CLR via C#》裡面的內容,對線程同步形成了脈絡較清晰的體系結構,在多線程中實現線程同步的是線程同步構造,這個構造分兩大類,一個是基元構造,一個是混合構造。所謂基元則是在代碼中使用最簡單的構造。基原構造又分成兩類,一個是用戶模式,另一個是內核模式。而混合構造則是在內部會使用基元構造的用戶模式和內核模式,使用它的模式會有一定的策略,因為用戶模式和內核模式各有利弊,混合構造則是為了平衡兩者的利與弊而設計出來。下面則列舉整個線程同步體系結構
先從線程同步問題的原因說起,當內存中有一個整形的變量A,裡面存放的值是2,當線程1執行的時候它會把A的值從內存中取出存放到CPU的寄存器中,並把A賦值為3,此時剛好線程1的時間片結束;接著CPU把時間片分給線程2,線程2同樣把A從內存中的值取出來放到內存中,但是由於線程1並沒有把變量A的新值3放回內存,故線程2讀到的仍然是舊的值(也就是髒數據)2,然後線程2要是需要對A值進行一些判斷之類的就會出現一些非預期的結果了。
而針對上面這種對資源的共享問題處理,往往會使用各種各樣辦法。下面則逐一介紹
先說說基元構造中的用戶模式,凡是用戶模式的優點是它的執行相對較快,因為它是通過一系列CPU指令來協調,它造成的阻塞只是極短時間的阻塞,對操作系統而言這個線程是一直在運行,從未被阻塞。缺點就是唯有系統內核才能停止這樣的一個線程運行。另一方面就是由於線程在自旋而非阻塞,那麼它還會占用這CPU的時間,造成對CPU時間的浪費。
首先是基元用戶模式構造中的volatile構造,這個構造網上很多說法是讓CPU對指定字段(Field,也就是變量)的讀都是從內存讀,每次寫都是往內存寫。然而它和編譯器的代碼優化有關系。先看看如下代碼
public class StrageClass { vo int mFlag = 0; int mValue = 0; public void Thread1() { mValue = 5; mFlag = 1; } public void Thread2() { if (mFlag == 1) Console.WriteLine(mValue); } }
在懂得多線程同步問題的同學們都會知道如果用兩個線程分別去執行上面兩個方法時,得出的結果有兩個:1.不輸出任何東西;2.輸出5。但是在CSC編譯器編譯成IL語言或JIT編譯成機器語言的過程中,會進行代碼優化,在方法Thread1中,編譯器會覺得給兩個字段賦值會沒什麼所謂,它只會站在單個線程執行的角度來看,完全不會顧及多線程的問題,因此它有可能會把兩行代碼的執行順序調亂,導致先給mFlag賦值為1,再給mValue賦值為5,這就導致了第三種結果,輸出0。可惜這種結果我一直無法測試出來。
解決這個現象的就是volatile構造,使用了這種構造的效果是,凡是對使用了此構造的字段進行讀操作時,該操作都保證在原有代碼順序下會在最先執行;或者是凡是對使用了此構造的字段進行寫操作時,該操作都保證在原有代碼順序下會在最後執行。
實現了volatile的構造現在來說有三個,其一是Thread的兩個靜態方法VolatileRead和VolatileWrite,在MSND上的解析如下
Thread.VolatileRead 讀取字段值。 無論處理器的數目或處理器緩存的狀態如何,該值都是由計算機的任何處理器寫入的最新值。
Thread.VolatileWrite 立即向字段寫入一個值,以使該值對計算機中的所有處理器都可見。
在多處理器系統上, VolatileRead 獲得由任何處理器寫入的內存位置的最新值。 這可能需要刷新處理器緩存;VolatileWrite 確保寫入內存位置的值立即可見的所有處理器。 這可能需要刷新處理器緩存。
即使在單處理器系統上, VolatileRead 和 VolatileWrite 確保值為讀取或寫入內存,並不緩存 (例如,在處理器寄存器中)。 因此,您可以使用它們可以由另一個線程,或通過硬件更新的字段對訪問進行同步。
從上面的文字看不出他和代碼優化有任何關聯,那接著往下看。
volatile關鍵字則是volatile構造的另外一種實現方式,它是VolatileRead和VolatileWrite的簡化版,使用 volatile 修飾符對字段可以保證對該字段的所有訪問都使用 VolatileRead 或 VolatileWrite。MSDN中對volatile關鍵字的說明是
volatile 關鍵字指示一個字段可以由多個同時執行的線程修改。 聲明為 volatile 的字段不受編譯器優化(假定由單個線程訪問)的限制。 這樣可以確保該字段在任何時間呈現的都是最新的值。
從這裡可以看出跟代碼優化有關系了。而縱觀上面的介紹得出兩個結論:
1.使用了volatile構造的字段讀寫都是直接對內存操作,不涉及CPU寄存器,使得所有線程對它的讀寫都是同步,不存在髒讀了。讀操作是原子的,寫操作也是原子的。
2.使用了volatile構造修飾(或訪問)字段,它會嚴格按照代碼編寫的順序執行,讀操作將會在最早執行,寫操作將會最遲執行。
最後一個volatile構造是在.NET Framework中新增的,裡面包含的方法都是Read和Write,它實際上就相當於Thread的VolatileRead 和VolatileWrite 。這需要拿源碼來說明了,隨便拿一個Volatile的Read方法來看
而再看看Thraed的VolatileRead方法
另一個用戶模式構造是Interlocked,這個構造是保證讀和寫都是在原子操作裡面,這是與上面volatile最大的區別,volatile只能確保單純的讀或者單純的寫。
為何Interlocked是這樣,看一下Interlocaked的方法就知道了
Add(ref int,int)// 調用ExternAdd 外部方法
CompareExchange(ref Int32,Int32,Int32)//1與3是否相等,相等則替換2,返回1的原始值
Decrement(ref Int32)//遞減並返回 調用add
Exchange(ref Int32,Int32)//將2設置到1並返回
Increment(ref Int32)//自增 調用add
就隨便拿其中一個方法Add(ref int,int)來說(Increment和Decrement這兩個方法實際上內部調用了Add方法),它會先讀到第一個參數的值,在與第二個參數求和後,把結果寫到給第一參數中。首先這整個過程是一個原子操作,在這個操作裡面既包含了讀,也包含了寫。至於如何保證這個操作的原子性,估計需要查看Rotor源碼才行。在代碼優化方面來說,它確保了所有寫操作都在Interlocked之前去執行,這保證了Interlocked裡面用到的值是最新的;而任何變量的讀取都在Interlocked之後讀取,這保證了後面用到的值都是最新更改過的。
CompareExchange方法相當重要,雖然Interlocked提供的方法甚少,但基於這個可以擴展出其他更多方法,下面就是個例子,求出兩個值的最大值,直接抄了Jeffrey的源碼
查看上面代碼,在進入循環之前先聲明每次循環開始時target的值,在求出最值之後,核對一下target的值是否有變化,如果有變化則需要再記錄新值,按照新值來再求一次最值,直到target不變為止,這就滿足了Interlocked中所說的,寫都在Interlocked之前發生,Interlocked往後就能讀到最新的值。
基元內核模式
內核模式則是靠操作系統的內核對象來處理線程的同步問題。先說其弊端,它的速度會相對慢。原因有兩個,其一由於它是由操作系統內核對象來實現的,需要操作系統內部去協調,另外一個原因是內核對象都是一些非托管對象,在了解了AppDomain之後就會知道,訪問的對象不在當前AppDomain中的要麼就進行按值封送,要麼就進行按引用封送。經過觀察這部分的非托管資源是按引用封送,這就會存在性能影響。綜合上面兩方面的兩點得出內核模式的弊端。但是他也是有利的方面:1.線程在等待資源的時候不會"自旋"而是阻塞,這個節省了CPU時間,並且這個阻塞可以設定一個超時值。2.可以實現Window線程和CLR線程的同步,也可同步不同進程中的線程(前者未體驗到,而對於後者則知道semaphores中有邊界值資源)。3.可應用安全性設置,為經授權賬戶禁止訪問(這個不知道是咋回事)。
內核模式的所有對象的基類是WaitHandle。內核模式的所有類層次如下
WaitHandle
EventWaitHandle
AutoResetEvent
ManualResetEvent
Semaphore
Mutex
WaitHandle繼承MarshalByRefObject,這個就是按引用封送了非托管對象。WaitHandle裡面主要是各種Wait方法,調用了Wait方法在沒有收到信號之前會被阻塞。WaitOne則是等待一個信號,WaitAny(WaitHandle[] waitHandles)則是收到任意一個waitHandles的信號,WaitAll(WaitHandle[] waitHandles)則是等待所有waitHandles的信號。這些方法都有一個版本允許設置一個超時時間。其他的內核模式構造都有類似的Wait方法。
EventWaitHandle的內部維護著一個布爾值,而Wait方法會在這個布爾值為false時線程就會被阻塞,直到該布爾值為true時線程才被釋放。操縱這個布爾值的方法有Set()和Reset(),前者是把布爾值設成true;後者則設成false。這相當於一個開關,調用了Reset之後線程執行到Wait就暫停了,直到Set才恢復。它有兩個子類,使用的方式類似,區別在於AutoResetEvent調用Set之後自動調用Reset,使得開關馬上恢復關閉狀態;而ManualResetEvent就需要手動調用Set讓開關關閉。這樣就達到一個效果一般情況下AutoResetEvent每次釋放的時候能讓一條線程通過;而ManualResetEvent在手動調用Reset之前有可能會讓多條線程通過。
Semaphore的內部是維護著一個整形,當構造一個Semaphore對象時會指定最大的信號量與初始信號量值,每當調用一次WaitOne,信號量就會加1,當加到最大值時,線程就會被阻塞,當調用Release的時候就會釋放一個或多個信號量,此時被阻塞掉的一個或多個線程就會被釋放。這個就符合生產者與消費者問題了,當生產者不斷往產品隊列中加入產品時,他就會WaitOne,當隊列滿了,就相當於信號量滿了,生成者就會被阻塞,當消費者消費掉一個商品時,就會Release釋放掉產品隊列中的一個空間,此時因沒有空間存放產品的生產者又可以開始工作往產品隊列中存放產品了。
Mutex的內部與規則相對前面兩者稍微復雜一點,先說與前面相似的地方就是同樣都會通過WaitOne來阻塞當前線程,通過ReleastMutex來釋放對線程的阻塞。區別在於WaitOne的允許第一個調用的線程通過,其余後面的線程調用到WaitOne就會被阻塞,通過了WaitOne的線程可以重復調用WaitOne多次,但是必須調用同樣次數的ReleaseMutex來釋放,否則會因為次數不對等導致別的線程一直處於阻塞的狀態。相比起之前的幾個構造,這個構造會有線程所有權與遞歸這兩個概念,這個是單純靠前面的構造都無法實現的,額外封裝除外。
混合構造
上面的基元構造是用了最簡單的實現方式,用戶 模式有用戶模式的快,但是它會帶來CPU時間的浪費;內核模式解決了這個問題,但是會帶來性能上的損失,各有利弊,而混合構造則是集合了兩者的利,它會在內部通過一定策略適當的時機使用用戶模式,再另一種情況下又會使用內核模式。但是這些層層判斷帶來的是內存上的開銷。在多線程同步中沒有完美的構造,各個構造都有利弊,存在即有意義,結合具體的應用場景就會有最優的構造可供使用。只是在於我們能否按照具體的場景權衡利弊而已。
各種Slim後綴的類,在System.Threading命名空間中,可以看到若干個以Slim後綴結尾的類:ManualResetEventSlim,SemaphoreSlim,ReaderWriterLockSlim。除了最後一個,其余兩個都是在基元內核模式中有一樣的構造,但是這三個類都是原有構造的簡化版,尤其是前兩個,使用方式跟原有的一樣,但是盡量避免使用操作系統的內核對象,而達到了輕量級的效果。比如在SemaphoreSlim中使用了內核構造ManualResetEvent,但是這個構造是通過延時初始化,沒達到非不得已時都不使用。至於ReaderWriterLockSlim則在後面再介紹。
Monitor與lock,lock關鍵字可謂是最廣為人知的一種實現多線程同步的手段,那麼下面則又從一段代碼說起
這個方法相當簡單且無實際意義,它只是為了看編譯器把這段代碼編譯成什麼樣子,通過查看IL如下
留意到IL代碼中出現了try…finally語句塊、Monitor.Enter與Monotor.Exit方法。然後把代碼更改一下再編譯看看IL
IL代碼
代碼比較相似,但並非等價,實際上與lock語句塊等價的代碼如下
那麼既然lock本質上是調用了Monitor,那Monitor是如何通過對一個對象加鎖,然後實現線程同步。原來每個在托管堆裡面的對象都有兩個固定的成員,一個指向該對象類型的指針,另一個是指向一個線程同步塊索引。這個索引指向一個同步塊數組的元素,Monitor對線程加鎖就是靠這個同步塊。按照Jeffrey(CLR via C#的作者)的說法同步塊中有三個字段,所有權的線程Id,等待線程的數量,遞歸的次數。然而我通過另一批文章了解到線程同步塊的成員並非單純這幾個,有興趣的同學可以去閱讀《揭示同步塊索引》的文章,有兩篇。 當Monitor需要為某個對象obj加鎖時,它會檢查obj的同步塊索引有否為數組的某個索引,如果是-1的,則從數組中找出一個空閒的同步塊與之關聯,同時同步塊的所有權線程Id就記錄下當前線程的Id;當再次有線程調用Monitor的時候就會檢查同步塊的所有權Id和當前線程Id是否對應上,能對應上的就讓其通過,在遞歸次數上加1,如果對應不上的就把該線程扔到一個就緒隊列(這個隊列實際上也是存在同步塊裡面)中,並將其阻塞;這個同步塊會在調用Exit的時候檢查遞歸次數確保遞歸完了就清除所有權線程Id。通過等待線程數量得知是否有線程在等待,如果有則從等待隊列中取出線程並釋放,否則就解除與同步塊的關聯,讓同步塊等待被下個被加鎖的對象使用。
Monitor中還有一對方法Wait與Pulse。前者可以使得獲得到鎖的線程短暫地將鎖釋放,而當前線程就會被阻塞而放入等待隊列中。直到其他線程調用了Pulse方法,才會從等待隊列中把線程放到就緒隊列中,等待下次鎖被釋放時,才有機會被再次獲取鎖,具體能否獲取就要看等待隊列中的情況了。
ReaderWriterLock讀寫鎖,傳統的lock關鍵字(即等價於Monitor的Enter和Exit),他對共享資源的鎖是全互斥鎖,一經加鎖的資源其他資源完全不能訪問。
而ReaderWriterLock對互斥資源的加的鎖分讀鎖與寫鎖,類似於數據庫中提到的共享鎖和排他鎖。大致情況是加了讀鎖的資源允許多個線程對其訪問,而加了寫鎖的資源只有一個線程可以對其訪問。兩種加了不同縮的線程都不能同時訪問資源,而嚴格來說,加了讀鎖的線程只要在同一個隊列中的都能訪問資源,而不同隊列的則不能訪問;加了寫鎖的資源只能在一個隊列中,而寫鎖隊列中只有一個線程能訪問資源。區分讀鎖的線程是否在於統一個隊列中的判斷標准是,本次加讀鎖的線程與上次加讀鎖的線程這個時間段中,有否別的線程加了寫鎖,沒沒別的線程加寫鎖,則這兩個線程都在同一個讀鎖隊列中。
ReaderWriterLockSlim和ReaderWriterLock類似,是後者的升級版,出現在.NET Framework3.5,據說是優化了遞歸和簡化了操作。在此遞歸策略我尚未深究過。目前大概列舉一下它們通常用的方法
ReaderWriterLock常用的方法
Acqurie或Release ReaderLock或WriteLock 的排列組合
UpGradeToWriteLock/DownGradeFromWriteLock 用於在讀鎖中升級到寫鎖。當然在這個升級的過程中也涉及到線程從讀鎖隊列切換到寫鎖隊列中,因此需要等待。
ReleaseLock/RestoreLock 釋放所有鎖和恢復鎖狀態
ReaderWriterLock實現IDispose接口,其方法則是以下模式
TryEnter/Enter/Exit ReadLock/WriteLock/UpGradeableReadLock
(以上內容引用自另一篇筆記《ReaderWriterLock》)
CoutdownEvent比較少用的混合構造,這個跟Semaphore相反,體現在Semaphore是在內部計數(也就是信號量)達到最大值的時候讓線程阻塞,而CountdownEvent是在內部計數達到0的時候才讓線程阻塞。其方法有
AddCount //計數遞增;
Signal //計數遞減;
Reset //計數重設為指定或初始;
Wait //當且僅當計數為0才不阻塞,否則就阻塞。
Barrier也是一個比較少用的混合構造,用於處理多線程在分步驟的操作中協作問題。它內部維護著一個計數,該計數代表這次協作的參與者數量,當不同的線程調用SignalAndWait的時候會給這個計數加1並且把調用的線程阻塞,直到計數達到最大值的時候,才會釋放所有被阻塞的線程。假設還是不明白的話就看一下MSND上面的示例代碼
這裡給Barrier初始化的參與者數量是3,同時每完成一個步驟的時候會調用委托,該方法是輸出count的值步驟索引。參與者數量後來增加了兩個又減少了一個。每個參與者的操作都是相同,給count進行原子自增,自增完則調用SgnalAndWait告知Barrier當前步驟已完成並等待下一個步驟的開始。但是第三次由於回調方法裡拋出了一個異常,每個參與者在調用SignalAndWait的時候都會拋出一個異常。通過Parallel開始了一個並行操作。假設並行開的作業數跟Barrier參與者數量不一樣就會導致在SignalAndWait會有非預期的情況出現。
接下來說兩個Attribute,這個估計不算是同步構造,但是也能在線程同步中發揮作用
MethodImplAttribute這個Attribute適用於方法的,當給定的參數是MethodImplOptions.Synchronized,它會對整個方法的方法體進行加鎖,凡是調用這個方法的線程在沒有獲得鎖的時候就會被阻塞,直到擁有鎖的線程釋放了才將其喚醒。對靜態方法而言它就相當於把該類的類型對象給鎖了,即lock(typeof(ClassType));對於實例方法他就相當於把該對象的實例給鎖了,即lock(this)。最開始對它內部調用了lock這個結論存在猜疑,於是用IL編譯了一下,發現方法體的代碼沒啥異樣,查看了一些源碼也好無頭緒,後來發現它的IL方法頭跟普通的方法有區別,多了一個synchronized
於是網上找各種資料,最後發現"junchu25"的博客[1][2]裡提到用WinDbg來查看JIT生成的代碼。
調用Attribute的
調用lock的
對於用這個Attribute實現的線程同步連Jeffrey都不推薦使用。
System.Runtime.Remoting.Contexts.SynchronizationAttribute這個Attribute適用於類,在類的定義中加了這個Attribute並繼承與ContextBoundOject的類,它會對類中的所有方法都加上同一個鎖,對比MethodImplAttribute它的范圍更廣,當一個線程調用此類的任何方法時,如果沒有獲得鎖,那麼該線程就會被阻塞。有個說法是它本質上調用了lock,對於這個說法的求證就更不容易,國內的資源少之又少,裡面又涉及到AppDomain,線程上下文,最後核心的就是由SynchronizedServerContextSink這個類去實現的。AppDomain應該要另立篇進行介紹。但是在這裡也要稍微說一下,以前以為內存中就是有線程棧與堆內存,而這只是很基本的劃分,堆內存還會劃分成若干個AppDomain,在每個AppDomain中也至少有一個上下文,每個對象都會從屬與一個AppDomain裡面的一個上下文中。跨AppDomain的對象是不能直接訪問的,要麼進行按值封送(相當於深復制一個對象到調用的AppDomain),要麼就按引用封送。對於按引用封送則需要該類繼承MarshalByRefObject。對繼承了這個類的對象進行調用時都不是調用類的本身,而是通過代理的形式進行調用。那麼跨上下文的也需要進行按值封送操作。平常構造的一個對象都是在進程默認AppDomain下的默認上下文中,而使用了SynchronizationAttribute特性的類它的實例是屬於另外的一個上下文中,繼承了ContextBoundObject基類的類進行跨上下文訪問對象時也是通過按引用封送的方式用代理訪問對象,並非訪問到對象本身。至於是否跨上下文訪問對象可以通過的RemotingServices.IsObjectOutOfContext(obj)方法進行判斷。SynchronizedServerContextSink是mscorlib的一個內部類。當線程調用跨上下文的對象時,這個調用會被SynchronizedServerContextSink封裝成WorkItem的對象,該對象也mscorlib的中的一個內部類,SynchronizedServerContextSink就請求SynchronizationAttribute,Attribute根據現在是否有多個WorkItem的執行請求來決定當前處理的這個WorkItem會馬上執行還是放到一個先進先出的WorkItem隊列中按順序執行,這個隊列是SynchronizationAttribute的一個成員,隊列成員入隊出隊時或者Attribute判斷是否馬上執行WorkItem時都需要獲取一個lock的鎖,被鎖的對象也正是這個WorkItem的隊列。這裡面涉及到幾個類的交互,鄙人現在還沒完全看清,以上這個處理過程可能有錯,待分析清楚再進行補充。不過通過這個Attribute實現的線程同步按逼人的直覺也是不推薦使用的,主要是性能方面的損耗,鎖的范圍也比較大。