只要多條執行緒會同時接觸同一份可變資料,就得談同步。否則問題不是「偶爾算錯」,而是整個程式的正確性都建立在碰運氣的排程順序上。
這篇把常見同步原語拆成幾個層次:先看為什麼需要同步,再看使用者模式與核心模式的差異,最後收斂到 .NET 實務上最常用的幾個工具。
為什麼需要同步
如果兩條執行緒同時修改同一個狀態,就可能產生 race condition。典型問題包括:
- 加總結果遺失
- 狀態讀到一半的中間值
- 一條執行緒看到另一條執行緒尚未完整發布的資料
- 事件順序失去保證
同步的目的,不只是「一次只讓一個人進來」,還包括:
- 確保可見性
- 保證操作的原子性
- 控制不同工作之間的先後關係
執行緒安全不是免費的
一段程式碼要變成 thread-safe,通常至少要付出下列代價之一:
- 額外記憶體屏障
- 自旋等待
- 核心態阻塞與喚醒
- 更複雜的設計與除錯成本
因此同步不是越多越好,而是要選「足夠正確、但成本最低」的工具。
使用者模式與核心模式原語
可以先把同步原語分成兩大類:
- 使用者模式(user mode)
- 核心模式(kernel mode)
差異很實際:
- 使用者模式通常更快,因為不需要每次都切到 OS 核心
- 核心模式成本更高,但能真正阻塞執行緒,也能支援跨行程同步
如果只是保護同一個 process 內的短臨界區,優先考慮使用者模式工具;如果需要等待 OS 物件、跨 process 同步,才考慮核心模式。
先從最便宜的工具開始:Volatile
Volatile 解決的不是複合操作競爭,而是可見性與重排序問題。
它適合的情境像是:
- 一條執行緒寫入旗標
- 另一條執行緒輪詢旗標決定是否退出
private static int _shouldStop;
Volatile.Write(ref _shouldStop, 1);
if (Volatile.Read(ref _shouldStop) == 1)
{
return;
}
要點是:
- 它只能保證該讀寫的可見性語意
- 它不能把
x++這種複合操作變成原子 - 如果你需要讀改寫一體完成,
Volatile不夠
volatile 關鍵字也是同一方向的工具,但可用範圍與表達力較窄,今天更常直接用 System.Threading.Volatile。
Interlocked:原子更新的第一選擇
如果需求是單一欄位的原子操作,優先看 Interlocked。
常見例子:
int newValue = Interlocked.Increment(ref _count);
int oldValue = Interlocked.Exchange(ref _state, 1);
bool updated = Interlocked.CompareExchange(ref _ownerId, currentId, 0) == 0;
它適合:
- 計數器
- 單欄位狀態切換
- lock-free 演算法中的 compare-and-swap
它不適合:
- 需要保護多個欄位的一致性
- 複雜臨界區
- 必須等待條件變化的情境
原則很簡單:能用 Interlocked 解決,就不要急著上 lock。
WaitHandle 家族:核心模式等待原語
WaitHandle 是一組核心模式同步工具的抽象基底,能把執行緒真正阻塞住,直到條件成立或逾時。
常見能力包括:
WaitOne()WaitAny()WaitAll()
這類工具比 user-mode 原語慢,但有兩個明確優勢:
- 可讓執行緒真正睡眠,避免空轉
- 可與 OS 層級同步物件整合
EventWaitHandle、Semaphore、Mutex
EventWaitHandle
事件可以把它想成一個布林訊號:
- signaled:允許等待者通過
- non-signaled:等待者阻塞
常見兩個具體型別:
AutoResetEvent:放行一個等待者後自動重置ManualResetEvent:會持續保持 signaled,直到手動 reset
適合拿來做「通知」而不是資料保護。
Semaphore
semaphore 用計數方式控制同時進入資源的人數。適合:
- 限流
- 資源池
- 固定數量的併發配額
Mutex
mutex 是跨執行緒甚至跨行程的互斥工具,但它的成本通常高於一般 lock / Monitor。如果你只在同一個 process 裡保護普通臨界區,通常不該優先選它。
ManualResetEventSlim 與 SemaphoreSlim
這兩個 Slim 型別是很實用的折衷方案:
- 先用輕量方式等待
- 需要時才退化到較重的等待機制
ManualResetEventSlim
適合短時間等待某個訊號成立,例如某個初始化完成。
SemaphoreSlim
是現代 .NET 很常用的限流工具,尤其因為它提供 WaitAsync():
await _semaphore.WaitAsync(token);
try
{
await DoWorkAsync();
}
finally
{
_semaphore.Release();
}
這點很重要,因為許多核心模式老原語並沒有良好的 async 整合,而 SemaphoreSlim 剛好補上這個缺口。
Monitor 與 lock
在 .NET 日常程式裡,最常見的互斥工具仍然是 lock,它底層就是建構在 Monitor 上。
private readonly object _gate = new();
lock (_gate)
{
_count++;
_items.Add(_count);
}
它適合:
- 保護短小、同步、process 內部的臨界區
- 一次維護多個欄位的一致性
幾個基本原則必須守:
- 永遠鎖 private 專用物件,不要鎖
this - 不要鎖字串
- 不要鎖
typeof(...) - 不要讓鎖物件暴露到外部
- 持鎖時間要短
如果需要手動使用 Monitor,通常是因為你要:
TryEnterPulsePulseAllWait
也就是需要 condition variable 風格的協作,而不只是普通互斥。
ReaderWriterLockSlim
如果讀多寫少,ReaderWriterLockSlim 可能比單純 lock 更合適。
它的策略是:
- 多個 reader 可同時進入
- writer 需要獨占
這在快取、設定快照、查詢型共享資料上很常見。
但它不是無腦升級版。若臨界區很短、競爭不高,單純 lock 反而可能更簡單、更快。只有在讀取佔絕大多數且競爭明顯時,reader-writer 模式才容易回本。
CountdownEvent
CountdownEvent 適合做一組工作的收斂同步。你可以把它理解成「等 N 件事都做完,再放行」。
典型場景:
- 啟動多個 worker
- 每完成一個就
Signal() - 主控流程
Wait()等全部收齊
對於需要聚合完成訊號的場景,這比自己手寫 shared counter + event 更清楚。
Concurrent Collections
很多時候,真正需要的不是鎖,而是直接選用 thread-safe 容器。
System.Collections.Concurrent 常用型別包括:
ConcurrentQueue<T>ConcurrentStack<T>ConcurrentBag<T>ConcurrentDictionary<TKey, TValue>
它們的價值在於把同步細節藏進容器內部,降低手寫鎖的機會。不過這不代表整段業務邏輯自動 thread-safe,因為:
- 單次容器操作可能安全
- 多步驟組合操作仍可能需要外層協調
也就是說,「容器安全」不等於「整個流程安全」。
實務上怎麼選
可以用一個很務實的順序判斷:
- 能不能避免共享可變狀態
- 能不能改用 immutable data 或 message passing
- 單欄位原子操作能不能用
Interlocked - 可見性問題能不能只靠
Volatile - 一般同步臨界區用
lock - 需要 async-friendly 限流用
SemaphoreSlim - 需要多 reader / 單 writer 時再看
ReaderWriterLockSlim - 真的需要 OS 層能力時才進入
WaitHandle家族
這個順序的核心思路只有一個:先用最便宜、最容易推理的工具。
實務上該記住什麼
- 同步的成本不只在效能,也在設計複雜度
- 先避免共享,再談鎖
- 能用
Interlocked就別急著上lock lock適合短小同步臨界區,但不能跨awaitSemaphoreSlim是 async 場景非常實用的同步工具- 如果要手動釋放鎖或配額,應該放在
finally中保證收尾
參考資料
- 《CLR via C#》第四版,Jeffrey Richter