1. 介紹
在多執行緒應用程式中,共享資源是指兩個或更多執行緒可以同時存取的任何東西。這可能是:
- 變數(例如,全域計數器或清單)。
- 物件(例如,使用者的集合)。
- 檔案或網路 socket。
- 任何會被不同執行緒修改的資料結構。
在我們的主控台應用中,最常遇到的是在執行緒間「shared」的變數與物件。
類比
想像兩個人同時嘗試在同一本筆記本上寫東西,卻沒排好順序。最好的情況是字跡潦草,最壞的情況是有人把別人的內容覆蓋掉。在程式裡情況一樣,只是那兩個「人」是執行緒。
常見會發生競態的資源(簡述)
下面表格列出最常見、在多執行緒共同存取下有危險的資源:
| 資源 | 問題類型 | 範例 |
|---|---|---|
| 像 int 的變數 | 不正確的遞增/遞減 | 計數器、索引 |
| 共享集合 | 元素遺失/損壞、拋出例外 | 共享的訂單清單 |
| 物件 | 狀態變更不一致 | 旗標、屬性 |
| 檔案 | 資料損壞、讀寫不正確 | 日誌檔、設定檔 |
2. 競態條件:它如何表現?
範例:訪問計數器
假設我們想統計使用者按鈕的次數(或在本例中,不同執行緒對變數做了多少次遞增)。簡單版本的程式碼:
int counter = 0;
void Increment() {
counter++;
}
現在建立兩個執行緒,每個執行緒呼叫 Increment() 100 000 次:
using System;
using System.Threading;
class Program
{
static int counter = 0;
static void Increment()
{
for (int i = 0; i < 100_000; i++)
{
counter++;
}
}
static void Main()
{
Thread t1 = new Thread(Increment);
Thread t2 = new Thread(Increment);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"預期: 200000, 實際: {counter}");
}
}
理論上 counter 應該被增加幾次?答案是 200000!但如果你多次執行此程式,很可能會看到不同的數字:185000、192500、198765… 為什麼會這樣?
3. 為什麼 counter++ 不是原子操作?
實際上 counter++ 怎麼執行
在 C# 和其他高階語言中,程式會被轉成一系列機器指令。很遺憾地,運算子 counter++ 不會變成一條魔法指令「把變數加 1」。實際發生的是:
- 執行緒從記憶體讀取值(counter)。
- 在 CPU 暫存器中把這個值加上 1。
- 把新的值寫回記憶體(counter)。
如果兩個執行緒幾乎同時做這些步驟,兩者可能都讀到相同的舊值,分別遞增後再都寫回,這樣就少算了一次遞增。
競態情境
假設 counter 為 1000。兩個執行緒都讀到這個值(步驟 1),各自在暫存器中遞增到 1001(步驟 2),然後都把 1001 寫回記憶體(步驟 3)。結果:一次遞增被丟失!
競態視覺化
| 時間點 | 執行緒 1 | 執行緒 2 | 值 counter |
|---|---|---|---|
| 1 | 讀取 1000 | 1000 | |
| 2 | 讀取 1000 | 1000 | |
| 3 | 暫存器中遞增到 1001 | 暫存器中遞增到 1001 | 1000(尚未寫回) |
| 4 | 寫回 1001 | 1001 | |
| 5 | 寫回 1001 | 1001 |
結果是:兩次遞增只增加了 1!
4. 更多範例:那些「看不見的 bug」
如果 race condition 發生在不是數字的東西上會怎樣?
現在想像多個執行緒同時往同一個清單新增元素:
using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
static List<int> numbers = new List<int>();
static void AddNumbers()
{
for (int i = 0; i < 10000; i++)
{
numbers.Add(i);
}
}
static void Main()
{
Thread t1 = new Thread(AddNumbers);
Thread t2 = new Thread(AddNumbers);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"預期: 20000, 實際: {numbers.Count}");
}
}
這段程式也會在每次執行時產生不同結果:有時會拋出例外(crash),有時看到的元素數量比預期少。
原因在於 List<T> 預設不是 thread-safe。當兩個執行緒同時呼叫 Add 時,可能會損壞內部結構。
5. 原子操作(atomic operations)
什麼是原子操作?
原子操作是指一個不可被其它執行緒在中途打斷的整體操作。可以把它想成一個「交易」:要麼全部完成,要麼一律不執行。
- 在大多數平臺上,像 int 型別的賦值 myVar = 42; 是原子性的(除非那是非常大的物件)。
- 但 counter++ 並非原子性 — 它由三個連續動作組成。
專用的原子操作
在 .NET 有一些用來做原子操作的類別,例如 Interlocked。我們會在後面的講義詳細討論這種做法。
用 Interlocked.Increment 做原子遞增的範例:
using System.Threading;
int counter = 0;
Interlocked.Increment(ref counter); // 原子操作!
6. 為什麼很難抓到 race condition?
競態條件的危險性在於:
- 可能只在高負載下才出現。
- 不是每次都會觸發,可能只有 5%、甚至 0.01% 的機率。
- 會「隨機」當掉,並且常出現在你最意想不到的地方。
如何察覺問題?
如果每次執行程式你都得到不同(且錯誤)的結果,就應該懷疑是競態條件。
程式員的玩笑
「如果錯誤很少見,而且靠加個 Thread.Sleep(50) 就能暫時解決 — 你的問題比你想的還嚴重。」
7. 有用的小技巧
同步(synchronization)
為了保護關鍵區段(也就是對共享資源操作的程式碼區塊),需要對它們做同步。這部分會在後續講義詳細介紹。現在最重要的是學會發現並解釋問題的原因。
初學者常犯的錯
很多新手會想:「我只有一個 counter++,哪會出事?」可惜,一旦有超過一個執行緒,任何事情都有可能出錯!即使看起來很簡單的操作:讀取與寫入變數、往清單新增元素、改變物件狀態等,都可能導致問題。
競態在真實開發中的地位
在現代的多執行緒應用(例如伺服器 API、Web 請求處理、遊戲或行動應用)中,幾乎都有共享資源。若沒有同步機制,競態條件會導致訂單處理錯誤、系統當機、記憶體洩漏,並且除錯會非常困難。
在面試 middle/senior 的職位時,通常會被問到:「什麼是競態條件?如何避免?」如果你能舉出上面那些範例並解釋其內部機制,面試官會很欣賞。
GO TO FULL VERSION