CodeGym /課程 /C# SELF /介紹 Concurrent-集合

介紹 Concurrent-集合

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

1. 問題背景

在單一執行緒的應用裡,像 List<T>、Dictionary<T> 這類集合行為是可預期的。但一旦多個執行緒同時操作同一個集合,就會出現熟悉的問題:資料競爭(race conditions)。

如果好幾個執行緒在沒有適當同步的情況下讀寫同一個集合,你可能會遇到:

  • 資料不正確:某個元素可能被一個執行緒刪掉,另一個執行緒還在試著更新它。
  • 資料遺失:一個執行緒加入了元素,另一個執行緒不知情就覆寫了它。
  • 例外:集合可能會進入不合法狀態,然後拋出 InvalidOperationException(例如 "Collection was modified; enumeration operation may not execute.")或甚至 NullReferenceException。

範例 1:在 List<T> 的資料競爭(簡單遞增)

兩個執行緒同時對同一個列表元素做遞增。

using System.Collections.Generic;
using System.Threading.Tasks; // 用於 Task.Run

class RaceConditionExample
{
    static List<int> numbers = new List<int> { 0 }; // 列表只含一個元素

    static void Main(string[] args)
    {
        Console.WriteLine("初始值: " + numbers[0]); // 0

        // 啟動兩個工作,每個都遞增 numbers[0]
        Task task1 = Task.Run(() => IncrementNumbers(500_000));
        Task task2 = Task.Run(() => IncrementNumbers(500_000));

        Task.WaitAll(task1, task2); // 等兩個工作都結束

        Console.WriteLine("最終值: " + numbers[0]); // 期待 1_000_000,但...
        // 結果幾乎總是會小於 1_000_000!
    }

    static void IncrementNumbers(int count)
    {
        for (int i = 0; i < count; i++)
        {
            // 這個操作 "numbers[0]++" 其實包含 3 個步驟:
            // 1. 讀取 numbers[0]
            // 2. 值加 1
            // 3. 將新值寫回 numbers[0]
            numbers[0]++; 
        }
    }
}

為什麼會是競爭?如果執行緒 A 讀到 numbers[0](值為 0),接著執行緒 B 也在 A 寫回 1 前讀到 0,那兩個執行緒都會把 0 增到 1 並寫回 1。有一次遞增被丟失。操作 numbers[0]++ 不是原子性的。

範例 2:在 Dictionary 上發生 InvalidOperationException

一個執行緒在遍歷字典,另一個執行緒在修改它。

using System.Collections.Generic;
using System.Threading; // 用於 Thread.Sleep

class DictionaryRaceExample
{
    static Dictionary<int, string> users = new Dictionary<int, string>();

    static void Main(string[] args)
    {
        // 初始化字典
        for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");

        // 讀取執行緒
        Thread readerThread = new Thread(() =>
        {
            try
            {
                foreach (var user in users) // 遍歷字典
                {
                    Console.WriteLine($"讀者: {user.Key} - {user.Value}");
                    Thread.Sleep(10); // 模擬工作
                }
            }
            catch (InvalidOperationException ex)
            {
                Console.WriteLine($"讀者: 錯誤! {ex.Message}");
            }
        });

        // 寫入執行緒
        Thread writerThread = new Thread(() =>
        {
            Thread.Sleep(5); // 給讀者一點時間先開始
            for (int i = 5; i < 10; i++)
            {
                users.Add(i, $"New User {i}"); // 新增元素
                Console.WriteLine($"寫者: 新增 User {i}");
                Thread.Sleep(15);
            }
        });

        readerThread.Start();
        writerThread.Start();

        readerThread.Join(); // 等待執行緒結束
        writerThread.Join();
        Console.WriteLine("範例結束。");
    }
}

為什麼會發生錯誤?Dictionary<TKey, TValue>(和 List<T> 一樣)不適合在沒有同步的情況下被多個執行緒同時讀寫。當寫入執行緒改變了內部結構,讀取執行緒還在做 foreach,就會碰到 InvalidOperationException。

2. 為什麼簡單的鎖(lock)不一定最優?

把「所有東西都包在 lock 裡」看起來簡單,但有它的缺點:

// 糟糕的範例:鎖太粗(只為示範,不要這樣做!)
static object _lock = new object();
static List<int> _sharedList = new List<int>();

void AddItem(int item)
{
    lock (_lock)
    {
        _sharedList.Add(item);
    }
}

