1. 更複雜的 Race Condition 範例
Race Condition(競態條件)是最難捉摸的錯誤之一。為什麼?因為它們通常很少發生、依賴 CPU 速度、作業系統延遲、線程之間的隨機切換,通常只有在你想下班時才會重現。這些錯誤靜態分析器抓不到,單元測試抓不到(除非你是通靈師),但有一天……它們會在 production 裡出現。
為了避免這些討厭的驚喜,你需要真懂競態條件是怎麼來的,以及該怎麼處理。
範例 1. 沒有銀行道德的銀行
假設我們有一個非常簡單的銀行帳戶類別:
public class Account
{
public int Balance = 0;
public void Deposit(int amount)
{
Balance += amount;
}
public void Withdraw(int amount)
{
Balance -= amount;
}
}
想像有兩個線程同時嘗試存入 100 和 200 歐元,然後每個人依序提款 50 歐元。如果是單線程世界,餘額永遠是: (0 + 100 + 200 - 50 - 50) = 200。
那在多線程的現實會發生什麼?我們來做個實驗:
var acc = new Account();
var t1 = new Thread(() => {
acc.Deposit(100);
acc.Withdraw(50);
});
var t2 = new Thread(() => {
acc.Deposit(200);
acc.Withdraw(50);
});
t1.Start(); t2.Start();
t1.Join(); t2.Join();
Console.WriteLine(acc.Balance); // 這裡每次執行可能會得到不同的結果!
說明:
如果兩個線程都把 Balance 讀成 0,然後一個寫成 100,另一個寫成 200,而在這些動作之間發生了線程切換 — 餘額可能會「丟失」或「多出」錢。沒有鎖的銀行 — 騙子的天堂!
範例 2. 旗標變數
非常常見的錯誤是把布林旗標當成狀態指示器:
bool isReady = false;
void Worker()
{
while (!isReady)
{
// 等待...
}
// 做些事情
}
另一個線程可能會寫入 isReady = true。乍看之下好像安全:讀寫一個 bool 能有什麼問題?其實,連讀寫布林變數也可能不安全!原因在於:編譯器優化、CPU 快取、在多核系統中的指令重排。
會發生什麼?
一條線程可能永遠看不到變數的改變,持續在無窮迴圈中旋轉,即使另一條線程早就把 isReady 設為 true。在線程間傳遞「旗標」要用專門的原語(volatile、Interlocked、event 等等 — 詳見 官方文件)。
範例 3. null 檢查與物件建立
想像一個單例類(Singleton):
public class Singleton
{
private static Singleton _instance;
public static Singleton Instance
{
get
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
}
}
這段看起來很直觀?但如果很多線程同時呼叫 Instance,很可能會建立出多個實例!這是沒有同步的 double-check 問題:欄位 _instance 會因競爭而被初始化多次,如果不保護 lock,就會出問題。
2. Race Condition 如何出現在程式碼裡
「讀-修改-寫」操作的解剖 — 惡的根源
再看我們的遞增: counter++
在處理器層級,這包含:
- 從記憶體讀取: register = counter;
- 遞增: register = register + 1;
- 寫回: counter = register;
如果兩個線程幾乎同時執行這個區塊會怎樣?
- 兩個線程都讀到 counter = 0。
- 兩個都把暫存器加到 1。
- 兩個都把 1 寫回。
結果:兩次遞增後值只增加了 1!一個增加被「吃掉」了——沒人發現。
兩個線程「碰撞」的視覺化
執行緒 A | 執行緒 B | counter-------------------------------------------
讀取 (0) | | 0
| 讀取 (0) | 0
遞增 (1) | | 0
| 遞增 (1) | 0
寫回 (1) | | 1
| 寫回 (1) | 1 <-- 哎呀!期待 2,結果 1
為什麼競態條件這麼難抓?
- 競態條件會被「掩蓋」。在程式碼中加幾個 Console.WriteLine — 錯誤可能就消失了,只因為線程走了不同的路徑。
- 錯誤依賴核心數量、負載、作業系統與 .NET 版本。昨天還好好的,今天就壞掉了。
- 結果是非確定性的:錯誤不總是發生,只有在「特別情況」下才出現。
- 用除錯器追查這種事很折磨人。
3. .NET 的集合與類別中的 Race Condition
競態條件不只在變數上出現。集合、佇列,甚至一些標準的 .NET 類別並不總是 thread-safe。
範例:List<T> 不是 thread-safe
如果兩個線程同時對一個普通的 List<T> 呼叫 .Add(),後果可能很慘:
var list = new List<int>();
var tasks = new List<Task>();
for (int t = 0; t < 10; t++)
{
int threadNum = t;
tasks.Add(Task.Run(() => {
for (int i = 0; i < 1000; i++)
list.Add(threadNum * 1000 + i);
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(list.Count); // 很可能會少於 10000
這裡 Race Condition 發生在 Add() 內部,因為集合可能在擴展內部陣列、複製元素,而此時另一個線程又在新增元素……結果是資料遺失、拋出例外或集合損壞。
範例:多個線程共用同一個 indexer
var array = new int[10];
void Worker(int index, int value)
{
array[index] = value;
}
Parallel.Invoke(
() => Worker(5, 1),
() => Worker(5, 2)
);
最後 array[5] 可能是 1 或 2 — 看誰最後寫入。有時這行為是預期的,但多半會導致細微且難以捕捉的 bug。
4. 有用的細節
競態條件與非原子操作
如果某個操作看起來「很小」,但不保證原子性,就要特別小心。
- i++, i--
- a = b
- myObject.Property = value
- 「檢查旗標 — 然後改變值」
- 「取得物件 — 修改它的內部狀態」
這些單步看起來很簡單,但都不是原子動作!其他線程可以在微步之間介入。
關於競態條件的迷思與陷阱
迷思 1:「如果變數是 int,那它就是安全的,畢竟是原始型別」。
現實: 即便是對 int 的操作也不保證原子性,除非用 volatile 標記或通過專門 API 使用。
迷思 2:「我在集合裡寫入,別的線程只讀取 — 沒問題」。
現實: 不是的!在 .NET 中,集合在同時讀寫時不保證完整性,可能得到「壞掉」的物件或奇怪的例外。
迷思 3:「競態條件只在高負載服務裡出現」。
現實: 即使在小工具、遊戲、桌面程式也會碰到競態條件。只是發生頻率低或不那麼明顯。
如何防範 Race Condition?
- 使用同步原語(lock、Mutex、Monitor 等)。
- 對於簡單的遞增/遞減數字操作,使用 Interlocked(文件)。
- 對集合使用專門的 thread-safe 類別(ConcurrentBag、ConcurrentDictionary 等 — 參見 文件)。
- 不要在沒有保護下使用旗標變數或狀態(用 volatile、事件、同步原語)。
什麼情況會出現 Race Condition
| 情境 | 會有 Race Condition 嗎? | 如何防護 |
|---|---|---|
| 多個線程只讀資料 | 否(如果沒有變更) | - |
| 一個線程寫,另一個讀 | 會 | |
| 多個線程寫入 | 會 | |
| 多個線程修改集合 | 會 | 使用 thread-safe 集合 |
| 多個線程使用旗標 | 會 | volatile、lock、事件 |
原子性不是萬靈丹
有時即便是原子操作也救不了邏輯層面的「競爭」。
範例:
if (!cache.ContainsKey(key))
{
cache[key] = GetData(key);
}
如果兩個線程同時進到這個 if,兩個都會看到鍵不存在,然後兩個都建立新值 — 第二個會覆寫第一個!在這種情況下,原子性的 int 操作或 Interlocked 幫不上忙 —— 需要整段鎖定或用專門的函式(像 ConcurrentDictionary 的 GetOrAdd)。
4. 學生在處理 Race Condition 時常犯的錯誤
錯誤 #1:以為 lock 只在「大事」時才需要。
事實上連簡單變數在沒有保護時都會壞掉。
錯誤 #2:鎖錯物件。
例如鎖住字串或某個從類別外可存取的物件。結果同步根本沒起作用。
錯誤 #3:相信「結果幾乎總是正確」。
錯誤偶發並不代表可以不保護資源。
錯誤 #4:在沒有同步的情況下使用 async 方法。
抱持僥倖心理,結果在最意想不到的地方出現亂七八糟的錯誤。
錯誤 #5:寫了無鎖的 double-check Singleton。
結果在記憶體中出現多個物件副本,調試會變得噩夢般困難。
GO TO FULL VERSION