1.「Producer‑Consumer」 + Concurrent
以前、ConcurrentQueue を話した際に Producer‑Consumer パターンに触れました。ここでは複数のプロデューサーとコンシューマーがいる場合や、正しい終了シグナリングについて詳しく見ていきます。
ConcurrentQueue(や他の Concurrent コレクション)の最大の利点は、自分でスレッドセーフを扱ってくれることです。Enqueue や TryDequeue を lock で囲む必要はありません — 複数のスレッドが共有キューを安全に使えます。
例:複数のプロデューサーと複数のコンシューマー
いくつかのワーカースレッドがタスクを生成し、別のスレッド群がそれを処理します。
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 = $"ザダーチャ_{name}_{i}";
taskQueue.Enqueue(task);
Console.WriteLine($"[P:{name}] 追加した: {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}] 処理した: {task}");
Thread.Sleep(20);
}
else
{
Thread.Sleep(50); // キューが空なら待つ
}
}
Console.WriteLine($"[C:{name}] 作業を終了しました。");
}
// 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> は Producer‑Consumer に適していますが、空のキューでの待ちや終了のシグナリングは手動でやる必要があります。.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] 追加した: {i}");
Thread.Sleep(50);
}
numbers.CompleteAdding(); // プロデューサーが終了したことを通知
Console.WriteLine("[P] プロデューサーは追加を完了しました。");
}
void ConsumerBC()
{
// GetConsumingEnumerable は要素があるか CompleteAdding が呼ばれるまでブロックする
foreach (var item in numbers.GetConsumingEnumerable())
{
Console.WriteLine($"[C] 処理した: {item}");
Thread.Sleep(100);
}
Console.WriteLine("[C] コンシューマーは作業を終了しました。");
}
// 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($"キューは空ですか? {q.IsEmpty}"); // True
q.Enqueue("A");
q.Enqueue("B");
Console.WriteLine($"キューの要素数: {q.Count}"); // 2
Console.WriteLine($"キューは空ですか? {q.IsEmpty}"); // False
q.TryDequeue(out var itemA);
Console.WriteLine($"キューの要素数: {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($"配列の要素: {string.Join(", ", items)}");
// コレクション自体は変わらない
Console.WriteLine($"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($"Bag の要素数: {bag.Count}"); // 2
bag.Clear(); // コレクションをクリア
Console.WriteLine($"クリア後の Bag の要素数: {bag.Count}"); // 0
Console.WriteLine($"Bag は空ですか? {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($"現在の Count: {snapshotQueue.Count}"); // 50、80、120... のどれか
Thread.Sleep(100);
Console.WriteLine($"現在の Count(再度): {snapshotQueue.Count}"); // 別の値になる
int[] currentItems = snapshotQueue.ToArray();
Console.WriteLine($"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("イテレーションを開始します...");
int count = 0;
foreach (var item in iterQueue) // イテレーション
{
Console.Write($"{item} ");
count++;
Thread.Sleep(30); // 処理をシミュレートして Modifier にコレクションを変更させる
}
Console.WriteLine($"\nイテレーション終了。読み取った要素数: {count}。");
Console.WriteLine($"現在のキューの要素数: {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()、イテレーションに関する重要な注意点を見てきました。
GO TO FULL VERSION