CodeGym /課程 /C# SELF /進階的模式與 Concurrent 集...

進階的模式與 Concurrent 集合的特性

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

1. 「生產者‑消費者」 + Concurrent

我們之前在討論 ConcurrentQueue 時已經提過生產者‑消費者模式。現在來更細緻地看,有多個生產者與消費者時,以及如何正確地發出結束訊號。

ConcurrentQueue(和其他 Concurrent 集合)的最大優勢是它自己處理線程安全。你不用把 Enqueue 或 TryDequeue 用 lock 包起來——多個執行緒可以安全地透過共同的佇列互動。

範例:多個生產者與多個消費者

多個 worker 執行緒會產生任務,另外幾個執行緒處理這些任務。

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<string> taskQueue = new ConcurrentQueue<string>();
CancellationTokenSource cts = new CancellationTokenSource(); // 用來取消消費者的工作

// 生產者方法
void Producer(string name, int count)
{
    for (int i = 0; i < count; i++)
    {
        string task = $"Zadacha_{name}_{i}";
        taskQueue.Enqueue(task);
        Console.WriteLine($"[P:{name}] Dobavil: {task}");
        Thread.Sleep(10); 
    }
}

// 消費者方法
void Consumer(string name)
{
    while (!cts.Token.IsCancellationRequested || taskQueue.Count > 0)
    {
        if (taskQueue.TryDequeue(out string task))
        {
            Console.WriteLine($"[C:{name}] Obrabotal: {task}");
            Thread.Sleep(20); 
        }
        else
        {
            Thread.Sleep(50); // 等待,如果佇列是空的
        }
    }
    Console.WriteLine($"[C:{name}] Zavershil rabotu.");
}

// 在 Main 中啟動範例:
Task.Run(() => Producer("A", 10)); // 生產者 A
Task.Run(() => Producer("B", 10)); // 生產者 B
Task.Run(() => Consumer("1"));    // 消費者 1
Task.Run(() => Consumer("2"));    // 消費者 2

Thread.Sleep(1000); // 給足夠時間運作
cts.Cancel();       // 向消費者發送結束工作訊號
Thread.Sleep(500); // 給消費者時間處理剩餘項並結束

在這個例子裡,多個生產者與消費者同時操作同一個 ConcurrentQueue,不會發生資料競爭:Enqueue 和 TryDequeue 都是原子操作。

結束訊號的重要性(CancellationTokenSource)

我們用 CancellationTokenSource(簡稱 cts)來告訴消費者應該結束工作。這對 Producer‑Consumer 模式很關鍵:

  • 生產者已經完成工作。 當不再有新增項目時,消費者不應該無限期地等待空佇列。
  • 應用程式要關閉。 需要能正確地停止消費者。

CancellationTokenSource 與 CancellationToken 提供了標準化的機制:消費者週期性檢查 IsCancellationRequested,必要時可呼叫 ThrowIfCancellationRequested()。

2. BlockingCollection<T>

雖然 ConcurrentQueue<T> 很適合做生產者‑消費者,但它需要你自己在空佇列時等待,以及手動發出結束信號。若要更方便的實作,.NET 提供了 BlockingCollection<T>——它不是獨立的集合,而是包在任何 IProducerConsumerCollection<T>(例如 ConcurrentQueue)之上。

BlockingCollection 的優點:

  • 阻塞操作。 Add()/Take() 會在集合滿或空時阻塞執行緒。不需要手動檢查 IsEmpty。
  • 限制大小。 可以設置容量 (Capacity)。當達到限制時 Add() 會被阻塞——對於記憶體控制很有用。
  • 方便的完成處理。 CompleteAdding() 用來表示不會再新增,GetConsumingEnumerable() 允許消費者在整個生產完成前持續處理。

範例:用 BlockingCollection<T> 的 Producer‑Consumer

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

// 預設 BlockingCollection 使用 ConcurrentQueue
BlockingCollection<int> numbers = new BlockingCollection<int>(capacity: 10); // 容量 10

void ProducerBC(int count)
{
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i); // 若佇列滿會阻塞
        Console.WriteLine($"[P] Dobavil: {i}");
        Thread.Sleep(50);
    }
    numbers.CompleteAdding(); // 通知不再新增
    Console.WriteLine("[P] Proizvoditel zavershil dobavlenie.");
}

void ConsumerBC()
{
    // GetConsumingEnumerable 會阻塞,只要還有元素或尚未呼叫 CompleteAdding
    foreach (var item in numbers.GetConsumingEnumerable())
    {
        Console.WriteLine($"[C] Obrabotal: {item}");
        Thread.Sleep(100);
    }
    Console.WriteLine("[C] Potrebitel zavershil rabotu.");
}

// 在 Main 中啟動範例:
Task producerTask = Task.Run(() => ProducerBC(15)); // 15 個元素,容量 10
Task consumerTask = Task.Run(ConsumerBC);
Task.WaitAll(producerTask, consumerTask); // 等待完成

注意:因為有 GetConsumingEnumerable(),消費者端的程式碼變得更乾淨。如果你需要阻塞操作或容量限制,BlockingCollection 是個好工具。

3. 其他 Concurrent 集合的方法與屬性

IsEmpty, Count

  • IsEmpty (bool):集合是否為空。
  • Count (int):當前元素數量。

