目錄
1.lock
2.Monitor
3.鎖的其它要注意的問題
3.1同步對象的選擇
3.2什么時候該上鎖
3.3鎖和原子性
3.4嵌套鎖
3.5 死鎖
3.6 性能
4.Mutex
5.Semaphore
1.lock
讓我們先看一段代碼:
class ThreadUnsafe
{static int _val1 = 1, _val2 = 1;static void Go(){if (_val2 != 0) Console.WriteLine (_val1 / _val2);_val2 = 0;}
}
? ? ? ? 這段代碼在單線程運行時是安全的,但是多線程運行就會出現問題,即有可能在做除法時,出現_val2=0 的情況,這是由于在執行打印時,另一個線程可能會去修改_val2的值。
我們可以通過加鎖來解決這個問題:
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;}}
}
關鍵字lock保證任何時候,只有一個線程可以訪問變量_val1 和_val2。
2.Monitor
? ? ? ? 關鍵字lock的機制是靠Monitor類實現的。lock可以認為是利用try-finally 結構對Monitor.Enter和Monitor.Exit 函數進行封裝。比如上面使用lock關鍵字的代碼用Monitor類實現如下:
Monitor.Enter (_locker);
try
{if (_val2 != 0) Console.WriteLine (_val1 / _val2);_val2
= 0;
}
finally { Monitor.Exit (_locker); }
(ps:在沒有調用Monitor.Enter函數時,調用Monitor.Exit 會拋出異常)
????????然而這段代碼存在一個微妙的漏洞。試想這樣一種(不太可能的)情況:當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);// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }
這樣一來,即使在Enter函數出現異常,也不會去執行Monitor.Exit函數了。
Monitor 類還提供了?TryEnter
?方法,允許指定超時時間(以毫秒或?TimeSpan
?形式)。如果成功獲取鎖,該方法返回?true
;如果因超時未能獲取鎖,則返回?false
。TryEnter
?也可以不帶參數調用,此時它會立即“測試”鎖的狀態,如果無法立即獲取鎖,則立刻超時返回。
與?Enter
?方法類似,在 CLR 4.0 中,TryEnter
?也提供了接受?lockTaken
?參數的重載版本。
這里就不過多介紹了,感興趣的可以去看官方鏈接
3.鎖的其它要注意的問題
3.1同步對象的選擇
????????任何對參與線程可見的對象都可以作為同步對象,但必須遵循一個硬性規則:同步對象必須是引用類型。同步對象通常聲明為 private(這有助于封裝鎖邏輯),并且通常是實例字段或靜態字段。同步對象可以同時作為被保護對象本身,如下例中的 _list 字段所示:
class ThreadSafe
{List
<string> _list = new List <string>();void Test(){lock (_list){_list
.Add ("Item 1");...
專門用于加鎖的字段(如前例中的?_locker
)能夠精確控制鎖的作用范圍和粒度。此外,包含對象本身(this
)或其類型(typeof(ClassName)
)也可作為同步對象使用:
lock (this) { ... }
lock (typeof (Widget)) { ... } // For protecting access to statics
雖然上面兩個鎖對象都是合理的,卻是不建議的:
使用this作為鎖對象會造成:
- 外部代碼可能鎖定你的對象實例,導致死鎖。
- 破壞了面向對象的封裝原則。
使用類類型作為鎖對象則更糟糕:typeof(ClassName) 返回的是類的 Type 對象,該對象在 AppDomain 范圍內是唯一的。所有線程中任何使用 lock(typeof(ClassName)) 的代碼都會競爭同一個鎖,導致: ? ?性能瓶頸:無關代碼因共享同一個鎖而阻塞。 ?? ?死鎖風險:第三方庫或框架若恰好也鎖定了該類型,可能引發不可預料的死鎖。
3.2什么時候該上鎖
? ? ? ? 首先,如果你確定你的程序是單線程的,那任何時候都不需要上鎖。否則,上鎖基本原則是:任何對可寫共享字段的訪問都需要加鎖。即使是最簡單的單字段賦值操作,也必須考慮同步問題。例如以下類中,無論是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; }
}
3.3鎖和原子性
????????如果一組變量總是在同一個鎖內進行讀寫,那么可以認為這些變量的讀寫操作是原子性的。假設字段 x 和 y 始終在對 locker 對象加鎖的情況下進行讀寫:
lock (locker) { if (x != 0) y /= x; }
那么我們可以說 x 和 y 的訪問是原子性的,因為這段代碼塊不會被其他線程的操作分割或搶占,從而避免 x 或 y 被意外修改而導致結果失效。只要 x 和 y 始終在同一個獨占鎖內訪問,就永遠不會發生除零錯誤。
3.4嵌套鎖
? ? ? ? 一個線程可以反復的對一個對象添加鎖:
lock (locker)lock (locker)lock (locker){// Do something...}
或者改用Monitor類:
Monitor.Enter (locker); Monitor.Enter (locker);? Monitor.Enter (locker);
// Do something...
Monitor
.Exit (locker); ?Monitor.Exit (locker);?? Monitor.Exit (locker);
在這種情況下,只有當最外層的 lock 語句執行完畢退出時 - 或者執行了對應數量的 Monitor.Exit 語句后 - 對象才會被解鎖。 嵌套鎖在方法內部調用另一個加鎖方法時特別有用:
static readonly object _locker = new object();static void Main()
{lock (_locker){AnotherMethod();// We still have the lock - because locks are reentrant.}
}static void AnotherMethod()
{lock (_locker) { Console.WriteLine ("Another method"); }
}
3.5 死鎖
? ? ? ? 死鎖在多線程編程是比較常見的。下面這個代碼就會觸發死鎖:
object locker1 = new object();
object locker2 = new object();new Thread (() => {lock (locker1){Thread.Sleep (1000);lock (locker2);????? // Deadlock}}).Start();
lock (locker2)
{Thread.Sleep (1000);lock (locker1);????????????????????????? // Deadlock
}
????????在多線程編程中,死鎖是最棘手的難題之一——尤其是當存在大量相互關聯的對象時。究其根本,難點在于你永遠無法確定調用方已經獲取了哪些鎖。 設想這樣一個場景:你可能在類X中無意識地鎖定了私有字段a,卻不知道調用方(或調用方的調用方)已經在類Y中鎖定了字段b。與此同時,另一個線程正以相反的順序執行鎖定——這就形成了死鎖。
????????頗具諷刺意味的是,這種問題反而會因(良好的)面向對象設計模式而加劇,因為這些模式創建的調用鏈直到運行時才能確定。 雖然"按固定順序鎖定對象以避免死鎖"的建議在我們最初的示例中很有幫助,但很難適用于上述場景。
????????更明智的策略是:當持有鎖的情況下調用可能反向引用自身對象的方法時要格外謹慎。同時,需要審慎評估是否真的有必要在調用其他類的方法時保持鎖定(雖然很多時候確實需要——我們稍后會討論——但有時存在其他選擇)。更多地依賴聲明式編程、數據并行、不可變類型以及非阻塞同步結構,可以減少對鎖定的依賴。?
????????這個問題還可以換個角度理解:當持有鎖時調用外部代碼,鎖的封裝性就會在無形中被破壞。這不是CLR或.NET框架的缺陷,而是鎖機制與生俱來的局限性。目前包括軟件事務內存(Software Transactional Memory)在內的多個研究項目正在嘗試解決鎖機制帶來的各種問題。
???????? 另一個典型的死鎖場景發生在WPF應用程序調用Dispatcher.Invoke或Windows Forms應用程序調用Control.Invoke時——如果此時恰好持有鎖,而UI線程正在執行另一個等待同一鎖的方法,就會立即引發死鎖。通常只需改用BeginInvoke而非Invoke即可解決。當然,也可以在調用Invoke前釋放鎖,不過如果鎖是由調用方獲取的,這個方法就不適用了。我們將在"富客戶端應用與線程關聯性"章節詳細解釋Invoke和BeginInvoke的機制。
3.6 性能
????????加鎖操作本身非常高效:在2010年代的計算機上,如果鎖未被爭用,獲取和釋放一個鎖最快僅需20納秒。但當鎖出現爭用時,隨之而來的上下文切換會使開銷激增至微秒級別——如果線程需要重新調度,等待時間可能更長。對于極短時間的鎖定,使用SpinLock類可以避免上下文切換的開銷。 需要注意的是,如果鎖持有時間過長,不僅會降低并發性能,還會顯著增加死鎖風險。鎖的爭用會引發線程阻塞,當多個線程相互等待對方釋放鎖時,系統吞吐量將急劇下降。因此,開發者需要在保證線程安全的前提下,盡量縮小臨界區范圍,并考慮使用讀寫鎖(ReaderWriterLockSlim)等更細粒度的同步機制來提升并發性。對于高并發場景,無鎖編程(lock-free programming)或不可變數據結構往往是更好的選擇。
4.Mutex
????????互斥鎖(Mutex)類似于 C# 的 lock 語句,但它的作用范圍可以跨越多個進程。也就是說,Mutex 既可以是應用程序級別的,也可以是計算機全局范圍的。( 獲取和釋放一個無競爭的 Mutex 需要幾微秒時間——這比 lock 語句慢了約 50 倍。)
????????使用 Mutex 類時,你需要調用 WaitOne 方法來加鎖,調用 ReleaseMutex 方法來解鎖。關閉或釋放 Mutex 會自動解除鎖定。與 lock 語句一樣,Mutex 只能由獲取它的同一個線程來釋放。 跨進程 Mutex 的一個常見用途是確保同一時間只能運行一個程序實例。具體實現如下:
class OneAtATimePlease
{static void Main(){// Naming a Mutex makes it available computer-wide. Use a name that's// unique to your company and application (e.g., include your URL).using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo")){// Wait a few seconds if contended, in case another instance// of the program is still in the process of shutting down.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();}
}
當然也可以這樣實現:
static void Main()
{using var mutex = new Mutex(true, "Global\\MyApp", out bool createdNew);if (!createdNew){Console.WriteLine("程序已在運行中!");return;}// 主程序邏輯Console.WriteLine("程序啟動...");Console.ReadLine();
}
一般而言,mutex在多線程編程中使用的不多,lock是更常見的選擇。但涉及到跨進程時,lock可能就無能為力了,這是可以考慮mutex.
5.Semaphore
????????信號量(Semaphore)就像一家夜總會:它有一定的容量限制,由門口的保安嚴格執行。一旦滿員,其他人就無法進入,只能在門外排隊等候。每當有一個人離開,隊首的一個人就能進入。它的構造函數至少需要兩個參數:當前夜總會內的空位數,以及夜總會的總容量。
容量為1的信號量與互斥鎖(Mutex)或lock類似,但關鍵區別在于信號量沒有"所有者"——它對線程是透明的。任何線程都可以調用信號量的Release方法,而Mutex和lock只能由獲取鎖的線程來釋放。
? ? ? ? (這個類有兩個功能相似的版本:Semaphore和SemaphoreSlim。后者是在.NET Framework 4.0中引入的,針對并行編程的低延遲需求進行了優化。它在傳統多線程編程中也很有用,因為它允許在等待時指定取消令牌。不過,它不能用于進程間通信。 Semaphore執行WaitOne或Release大約需要1微秒;而SemaphoreSlim只需要前者的四分之一時間。 )
信號量在限制并發度方面非常有用——可以防止過多線程同時執行某段代碼。在下面的例子中,五個線程試圖進入一家同時只允許三個線程進入的"夜總會":
????????
class TheClub // No door lists!
{static SemaphoreSlim _sem = new SemaphoreSlim (3); // Capacity of 3static void Main(){for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);}static void Enter (object id){Console.WriteLine (id + " wants to enter");_sem.Wait();Console.WriteLine (id + " is in!"); // Only three threadsThread.Sleep (1000 * (int) id); // can be here atConsole.WriteLine (id + " is leaving"); // a time._sem.Release();}
}
執行結果如下:
1 wants to enter 1 is in! 2 wants to enter 2 is in! 3 wants to enter 3 is in! 4 wants to enter 5 wants to enter 1 is leaving 4 is in! 2 is leaving 5 is in!
如果將 Sleep 語句替換為密集的磁盤 I/O 操作,信號量(Semaphore)通過限制過多的并發硬盤訪問,反而能夠提升整體性能。 如果給信號量命名,它就能像互斥鎖(Mutex)一樣實現跨進程同步。
本小節就介紹到這里,下面一節將介紹線程安全的一些實現準則。