CodeGym /課程 /C# SELF /深入解析 Race Conditions(競態條件)

深入解析 Race Conditions(競態條件)

C# SELF
等級 57 , 課堂 0
開放

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;
    }
}

想像有兩個線程同時嘗試存入 100200 歐元,然後每個人依序提款 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。在線程間傳遞「旗標」要用專門的原語(volatileInterlocked、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++

在處理器層級,這包含:

  1. 從記憶體讀取: register = counter;
  2. 遞增: register = register + 1;
  3. 寫回: 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] 可能是 12 — 看誰最後寫入。有時這行為是預期的,但多半會導致細微且難以捕捉的 bug。

4. 有用的細節

競態條件與非原子操作

如果某個操作看起來「很小」,但不保證原子性,就要特別小心。

  • i++, i--
  • a = b
  • myObject.Property = value
  • 「檢查旗標 — 然後改變值」
  • 「取得物件 — 修改它的內部狀態」

這些單步看起來很簡單,但都不是原子動作!其他線程可以在微步之間介入。

關於競態條件的迷思與陷阱

迷思 1:「如果變數是 int,那它就是安全的,畢竟是原始型別」。
現實: 即便是對 int 的操作也不保證原子性,除非用 volatile 標記或通過專門 API 使用。

迷思 2:「我在集合裡寫入,別的線程只讀取 — 沒問題」。
現實: 不是的!在 .NET 中,集合在同時讀寫時不保證完整性,可能得到「壞掉」的物件或奇怪的例外。

迷思 3:「競態條件只在高負載服務裡出現」。
現實: 即使在小工具、遊戲、桌面程式也會碰到競態條件。只是發生頻率低或不那麼明顯。

如何防範 Race Condition?

  • 使用同步原語(lockMutexMonitor 等)。
  • 對於簡單的遞增/遞減數字操作,使用 Interlocked文件)。
  • 對集合使用專門的 thread-safe 類別(ConcurrentBagConcurrentDictionary 等 — 參見 文件)。
  • 不要在沒有保護下使用旗標變數或狀態(用 volatile、事件、同步原語)。

什麼情況會出現 Race Condition

情境 會有 Race Condition 嗎? 如何防護
多個線程只讀資料 否(如果沒有變更) -
一個線程寫,另一個讀
lock/volatile
多個線程寫入
lock/Interlocked
多個線程修改集合 使用 thread-safe 集合
多個線程使用旗標 volatile、lock、事件

原子性不是萬靈丹

有時即便是原子操作也救不了邏輯層面的「競爭」。

範例:

if (!cache.ContainsKey(key))
{
    cache[key] = GetData(key);
}

如果兩個線程同時進到這個 if,兩個都會看到鍵不存在,然後兩個都建立新值 — 第二個會覆寫第一個!在這種情況下,原子性的 int 操作或 Interlocked 幫不上忙 —— 需要整段鎖定或用專門的函式(像 ConcurrentDictionaryGetOrAdd)。

4. 學生在處理 Race Condition 時常犯的錯誤

錯誤 #1:以為 lock 只在「大事」時才需要。
事實上連簡單變數在沒有保護時都會壞掉。

錯誤 #2:鎖錯物件。
例如鎖住字串或某個從類別外可存取的物件。結果同步根本沒起作用。

錯誤 #3:相信「結果幾乎總是正確」。
錯誤偶發並不代表可以不保護資源。

錯誤 #4:在沒有同步的情況下使用 async 方法。
抱持僥倖心理,結果在最意想不到的地方出現亂七八糟的錯誤。

錯誤 #5:寫了無鎖的 double-check Singleton。
結果在記憶體中出現多個物件副本,調試會變得噩夢般困難。

2
任務
C# SELF, 等級 57, 課堂 0
上鎖
透過 `Monitor` 同步存取資源
透過 `Monitor` 同步存取資源
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION