1. 介紹
在多執行緒應用中,出現 race condition — 不是「是否會發生」而是「何時會發生」。即使你覺得你的程式碼很可靠,而且只有「兩個小執行緒」、「一切明顯且簡單」,競態也可能潛伏在最看似無害的邏輯片段裡。
競態條件到底是什麼,為什麼那麼可怕?想像兩個人同時編輯同一張紙 — 一個寫入,另一個擦除。有時沒事,有時變得亂七八糟。在程式裡後果更有趣:錯誤不會每次都出現,而只在某些幾乎隨機的情況下發生。
Race condition (競態條件) — 是指程式的執行結果取決於哪個執行緒先存取某個資源或完成某個操作。這個問題僅在並發(多執行緒)存取,共用資料或資源被兩個或更多執行緒訪問時出現。
競態發生時會怎樣?
這裡有個簡單的示意。想像我們有兩個執行緒和一個共用資源(例如變數 X):
+---------+ +---------+
| 執行緒 1 | | 執行緒 2 |
+----+----+ +----+----+
| |
| 讀取 X |
| <-------------------|
| |
| 增加 X |
|-------------------> |
| |
| 寫回 X |
| <-------------------|
如果兩個執行緒同時讀取變數 X,然後都進行增加並寫回,某一方會「覆蓋」另一方的變更,總增加次數就不會和預期相符。
2. 經典的 Race Condition 範例
我們來看個例子。假設我們要統計不同執行緒按鈕點擊的次數或處理任務的數量。
拿個簡單變數和幾個會增加它的執行緒:
using System;
using System.Threading;
class Program
{
static int counter = 0; // 共享資源
static void Main()
{
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine("期望值: 200000");
Console.WriteLine("實際值: " + counter);
}
static void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // << 這裡可能會出問題!
}
}
}
我們期望什麼?
因為每個執行緒把 counter 增加 100000 次,我們預期最終值是 200000。
實際上得到什麼?
有時候 — 是的,會是 200000。但更多時候數值會更小 — 有時候差很多。多次重複實驗,結果會波動!
為什麼會這樣?
操作 counter++ 不是原子性的。它實際上大致做了以下幾個步驟(簡化):
- 讀取當前的 counter(例如 0)
- 加 1(得到 1)
- 寫回(counter = 1)
如果兩個執行緒同時讀到相同的舊值,兩方可能都寫回相同的新值,實際上只算了一次增加。
兩個執行緒的示意:
假設 counter = 0.
- 執行緒 1:讀取 0
- 執行緒 2:讀取 0
- 執行緒 1:計算 0 + 1 = 1
- 執行緒 2:計算 0 + 1 = 1
- 執行緒 1:寫回 1
- 執行緒 2:寫回 1(覆蓋了執行緒 1 的增加)
「恭喜」,剛剛丟失了一次增加!放大到數千或數百萬次操作,結果會很不穩定。
3. 還有其他例子:不只是遞增!
廚房裡的混亂
為了更形象,想像一家小咖啡館。兩個廚師在同一個平底鍋上煎蛋,但沒協調:
- 第一個放了一個蛋,第二個立刻也放自己的蛋 — 它們互相干擾;
- 一個以為「我已經放了兩個蛋」,另一個也這麼想,但實際鍋裡是三個,而他們以為是四個;
- 混亂開始了...
在程式中,race condition 也會導致類似的「混亂」:結果取決於一連串快速且無法控制的操作順序。
當執行緒互相干擾:同時存取資料
假設你實作一個銀行應用,客戶同時使用兩個執行緒向同一帳戶存款和取款(例如,一個是線上轉帳,另一個是櫃台):
account.Balance += 500; // 執行緒 1: 存款
account.Balance -= 300; // 執行緒 2: 取款
如果這些操作沒有保護,最終餘額可能會不正確:某些操作會被「覆蓋」或丟失,當執行緒同時運行時特別容易發生。
4. 有用的注意事項
為什麼 race condition 是個問題?
難以捕捉與重現。 錯誤可能只在負載高的機器或罕見條件下出現。
難以除錯。 在除錯時執行緒的時序可能改變,錯誤就消失了。
資料完整性被破壞。 會得到不正確、損壞的資料,有時完全不易察覺。
安全性風險。 在關鍵應用中競態可能導致資料外洩、資料破壞,甚至產生漏洞。
「競態時序」示意圖
+-----------------------+ +-----------------------+
| 執行緒 1 | | 執行緒 2 |
+-----------------------+ +-----------------------+
| 1. 讀取 counter | | |
| 2. 增加 counter | | |
| (但不寫回) | | |
| | | 1. 讀取 counter |
| | | 2. 增加 counter |
| | | 3. 寫回 counter |
| | | (counter = 1) |
| 3. 寫回 counter | | |
| (counter = 1) | | |
+-----------------------+ +-----------------------+
兩個執行緒都做了增加,但最終只記錄到一次增加!
競態常見在哪裡
- 任何被多個執行緒訪問的全域或靜態變數。
- 從多個執行緒填充的清單、佇列、集合。
- 事件和 delegate,如果訂閱/取消訂閱同時發生(例如 UI + 背景任務)。
- 快取、字典、連線管理。
- 任何對檔案、日誌、資料庫的互動,沒有使用交易或鎖。
如何避免 race condition:簡單導讀
- 同步!(更詳細會在後面的課程講)
- 使用語言特性和庫:lock, Monitor, mutexes, semaphores 等。
- 對於簡單操作使用原子方法(Interlocked.Increment 等)。
- 使用線程安全的集合(ConcurrentBag, ConcurrentDictionary)。
- 總是想一想:「如果我的兩個函式同時被呼叫,會怎樣?」
5. 實用建議
搜尋與診斷競態的建議
- 別相信最簡單的操作(遞增 ++、賦值),如果你使用多個執行緒。
- 儘量避免共享變數。
- 如果看到「飄忽不定」的 bug、難以重現的錯誤 — 想想是不是競態!
- 使用執行緒分析工具(dotTrace, Concurrency Visualizer, Thread Sanitizer)。
- 做壓力測試 — 執行緒和操作越多,越容易觸發錯誤。
哪些情況可以在沒同步下安全操作,哪些不行
| 操作 | 在多執行緒環境安全嗎? | 說明 |
|---|---|---|
| 賦值 int | 🟩 有時* | 只有一個執行緒寫入,其他執行緒只讀取;否則會有競態 |
| 遞增 (++/--) | 🟥 否 | 不是原子操作!會有 Race Condition |
| 讀取 string | 🟩 有時* | 如果字串在建立後不會改變 |
| 物件賦值 | 🟩 有時* | 在沒有同時寫入的情況下安全 |
| 加入到 List<T> | 🟥 否 | List<T> 不是線程安全的 |
|
🟩 是 | 特殊的原子方法 |
— 「有時」是指如果只有一個執行緒在寫,其他都只讀,那就安全;但如果有多個執行緒可能寫入 — 就一定會有競態。
6. 典型錯誤與陷阱
在上面的示範程式中我們看到 counter++ 是個問題。另一個陷阱是檢查值然後在條件中變更值的情況。
範例:關於「第一次啟動」的有趣錯誤
if (!alreadyStarted)
{
alreadyStarted = true;
// 做初始化...
}
如果多個執行緒同時執行這段程式,可能每個執行緒都會看到 alreadyStarted == false 而進入區塊!結果就是初始化會被執行兩次,可能導致故障。
GO TO FULL VERSION