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> 就不保證順序)。
GO TO FULL VERSION