int GetItemCount()
{
    lock (_lock)
    {
        return _sharedList.Count;
    }
}
  • 效能(bottleneck): lock 會鎖住整個集合。當有 100 個執行緒時,99 個會在等一個在操作,哪怕那些操作彼此之間其實沒有衝突。
  • 複雜性:你必須在每個使用集合的地方記得加 lock。只要漏掉一處,race condition 就回來了。
  • 死鎖(deadlock): 多個 lock 在不同物件上很容易導致 deadlock。
  • 迭代器: foreach 也救不了,如果另一個執行緒在修改集合。

這就是為什麼 .NET 提供了專門的執行緒安全集合。

原子操作

執行緒安全的集合 — 保證在多執行緒同時存取時仍正確運作,無需使用者在外面上鎖。關鍵在於 原子操作(atomic operations):一個操作要嘛全部完成,要嘛完全不發生 — 其他執行緒不會看到「半成品」狀態。

  • 新增、刪除、讀取 —— 表現得好像是逐一執行的。
  • 內部會使用低層技術:interlocked 操作(Interlocked)、Compare-And-Swap (CAS)、較細粒度的鎖,而不是全域性鎖整個集合。

3. System.Collections.Concurrent 概覽

命名空間 System.Collections.Concurrent 提供了一組從頭設計、用於多執行緒場景的集合。它的設計哲學是:最大化並行度,最小化鎖。

  • 效能:隨核心數增加能更好擴展。
  • 簡單:通常不需要手動在每個操作外加 lock。
  • 減少錯誤:手動同步帶來的一類問題會變少。
  • 為競爭場景優化:在同時新增/刪除時表現良好。

4. 主要類別

ConcurrentQueue<T>(執行緒安全的佇列)

原則:FIFO — 先進先出。適用場景:producer–consumer、日誌、工作佇列。

using System.Collections.Concurrent;

ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();

void Producer() => messageQueue.Enqueue("訊息 1");

void Consumer()
{
    if (messageQueue.TryDequeue(out string message))
    {
        Console.WriteLine($"已處理: {message}");
    }
    else
    {
        Console.WriteLine("佇列為空。");
    }
}

ConcurrentStack<T>(執行緒安全的堆疊)

原則:LIFO — 後進先出。適用場景:操作歷史、DFS 遍歷、物件池。

using System.Collections.Concurrent;

ConcurrentStack<int> historyStack = new ConcurrentStack<int>();

void PushAction(int value) => historyStack.Push(value);

void PopAction()
{
    if (historyStack.TryPop(out int action))
    {
        Console.WriteLine($"已取消動作: {action}");
    }
    else
    {
        Console.WriteLine("堆疊為空。");
    }
}

ConcurrentBag<T>(執行緒安全的「袋子」)

無序的集合,不保證順序。針對「執行緒通常會拿回自己放進去的東西」這種情況做了優化。很適合用在物件池。

using System.Collections.Concurrent;

ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();

void AddObject() => objectPool.Add(System.Guid.NewGuid());

void TakeObject()
{
    if (objectPool.TryTake(out System.Guid obj))
    {
        Console.WriteLine($"取得物件: {obj}");
    }
    else
    {
        Console.WriteLine("物件池為空。");
    }
}

ConcurrentDictionary<TKey, TValue>(執行緒安全的字典)

支援對鍵的新增、更新和取得的原子操作。很適合用在快取、session、計數器等場景。

using System.Collections.Concurrent;

ConcurrentDictionary<string, int> userScores = new ConcurrentDictionary<string, int>();

void UpdateScore(string user, int score)
{
    // 原子性地新增(如果沒存在)或更新(如果已存在)
    userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
    Console.WriteLine($"分數 {user}: {userScores[user]}");
}

void GetScore(string user)
{
    if (userScores.TryGetValue(user, out int score))
    {
        Console.WriteLine($"目前分數 {user}: {score}");
    }
    else
    {
        Console.WriteLine($"找不到使用者 {user}.");
    }
}

5. 何時該用這些集合而不是一般集合?

  • 應用是多執行緒的:如果只有單一執行緒,普通集合會更快(沒有額外開銷)。
  • 多個執行緒共用同一個集合:這是使用 System.Collections.Concurrent 的主要指標。
  • 需要高效能與可擴展性:這些集合為了最小化等待而設計。
  • 想簡化程式碼:不用在每次操作外面寫手動的 lock。
  • 需要原子操作:新增/刪除/取得不會讓集合進入不一致狀態。

不要用 Concurrent-集合的情況:

  • 應用是嚴格單執行緒的。
  • 你需要多個相關操作的「交易性」一致(可能還是需要外部同步或其他機制)。
  • 你需要嚴格的取出順序,但該集合不保證(例如 ConcurrentBag<T> 就不保證順序)。
2
任務
C# SELF, 等級 58, 課堂 0
上鎖
將元素加入 ConcurrentQueue
將元素加入 ConcurrentQueue
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION