只要多條執行緒會同時接觸同一份可變資料,就得談同步。否則問題不是「偶爾算錯」,而是整個程式的正確性都建立在碰運氣的排程順序上。

這篇把常見同步原語拆成幾個層次:先看為什麼需要同步,再看使用者模式與核心模式的差異,最後收斂到 .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 層級同步物件整合

EventWaitHandleSemaphoreMutex

EventWaitHandle

事件可以把它想成一個布林訊號:

  • signaled:允許等待者通過
  • non-signaled:等待者阻塞

常見兩個具體型別:

  • AutoResetEvent:放行一個等待者後自動重置
  • ManualResetEvent:會持續保持 signaled,直到手動 reset

適合拿來做「通知」而不是資料保護。

Semaphore

semaphore 用計數方式控制同時進入資源的人數。適合:

  • 限流
  • 資源池
  • 固定數量的併發配額

Mutex

mutex 是跨執行緒甚至跨行程的互斥工具,但它的成本通常高於一般 lock / Monitor。如果你只在同一個 process 裡保護普通臨界區,通常不該優先選它。

ManualResetEventSlimSemaphoreSlim

這兩個 Slim 型別是很實用的折衷方案:

  • 先用輕量方式等待
  • 需要時才退化到較重的等待機制

ManualResetEventSlim

適合短時間等待某個訊號成立,例如某個初始化完成。

SemaphoreSlim

是現代 .NET 很常用的限流工具,尤其因為它提供 WaitAsync()

await _semaphore.WaitAsync(token);
try
{
    await DoWorkAsync();
}
finally
{
    _semaphore.Release();
}

這點很重要,因為許多核心模式老原語並沒有良好的 async 整合,而 SemaphoreSlim 剛好補上這個缺口。

Monitorlock

在 .NET 日常程式裡,最常見的互斥工具仍然是 lock,它底層就是建構在 Monitor 上。

private readonly object _gate = new();

lock (_gate)
{
    _count++;
    _items.Add(_count);
}

它適合:

  • 保護短小、同步、process 內部的臨界區
  • 一次維護多個欄位的一致性

幾個基本原則必須守:

  • 永遠鎖 private 專用物件,不要鎖 this
  • 不要鎖字串
  • 不要鎖 typeof(...)
  • 不要讓鎖物件暴露到外部
  • 持鎖時間要短

如果需要手動使用 Monitor,通常是因為你要:

  • TryEnter
  • Pulse
  • PulseAll
  • Wait

也就是需要 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,因為:

  • 單次容器操作可能安全
  • 多步驟組合操作仍可能需要外層協調

也就是說,「容器安全」不等於「整個流程安全」。

實務上怎麼選

可以用一個很務實的順序判斷:

  1. 能不能避免共享可變狀態
  2. 能不能改用 immutable data 或 message passing
  3. 單欄位原子操作能不能用 Interlocked
  4. 可見性問題能不能只靠 Volatile
  5. 一般同步臨界區用 lock
  6. 需要 async-friendly 限流用 SemaphoreSlim
  7. 需要多 reader / 單 writer 時再看 ReaderWriterLockSlim
  8. 真的需要 OS 層能力時才進入 WaitHandle 家族

這個順序的核心思路只有一個:先用最便宜、最容易推理的工具。

實務上該記住什麼

  • 同步的成本不只在效能,也在設計複雜度
  • 先避免共享,再談鎖
  • 能用 Interlocked 就別急著上 lock
  • lock 適合短小同步臨界區,但不能跨 await
  • SemaphoreSlim 是 async 場景非常實用的同步工具
  • 如果要手動釋放鎖或配額,應該放在 finally 中保證收尾

參考資料

  • 《CLR via C#》第四版,Jeffrey Richter