C# 鎖機制全景與高效實踐:從 Monitor 到 .NET 9 全新 Lock
引言:線程安全與鎖的基本概念
線程安全
在多線程編程中,保障共享資源的安全訪問依賴于有效的線程同步機制。理解并處理好以下兩個核心概念至關重要:
- 線程安全:指某個類、方法或數據結構能夠在被多個線程同時訪問或修改時,依然保持內部狀態的一致性,并產生預期的結果。這通常意味著需要對共享狀態(如全局變量、靜態變量或對象實例字段)的并發訪問進行有效管控,防止數據損壞或不一致性。
- 競態條件 (Race Condition): 是一種典型的并發缺陷。當多個線程在缺乏適當同步機制的情況下,無序地、競爭性地訪問或修改共享資源時,程序執行結果變得依賴于無法預測的線程調度時序(即執行順序)。這種不確定性常常會導致數據錯誤、程序崩潰或行為異常。競態條件是線程安全缺失的直接體現。
鎖的基本概念
- 鎖的本質:鎖是一種同步工具,用于確保共享資源的互斥訪問(一次只有一個線程使用)。當一個線程獲得鎖并執行被保護的代碼段(臨界區)時,其他試圖獲取同一鎖的線程會被阻塞或等待,直到鎖被釋放。
- 鎖的目標:在保證正確性的前提下,最大化并發度和系統吞吐量,最小化延遲。
- 鎖的代價:
- 阻塞開銷:操作系統調度上下文切換的成本。
- 自旋開銷:忙等待消耗CPU周期。
- 死鎖風險:線程因相互等待對方釋放鎖而永久僵持。
- 優先級反轉:低優先級線程持有高優先級線程需要的鎖。
- 復雜性:使用不當可能導致程序難以理解和調試。
- 選擇鎖的依據:臨界區大小、等待時間長短、競爭激烈程度、讀/寫比例、進程邊界、公平性要求等。
1. Monitor
原理
Monitor類提供了一種互斥鎖機制,確保同一時間只有一個線程可以訪問臨界區。它是C#中lock語句的基礎,通過Monitor.Enter和Monitor.Exit實現鎖的獲取和釋放。
基于對象的內部 SyncBlock 索引關聯的一個系統鎖對象。每個.NET對象在堆上分配時,都有一個關聯的 Sync Block Index (SBI)。當首次對這個對象使用 lock 時,SBI 被分配并指向操作系統內核中的一個真正的鎖對象(比如 Windows 的 CRITICAL_SECTION)。
當鎖已被占用時,后續請求的線程會進入內核等待狀態,發生上下文切換。
Monitor.Wait(object obj), Monitor.Pulse(object obj), Monitor.PulseAll(object obj) 提供了在鎖內等待特定條件成立的能力(類似 ConditionVariable),可用于構建生產者-消費者模式等。
操作方式
lock語句是使用Monitor的簡便方式:
private readonly object _lock = new object();
lock (_lock)
{
// 臨界區代碼
}
等價于:
Monitor.Enter(_lock);
try
{
// 臨界區代碼
}
finally
{
Monitor.Exit(_lock);
}
應用場景
- 保護共享變量或非線程安全的集合
- 確保單一線程修改資源,如更新計數器或列表
- 需要簡單互斥的臨界區
- 臨界區執行時間相對較長(大于上下文切換開銷)
- 鎖競爭不是極端激烈
最佳實踐
- 使用私有對象(如
private readonly object _lock = new object();)進行鎖定,避免死鎖。 - 保持臨界區盡可能短,減少鎖競爭。
- 避免鎖定公共對象或類型(如
typeof(MyClass)),因為其他代碼可能也會鎖定它們。 - 不要在鎖內調用不可控的外部代碼,可能導致死鎖。
優點
- 使用簡單,
lock語句語法直觀。 - 對于短臨界區效率較高。
- Monitor 鎖是可重入(Reentrancy)的。同一個線程可以多次獲得同一個鎖對象上的鎖(進入嵌套的 lock 塊)。計數器會增加,只有等計數器歸零時鎖才會被釋放。
缺點
- 可能導致死鎖,如果鎖使用不當。Monitor.TryEnter(object obj, int timeoutMilliseconds) 允許設置等待超時,是避免死鎖的重要手段。
- 不支持多讀單寫場景。
- .NET 的 Monitor 鎖是非公平的(Windows CLR 實現)。當鎖釋放時,操作系統從等待隊列中選擇下一個喚醒的線程是不確定的,不一定是最早等待的那個(這有助于提高吞吐量,但可能導致某些線程“饑餓”)。
2. System.Threading.Lock
原理
System.Threading.Lock是.NET 9(C# 13)引入的新同步原語,旨在提供比Monitor更高效的互斥鎖機制。它通過EnterScope方法支持using語句,確保鎖自動釋放,降低死鎖風險。
操作方式
直接使用:
private readonly Lock _lock = new Lock();
using (_lock.EnterScope())
{
// 臨界區代碼
}
或在C# 13及以上版本中使用lock語句:
lock (_lock)
{
// 臨界區代碼
}
應用場景
- 與
Monitor類似,用于保護共享資源。 - 適用于需要高性能的場景,如高并發系統。
最佳實踐
- 使用私有
Lock實例。 - 利用
using語句確保鎖自動釋放。 - 避免將
Lock對象轉換為object或其他類型,以防止編譯器警告。
優點
- 性能比
Monitor高約25%。
| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | Alloc Ratio |
|------------------------- |----------:|---------:|---------:|------:|-------:|----------:|------------:|
| CountTo1000WithLock | 107.22 us | 1.561 us | 1.460 us | 1.00 | 0.1221 | 1.06 KB | 1.00 |
| CountTo1000WithLockClass | 75.73 us | 0.884 us | 0.827 us | 0.71 | 0.1221 | 1.05 KB | 0.99 |
- 使用
Dispose模式自動釋放鎖,降低死鎖風險。 - 與
lock語句無縫集成,語法簡潔。
缺點
- 需要.NET 9或更高版本。
- 開發者對其熟悉度較低。
3. Mutex
原理
Mutex(互斥鎖)是一種支持進程間同步的互斥鎖機制,確保只有一個線程或進程訪問共享資源。- 可以通過命名互斥鎖實現跨進程同步。
- 比 Monitor/lock 重得多(涉及系統調用)。
- 支持安全訪問系統資源(如文件、硬件設備句柄)。
操作方式
private static Mutex _mutex = new Mutex();
_mutex.WaitOne();
// 臨界區代碼
_mutex.ReleaseMutex();
應用場景
- 跨進程同步,如確保應用程序的單一實例運行。
- 保護共享資源,如文件或數據庫。
最佳實踐
- 使用命名互斥鎖(如
new Mutex(false, "MyAppMutex"))進行進程間同步。 - 盡快釋放互斥鎖,減少阻塞時間。
注意
- 重入性:命名 Mutex 默認是可重入的(同一個線程)。匿名(未命名)Mutex 在 .NET Framework 默認可重入,在 .NET Core+ 中默認為 .NoRecursion 行為。
- 自動釋放:如果持有 Mutex 的線程終止(例如崩潰),操作系統會自動釋放鎖(這可能導致程序邏輯錯誤),并且下一個等待的線程可能接收到 AbandonedMutexException。
優點
- 支持進程間同步。
- 提供可靠的互斥訪問。
缺點
- 由于涉及內核模式轉換,性能較低。
- 開銷較大,不適合高頻短臨界區。
4. SpinLock
原理
SpinLock是一種互斥鎖,線程在嘗試獲取鎖時會通過自旋(循環檢查)等待鎖可用,適用于極短的臨界區。
操作方式
private SpinLock _spinLock = new SpinLock();
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
// 臨界區代碼
}
finally
{
if (lockTaken)
{
_spinLock.Exit();
}
}
應用場景
- 極短的臨界區,鎖持有時間短于上下文切換成本。
- 高并發場景,鎖競爭頻繁但持續時間短。
最佳實踐
- 僅用于極短臨界區。
- 避免在低競爭或長臨界區場景中使用。
優點
- 對于短臨界區開銷低。
- 無上下文切換。
缺點
- 如果鎖持有時間長,會浪費CPU周期。
- 不適合長臨界區。
5. ReaderWriterLockSlim
原理
ReaderWriterLockSlim允許多個線程同時讀取資源,但寫操作互斥,且寫時不允許讀操作,適合讀多寫少的場景。
有幾種不同的鎖定模式:
- 讀取鎖 (Read Lock):共享模式,允許多個線程同時持有。
- 寫入鎖 (Write Lock):獨占模式,一旦持有,排斥所有讀取鎖和其他寫入鎖。
- 可升級讀取鎖 (Upgradeable Read Lock):一種特殊模式,允許一個讀取線程在持有讀鎖的同時,后續有需要時可以原子性地升級 (Upgrade)為寫入鎖(避免先釋放讀鎖再嘗試拿寫鎖過程中出現競態或死鎖)
操作方式
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
public string ReadData()
{
_rwLock.EnterReadLock(); // 獲取讀鎖
try
{
// 安全讀取共享數據
return _cachedData;
}
finally
{
_rwLock.ExitReadLock(); // 釋放讀鎖
}
}
public void UpdateData(string newData)
{
_rwLock.EnterWriteLock(); // 獲取寫鎖
try
{
// 安全更新共享數據
_cachedData = newData;
}
finally
{
_rwLock.ExitWriteLock(); // 釋放寫鎖
}
}
// 使用可升級鎖 (避免“寫者饑餓”風險):
public void UpdateIfCondition(string newData, Func<bool> condition)
{
_rwLock.EnterUpgradeableReadLock(); // 獲取可升級讀鎖
try
{
if (condition())
{
_rwLock.EnterWriteLock(); // 升級為寫鎖
try
{
// 安全更新共享數據
_cachedData = newData;
}
finally
{
_rwLock.ExitWriteLock(); // 降級回可升級讀鎖
}
}
}
finally
{
_rwLock.ExitUpgradeableReadLock(); // 釋放鎖
}
}
應用場景
- 讀操作頻繁、寫操作較少的場景,如緩存系統。
最佳實踐
- 確保寫操作快速,減少讀線程阻塞。
- 避免長時間持有寫鎖,防止寫者饑餓。
注意
- ReaderWriterLockSlim 性能更好,語義更清晰,設計更合理。強烈建議總是使用 ReaderWriterLockSlim 而不是 ReaderWriterLock。
- 性能特征:在純讀場景下并發度接近無鎖;寫操作開銷比普通互斥鎖略高(需要管理讀寫狀態轉換);升級操作開銷適中。
- 公平性與策略:提供了構造參數 LockRecursionPolicy.NoRecursion / .SupportsRecursion 和 ReaderWriterLockSlim(lockRecursionPolicy) 來控制遞歸行為。也涉及公平性問題(如讀者優先或寫者優先,ReaderWriterLockSlim 有機制防止寫者餓死)。
優點
- 允許多個線程同時讀取,提高性能。
- 適合讀多寫少場景。
缺點
- 使用復雜,需管理讀寫鎖狀態。不恰當地嵌套獲取不同類型的鎖(特別是嘗試升級鎖失敗時等待其他鎖)會導致死鎖。
- 可能導致寫者饑餓。
6. Semaphore 和 SemaphoreSlim
原理
Semaphore控制對資源池的并發訪問,限制同時訪問的線程數。Semaphore:內核模式,支持跨進程、命名。- SemaphoreSlim:輕量級用戶模式實現(必要時退化到內核),僅進程內有效,性能開銷遠小于 Semaphore。絕大多數進程內場景應優先使用 SemaphoreSlim。
- SemaphoreSlim 默認使用公平隊列(FIFO),有助于防止饑餓。Semaphore 的公平性由操作系統決定。
操作方式
private Semaphore _semaphore = new Semaphore(3, 3); // 初始和最大計數
//WaitOne/WaitAsync:嘗試獲取一個令牌(信號)。若無可用令牌則阻塞/異步等待
_semaphore.WaitOne();
// Release:釋放一個令牌
_semaphore.Release();
SemaphoreSlim使用方式類似。
應用場景
- 限制并發訪問特定資源的數量(API調用限流、連接池控制、異步任務并發度控制)。
最佳實踐
- 使用
Semaphore進行進程間同步,SemaphoreSlim用于進程內。 - 設置合理的初始和最大計數。
優點
- 靈活控制并發級別。
SemaphoreSlim性能較高。
缺點
- 使用較復雜。
- 可能導致死鎖。
7. EventWaitHandle、AutoResetEvent、ManualResetEvent、ManualResetEventSlim
原理
事件用于線程間信號傳遞。AutoResetEvent在信號一個等待線程后自動重置;ManualResetEvent保持信號狀態直到手動重置;ManualResetEventSlim是輕量級版本。
操作方式
AutoResetEvent示例:
private AutoResetEvent _event = new AutoResetEvent(false);
_event.WaitOne(); // 等待信號
// 執行操作
_event.Set(); // 發送信號
ManualResetEvent示例:
private ManualResetEvent _event = new ManualResetEvent(false);
_event.WaitOne(); // 等待信號
// 執行操作
_event.Set(); // 發送信號
_event.Reset(); // 重置事件
應用場景
- 生產者-消費者模式。
- 等待特定任務完成。
- 啟動/停止信號廣播、一次性初始化完成指示。
最佳實踐
- 使用
AutoResetEvent進行一對一信號傳遞。 - 使用
ManualResetEvent廣播信號給多個線程。
優點
- 提供簡單的信號傳遞機制。
缺點
- 狀態管理復雜,尤其是
ManualResetEvent。
8. CountdownEvent
原理
初始化一個計數(N)。線程調用 Signal() 來遞減計數。當計數達到0時,所有在該對象上 Wait() 的線程被釋放。適用于“N個任務完成后繼續”的場景。
操作方式
private CountdownEvent _countdown = new CountdownEvent(3);
_countdown.Wait(); // 等待計數歸零
// 執行操作
_countdown.Signal(); // 減少計數
應用場景
- 主線程等待一組分散操作的完成,模擬部分 Task.WaitAll 效果但有更多控制(可在操作執行過程中動態調整計數)。
最佳實踐
- 設置正確的初始計數。
- 確保所有信號都發送,避免死鎖。
優點
- 便于等待多個事件。
缺點
- 僅限于計數場景。
9. Barrier
原理
允許多個線程分階段執行任務,并確保所有參與線程在一個共同的屏障點(Phase)同步匯合(都到達后)才能繼續下一階段。
操作方式
private Barrier _barrier = new Barrier(3);
_barrier.SignalAndWait(); // 信號并等待其他線程
// 繼續執行
應用場景
- 并行算法中協調多個線程的階段,如分治算法、復雜數據并行流水線處理。
最佳實踐
- 確保所有參與者調用
SignalAndWait。
優點
- 協調多線程分階段執行。
缺點
- 設置復雜,需確保所有線程參與。
10. SpinWait
原理
SpinWait通過自旋等待條件成立,適合短時間等待。
操作方式
SpinWait.SpinUntil(() => someCondition);
應用場景
- 短時間等待條件成立,如檢查標志位。
最佳實踐
- 用于預期很快滿足的條件。
- 避免長時間自旋。
優點
- 避免上下文切換。
缺點
- 長時間等待浪費CPU資源。
11. 無鎖替代
-
不可變性 (Immutability):一旦創建對象就不可修改。避免了修改引起的同步需求(readonly 字段,記錄類型 record)。
-
線程本地存儲 (Thread-Local Storage - TLS):ThreadStaticAttribute, AsyncLocal 變量,ThreadLocal。每個線程使用自己獨立的數據副本(適用性有限)。
-
Interlocked 類:提供對簡單類型(int, long, IntPtr, float, double, object 引用)執行原子操作的靜態方法(Increment, Decrement, Add, Exchange, CompareExchange)。是最輕量級的“鎖”,基于 CPU 的原子指令實現,性能極高,無鎖開銷。
private int _counter = 0; public void IncrementSafely() { Interlocked.Increment(ref _counter); // 原子+1 } public void SetIfEqual(int newValue, int expected) { Interlocked.CompareExchange(ref _counter, newValue, expected); // CAS } -
基于任務的異步模式 (TAP) 與 Task:
- Channel (System.Threading.Channels):.NET Core 2.1+ 引入。高性能、無鎖/有界可選的生產者-消費者隊列替代方案(取代 BlockingCollection 和無鎖隊列手動實現)。支持單/多生產者、單/多消費者。是編寫異步管道、處理背壓 (Backpressure) 的首選。
var channel = Channel.CreateUnbounded<T>(); // 生產者 await channel.Writer.WriteAsync(item); // 消費者 while (await channel.Reader.WaitToReadAsync()) while (channel.Reader.TryRead(out var item)) { ... }- ValueTask / IValueTaskSource:Task 的輕量級替代(減少了堆分配),尤其在同步完成路徑上優化顯著。
-
Immutable Collections (System.Collections.Immutable):提供線程安全的不可變集合,通過原子替換整個集合引用來“修改”數據。讀操作非常高效(無需鎖),寫操作創建新集合,適合讀遠多于寫的共享數據。
-
專為并發訪問設計的內置集合:
- ConcurrentDictionary<TKey, TValue>:高效、低鎖競爭、可并行的字典。
- ConcurrentQueue / ConcurrentStack:先進先出(FIFO) / 后進先出(LIFO)隊列,基于CAS實現,避免鎖爭用。
- BlockingCollection:有界/無界生產者-消費者隊列(底層使用 ConcurrentQueue 等),提供 Take() 阻塞語義(Channel 通常是更好的異步選擇)。支持優雅取消和完成通知。
12. 結語
選擇合適的同步原語取決于應用程序需求,如是否需要進程間同步、讀寫分離或高性能。System.Threading.Lock是C# 13 中的新選擇,性能優于Monitor,適合大多數互斥場景。開發者應根據場景權衡性能、復雜性和功能,確保線程安全的同時避免死鎖和性能瓶頸。
13. 附件表格對比
|
同步原語
|
互斥性
|
允許多讀
|
進程間支持
|
性能
|
示例用例
|
是否支持可重入
|
|---|---|---|---|---|---|---|
|
Monitor
|
是
|
否
|
否
|
高
|
保護共享變量
|
是
|
|
System.Threading.Lock
|
是
|
否
|
否
|
極高
|
高性能互斥鎖
|
是
|
|
Mutex
|
是
|
否
|
是
|
低
|
進程間同步
|
是
|
|
SpinLock
|
是
|
否
|
否
|
極高
|
極短臨界區
|
否
|
|
ReaderWriterLockSlim
|
是(寫)
|
是
|
否
|
中
|
讀多寫少資源
|
是
|
|
Semaphore
|
否
|
無
|
是
|
中
|
限制并發訪問
|
否
|
|
SemaphoreSlim
|
否
|
無
|
否
|
高
|
進程內并發控制
|
否
|
|
EventWaitHandle
|
否
|
無
|
是
|
中
|
線程/進程間信號傳遞
|
否
|
|
ManualResetEventSlim
|
否
|
無
|
否
|
高
|
進程內信號傳遞
|
否
|
|
CountdownEvent
|
否
|
無
|
否
|
中
|
等待多個信號
|
否
|
|
Barrier
|
否
|
無
|
否
|
中
|
分階段線程執行
|
否
|
|
Interlocked
|
否
|
無
|
否
|
極高
|
原子操作
|
否
|
|
SpinWait
|
否
|
無
|
否
|
高
|
短時間自旋等待
|
否
|
?由于資料驗證范圍太廣,難免會有遺漏,如果上述表格內的內容有問題,請在評論區告訴我
本文來自博客園,作者:AI·NET極客圈,轉載請注明原文鏈接:http://www.rzrgm.cn/code-daily/p/18924622
歡迎關注我們的公眾號,作為.NET工程師,我們聚焦人工智能技術,探討 AI 的前沿應用與發展趨勢,為你立體呈現人工智能的無限可能,讓我們共同攜手共同進步。

浙公網安備 33010602011771號