多線程內容大體分兩部分,其一是異步操做,可經過專用,線程池,Task,Parallel,PLINQ等,而這裏又涉及工做線程與IO線程;其二是線程同步問題,鄙人如今學習與探究的是線程同步問題。 數據庫
經過學習《CLR via C#》裏面的內容,對線程同步造成了脈絡較清晰的體系結構,在多線程中實現線程同步的是線程同步構造,這個構造分兩大類,一個是基元構造,一個是混合構造。所謂基元則是在代碼中使用最簡單的構造。基元構造又分紅兩類,一個是用戶模式,另外一個是內核模式。而混合構造則是在內部會使用基元構造的用戶模式和內核模式,使用它的模式會有必定的策略,由於用戶模式和內核模式各有利弊,混合構造則是爲了平衡二者的利與弊而設計出來。下面則列舉整個線程同步體系結構 數組
1.1 用戶模式 緩存
1.1.1 volatile 安全
1.1.2 Interlock 多線程
1.2 內核模式 異步
1.2.1 WaitHandle 性能
1.2.2 ManualResetEvent與AutoResetEvent 學習
1.2.3 Semaphore 測試
1.2.4 Mutex 優化
2.1 各類Slim
2.2 Monitor
2.3 MethodImplAttribute與SynchronizationAttribute
2.4 ReaderWriterLock
2.5 Barier(少用)
2.6 CoutdownEvent(少用)
先從線程同步問題的緣由提及,當內存中有一個整形的變量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 { volatile 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實現的線程同步按鄙人的直覺也是不推薦使用的,主要是性能方面的損耗,鎖的範圍也比較大。