範例:使用 IsEmpty 和 Count

using System.Collections.Concurrent;

ConcurrentQueue<string> q = new ConcurrentQueue<string>();
Console.WriteLine($"Ochered pustaya? {q.IsEmpty}"); // True

q.Enqueue("A");
q.Enqueue("B");
Console.WriteLine($"Elementov v ocheredi: {q.Count}"); // 2
Console.WriteLine($"Ochered pustaya? {q.IsEmpty}"); // False

q.TryDequeue(out var itemA);
Console.WriteLine($"Elementov v ocheredi: {q.Count}"); // 1

轉為陣列(ToArray())

所有 Concurrent 集合都有 ToArray() 方法,會回傳元素的一個瞬間快照。

範例:使用 ToArray()

using System.Collections.Concurrent;

ConcurrentStack<int> s = new ConcurrentStack<int>();
s.Push(10);
s.Push(20);
s.Push(30);

int[] items = s.ToArray(); // 會建立一個新陣列: [30, 20, 10] (對於堆疊 LIFO)
Console.WriteLine($"Elementy v masive: {string.Join(", ", items)}");

// 集合本身不會改變
Console.WriteLine($"Elementov v steke posle ToArray: {s.Count}"); // 3

清空集合

在 .NET 6+ 許多 Concurrent 集合新增了 Clear() 方法來移除所有元素。

範例:清空集合

using System.Collections.Concurrent;

ConcurrentBag<string> bag = new ConcurrentBag<string>();
bag.Add("Alpha");
bag.Add("Beta");
Console.WriteLine($"Elementov v Bag: {bag.Count}"); // 2

bag.Clear(); // 清空集合
Console.WriteLine($"Elementov v Bag posle ochistki: {bag.Count}"); // 0
Console.WriteLine($"Bag pust? {bag.IsEmpty}"); // True

4. Concurrent 集合行為的注意事項

要記得「瞬間快照」的概念。像是 Count 這類屬性和 ToArray() 的結果反映的是某一時刻的狀態。在並行改變的情況下,這個值可能在取得後立刻過時。

範例:Count 與 ToArray() —「瞬間快照」

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<int> snapshotQueue = new ConcurrentQueue<int>();

void AddItemsContinuously()
{
    for (int i = 0; i < 1000; i++)
    {
        snapshotQueue.Enqueue(i);
        Thread.Sleep(1); 
    }
}

// 在 Main 啟動範例:
Task.Run(AddItemsContinuously); // 一個不斷加入項目的執行緒

Thread.Sleep(100); // 給一點時間加入
Console.WriteLine($"Tekushchiy Count: {snapshotQueue.Count}"); // 可能是 50、80、120...
Thread.Sleep(100);
Console.WriteLine($"Tekushchiy Count snova: {snapshotQueue.Count}"); // 會是另一個值
int[] currentItems = snapshotQueue.ToArray();
Console.WriteLine($"Kolichestvo elementov v ToArray(): {currentItems.Length}"); // 可能和前面的 Count 不同

不要把 Count 當成在活躍修改期間的絕對保證。

5. 在 Concurrent 集合上迭代的細節

單一操作(像是 Add、TryTake、TryPop、GetOrAdd 等)是線程安全的。但如果你用 foreach 去迭代一個可能被其他執行緒修改的集合,並不保證你會看到所有元素或只看到某一時刻的元素——可能會遺漏或出現意外行為。

範例:在修改期間迭代

using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

ConcurrentQueue<int> iterQueue = new ConcurrentQueue<int>();

// 加入初始元素
for (int i = 0; i < 10; i++) iterQueue.Enqueue(i);

// 修改者執行緒
void Modifier()
{
    for (int i = 10; i < 20; i++)
    {
        iterQueue.Enqueue(i); // 新增元素
        Thread.Sleep(50);
    }
}

// 迭代者執行緒
void Iterator()
{
    Console.WriteLine("Nachinaem iteraciyu...");
    int count = 0;
    foreach (var item in iterQueue) // 迭代
    {
        Console.Write($"{item} ");
        count++;
        Thread.Sleep(30); // 模擬工作,讓修改者有機會改變集合
    }
    Console.WriteLine($"\nIteraciya zavershena. Prochteno {count} elementov.");
    Console.WriteLine($"Tekuschee kolichestvo v ocheredi: {iterQueue.Count}");
}

// 在 Main 中啟動範例:
Task.Run(Modifier);
Task.Run(Iterator);
Thread.Sleep(1500); // 給足夠時間運作

規則:如果你需要一個固定的元素集(例如用於報表),先用 ToArray() 取得快照,然後再對那個陣列做迭代:

// 如果集合可能改變,這是正確的迭代方式
int[] snapshot = iterQueue.ToArray();
foreach (var item in snapshot)
{
    // 現在你是在一個不可變的陣列快照上迭代
}

這就是我們對 Concurrent 集合進階模式與特性的介紹:看了多角色的 Producer‑Consumer 與 BlockingCollection,還有使用 Count、ToArray() 與迭代時應注意的重點。

2
任務
C# SELF, 等級 58, 課堂 3
上鎖
使用 `BlockingCollection` 並限制容量的範例
使用 `BlockingCollection` 並限制容量的範例
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION