從本篇文章開始,我將陸續介紹多執行緒中會遇到的三種情況。
情景一:此茅坑有主了
大錘:“我擦,居然一個茅坑有兩個人在用。”
大錘:“啊,忍不住了,一起擠擠吧~~~”
叫獸:“舒坦了,先走了。”
叫獸按下了沖水開關.... "嘩啦啦....."
大錘:“你妹啊,衝什麼水啊,衝得我一身 shit ”
解決方案:為了解決這種混亂的情況,管理員給茅坑加了道門,一次只允許一個人使用,其他人只能在外面等待。而且只要有人佔著,就算不拉屎,其他人也只能乖乖排隊。
問題抽象:當某一資源可能同時被多個執行緒讀取和修改時,資源的狀態將變得難以預料。
執行緒同步方案:Interlocked、lock、Moniter、SpinLock、ReadWriteLockSlim、Mutex
方案特性:除所有者外,其他人無條件等待;先到先得(誰先進茅坑,誰先用,沒有先後順序)
各方案間的區別(關於如何使用每種方案,很多文章和書籍都有介紹,就不再一一贅述了。)
這些方案從它們各自的實現方式可分為三種:使用者模式構造、核心模式構造 和 混合模式構造。
應該儘量使用使用者模式構造,它們的速度要顯著快於核心模式的構造。這是因為它們使用了特殊 CPU 指令來協調執行緒。這意味著協調是在硬體中發生的(所以才這麼快)。它們有一個缺點:只有 Windows 作業系統核心才能停止一個執行緒的執行(以避免浪費 CPU 時間)。所以,一個執行緒想要取得一個資源但又暫時取不到,它會一直在使用者模式中執行。這可能浪費大量 CPU 時間。
核心模式的構造是由 Windows 作業系統自身提供的。所以,它們要求你在應用程式的執行緒中呼叫在作業系統核心中實現的函式。將執行緒從使用者模式切換為核心模式(或相反)會招致巨大的效能損失,這正是為什麼應該避免使用核心模式構造的原因。然後,它們有一個重要的優點:一個執行緒使用一個核心模式的構造獲取一個由其它執行緒擁有的資源時,Windows會阻塞執行緒,使它不再浪費 CPU 時間。然後,當資源變得可用時,Windows 會恢復執行緒,允許它訪問資源。
---- 《CLR via C# (第 3 版)》 P706
上面這段話摘自《CLR via C#》,各別用詞稍微調整了下以便於理解。簡單來說,使用者模式會通過在 CPU 中不斷的執行某些指令來達到阻塞執行緒的效果(想像一下一直執行 while(true); 的樣子),而核心模式則是實實在在的把執行緒的執行給停止了,CPU 不會再去排程這個執行緒。混合模式,就不用說了,是兩者的結合。
那什麼時候該用什麼模式的構造呢?對於短時間的阻塞,選擇使用者模式;長時間的阻塞,選擇核心模式;阻塞時間不定的,選擇混合模式。
使用者模式(user-mode)
Interlocked 保證的是原子性,其原子操作包括 “遞增”、“遞減”、“相加”、“交換” 。之所以把它也歸入情景一,是因為它通過原子操作確保一個資源在 “讀取後,寫入前” 不會有其它執行緒中斷它的執行,從而保證了資源的獨佔使用。
優點:速度最快,且單次操作阻塞時間短。
缺點:可執行的操作有限。
SpinLock 自旋鎖,在 .Net 4.0 的時候引入。自旋的意思就是自個兒在原地旋轉,以此來佔用 CPU 時間。說白了就是類似 “while(狀態是否可用); ”,如果狀態不可用,則一直迴圈,直到狀態可用為止。可以用 Interlocked 來實現 SpinLock 的效果:
//參考 Clr via C# struct MySpinLock { int _lock; public void Enter() { //第一個執行緒進來的時候,Exchange 返回0,while 退出。其它執行緒進來,都返回1 while (Interlocked.Exchange(ref _lock, 1) == 1) ; } public void Exit() { Interlocked.Exchange(ref _lock, 0); } }
優點:速度快,可以用於各種操作。
缺點:如果操作需要很長時間,將會嚴重浪費 CPU 時間。在單核的處理器中使用該方式,可能造成死鎖。因為如果加鎖的執行緒優先順序低於阻塞的執行緒,那可能很長一段時間都無法被排程到CPU上,這樣就無法解鎖。
核心模式(kernal-mode)
Mutex 可以跨程式保證資源的獨佔使用,通過 WaitOne 來獲取鎖,ReleaseMutex 釋放鎖(使用哪個執行緒執行的 WaitOne,只能由該執行緒 ReleaseMutex)。它與後面要講到的 “Event” 都來自於同一個父類 WaitHandle。這是一個抽象類,包裝了 Windows 作業系統的核心物件控制程式碼。
主要用於:限制應用程式只能啟動一次。如 Sql Server、360安全衛士。
程式碼示例:
[STAThread] static void Main() { bool loaded = false; Mutex mutex = new Mutex(false, "SINGILE", out loaded); if (loaded) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new Form1()); } else { Application.Exit(); } }
優點:允許遞迴使用,可以跨程式使用
缺點:速度最慢(不僅是因為會在核心模式與使用者模式間進行切換,造成效能的損失;也因為相對於 Event,它提供了遞迴使用等高階的功能,這導致它比其它結構都要複雜)
混合模式(hybrid-mode)
Moniter 方式通過呼叫靜態方法 Enter、Exit 來實現對共享資源或程式碼段的獨佔使用,是 .Net 領域中問世最早的一種執行緒同步機制。我們都知道每個引用型別在堆中都會包含兩個特殊的欄位:同步塊索引 和 型別物件指標。而使用 Moniter.Enter 實際就會去操作同步塊索引,讓它指向堆中的同步塊陣列;Mointer.Exit 則會重新將同步塊索引置為 -1。
優點:速度還行,介於核心模式和使用者模式之間;支援遞迴使用。
缺點:會把所有操作(讀或寫)該資源的執行緒都阻塞,而當系統中讀執行緒的數量遠遠多於寫執行緒的時候,很有可能出現同一時刻只有多個讀執行緒,這個時候阻塞的行為就顯得多餘了。
Lock 是 C# 的語法糖,通過檢視 IL 程式碼可以知道,它最終將被解釋為 Moniter.Enter 和 Moniter.Exit。下面是 C# 4.0 程式碼的 IL。
通過上面的 IL,可以明確的看到 Moniter.Exit 被放置在 finally 塊中,這樣保證了鎖最終將被正確釋放(避免了可能發生的死鎖)。但有一點值得注意的是,如果程式碼塊中丟擲了異常,儘管可以保證鎖被釋放,但無法保證其中的共享資源仍舊是正確的。
優點:使用簡單;保證鎖肯定會被釋放;速度同 Moniter 。
缺點:同 Moniter。
ReadWriteLockSlim 與 Mointer 不同,它通過 EnterReadLock、EnterWriteLock、ExitReadLock、ExitWriteLock 來區別對待讀執行緒還是寫執行緒。所以對於讀執行緒加讀鎖,而寫執行緒加寫鎖,這樣噹噹前時刻不存在寫執行緒的時候,所有讀執行緒都可以併發的訪問資源。
優點:讀、寫鎖分離。當不存在寫執行緒的時候,速度要明顯快於 Mointer。而當有寫執行緒的時候,速度稍慢於 Mointer。
上面的方式各有優缺點,就算是經驗豐富的程式猿也不一定能保證執行緒一定是安全的。所以只要有可能還是建議大家儘量不使用、少使用共享資源,或者讓共享資源變成只讀。
總結
情景一中所說的所有方法都是圍繞一個目的 ------ “解決對共享資源的爭用問題”。當在實際開發過程中,如果碰到了共享資源(靜態變數、型別的成員變數、檔案等)或需要獨佔使用的程式碼段時,請考慮採用上述方式中的任何一種來保證執行緒安全。
本文來自《C# 基礎回顧: 執行緒同步的情景之一》