CodeGym /課程 /C# SELF /共享資源的問題

共享資源的問題

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

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」。實際發生的是:

  1. 執行緒從記憶體讀取值(counter)。
  2. 在 CPU 暫存器中把這個值加上 1。
  3. 把新的值寫回記憶體(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 的職位時,通常會被問到:「什麼是競態條件?如何避免?」如果你能舉出上面那些範例並解釋其內部機制,面試官會很欣賞。

2
任務
C# SELF, 等級 56, 課堂 0
上鎖
以整數計數器為例來偵測競態條件
以整數計數器為例來偵測競態條件
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION