マルチスレッド環境でデータを扱う開発では、データ競合やレースコンディションを防ぐことが不可欠です。C#には標準ライブラリとしてスレッドセーフなコレクションが用意されており、ロックを自前で扱わないでも安全に共有データを操作できます。この記事では「C# スレッドセーフ コレクション 種類」を深堀りし、各コレクションの特徴・使いどころ・性能比較などを最新情報に基づいて分かりやすく解説します。
C# スレッドセーフ コレクション 種類の概要と基本特徴
C#のスレッドセーフなコレクションは、複数のスレッドから同時に操作されても内部で競合が起きないように設計されています。主にSystem.Collections.Concurrent名前空間に属し、順序を守るもの、順序を問わないもの、辞書としてキーと値の管理を行うものなど複数の種類があります。これらはロック/ロックフリー/スピン/細粒度ロックなどの内部メカニズムを用いることで、安全性と性能のバランスをとっています。
標準コレクション(ListやDictionaryなど)はスレッドセーフではなく、同時に変更が入るケースでは明示的なロックが必要です。一方でスレッドセーフコレクションは、安全な追加・削除・更新等の操作を保証し、パフォーマンスと拡張性に優れています。
スレッドセーフコレクションとは何か
スレッドセーフコレクションとは、複数スレッドが同時にアクセスしても内部状態が壊れないコレクションを指します。典型的には読み取り・書き込み・削除・列挙などの操作がアトミックに処理されます。C#ではこれらがSystem.Collections.Concurrent名前空間で提供され、安全性を保ちながら複数スレッドで使うことができます。
ただし注意点もあり、スレッドセーフであることは「複数操作の組み合わせが原子的である」ことまでは保証しません。例えば存在チェック→追加、取得→更新など複数ステップの操作は、それぞれ個別には安全でも、全体としては競合のリスクがあります。
内部実装のメカニズム
これらのコレクションは、以下のような内部メカニズムでスレッドセーフ性を実現しています。ロックフリー(Atomic操作/Interlocked)、スピンロックや軽量シンクロナイゼーション、細粒度ロックなどを用途に応じて使い分けています。これにより高スループットが求められる場面でも性能が維持されやすくなっています。
さらに、各コレクションは並列度(Concurrency Level)や境界付き容量(Bounded Capacity)、プロデューサー/コンシューマーのパターンなどの要件をサポートするものがあります。状況に応じて適切なコレクションを選ぶことが重要です。
選定基準と性能のトレードオフ
スレッドセーフコレクションを選ぶ際には、以下のポイントを考慮すべきです。第一に操作のパターン(追加が頻繁か、読みが多いかなど)。第二に順序が必要かどうか。第三に性能要件(スループット・レイテンシ)がどれだけシビアか。
例えば、読み取りが圧倒的に多い場合は通常の辞書に読み専用ならロック不要ですが、更新が含まれるならConcurrentDictionaryが適切になります。順序を重視するならQueue系、順序が不要ならBag系などが適合します。性能はスレッド数や操作の種類によって変わります。
代表的なスレッドセーフコレクションの種類と使いどころ
ここではC#に標準で備わっている主要なスレッドセーフコレクションの種類を具体的に挙げ、それぞれの特徴と使いどころを最新情報に基づいて整理します。用途に応じて最適なコレクションを選ぶ基準になる内容です。
ConcurrentDictionary
複数スレッドからキーと値を扱う必要がある場合に最も汎用性が高い辞書型コレクションです。TryAdd、TryRemove、AddOrUpdate、GetOrAddなど、原子的操作が可能なメソッドを提供しています。キーの重複を防ぎつつ更新・取得を安全に行いたい場面に適しています。
また、読み込みが頻繁で更新は少ないケースでは高速に動作しますし、多数のスレッドで更新と読み込みが混在するケースでも高いスケーラビリティを発揮します。一方で順序を保証しない点、複数操作の組み合わせ(例:存在チェック+更新など)では注意が必要です。
ConcurrentQueue
FIFO(先入れ先出し)構造で、プロデューサー/コンシューマー型の処理に適しています。Enqueue/TryDequeueを用いて安全に追加と削除が可能です。最新情報によると、専用スレッドがキューに追加を行い別の専用スレッドが削除を行う純粋なプロデューサーコンシューマーのパターンで非常に効率が良くなります。
ただし混合型(複数のスレッドが追加と削除両方を行う場合)や処理時間が短い場合には、標準Queueにロックを使った方がパフォーマンスが良い場合もあります。順序を守りたい処理や作業順序が重要な場面ではこのコレクションが推奨されます。
ConcurrentStack
LIFO(後入れ先出し)構造で、最後に追加した要素を最初に取り出す用途に向いています。Undo操作、履歴管理、深さ優先探索などの場面で使用されます。Push/TryPop/TryPeekなどのメソッドを提供しており、複数スレッドで安全に扱えます。
混合操作(PushとPop両方を複数スレッドで行う)でもスケーラビリティが高く、性能が比較的安定しますが、順序が保証されるわけではなく、列挙中の要素の一貫性が部分的である点を理解して使う必要があります。
ConcurrentBag
順序を保証せず、重複を許すコレクションです。同一スレッドが追加と削除を頻繁に行うシナリオでパフォーマンスが高くなります。Thread-Localな内部構造を持ち、スレッドごとのリストで操作を行うことで競合を減らします。
逆に、順序性が重要なケースや、操作ごとに他スレッドが取る要素を期待するような処理では不適切です。また、アイテムの特定の削除はできないため、「特定の要素を消す」用途があるならDictionaryや他のコレクションを使う必要があります。
BlockingCollection
BlockingCollectionはIProducerConsumerCollectionをラップして、境界値(最大容量)とブロッキング挙動(要素がなければ待機、容量が上限に達すれば待機)を付加できるユーティリティ的コレクションです。生産者/消費者パターンでワークキューを実装する際に非常に便利です。
例えば複数生産者と複数消費者が存在し、リソース制限がある場合などでは、BlockingCollectionを使うことで簡潔かつ安全に処理を制御できます。内部にConcurrentQueueやConcurrentStackを使うことが多く、使いやすさと機能性のバランスが良いです。
比較表で分かる用途と性能
各スレッドセーフコレクションの比較を表形式で整理します。使いどころや性能の違いを直感的に理解できるようにしています。
| コレクション種類 | 順序保証 | 主な用途 | 性能の特徴 |
|---|---|---|---|
| ConcurrentDictionary<TKey, TValue> | なし | キー/値の共有管理、キャッシュ、頻繁な更新と読み込み | 細粒度ロック/アトミック操作で高スケーラビリティ |
| ConcurrentQueue<T> | FIFO順序を保持 | 順序処理、プロデューサー/コンシューマー | ロックフリー実装あり、専用スレッド構成で性能最良 |
| ConcurrentStack<T> | LIFO順序を保持 | 履歴管理、Undo, 深さ優先系処理 | 混合操作で高性能、Push/Pop連続操作で強い |
| ConcurrentBag<T> | 順序保証なし | 順序無視の集計、並列処理での結果収集 | 非常に軽量、スレッドローカルで競合が減少 |
| BlockingCollection<T> | ラップする内部に依存 | 制限付きキュー、待機処理、生産者消費者制御 | 制限・待機機能により制御しやすいが遅延可能性あり |
C# スレッドセーフ コレクション 種類を使う際の注意点とベストプラクティス
スレッドセーフコレクションは便利ですが、用途を誤ると性能低下やバグの原因になります。この章では使う際の注意点と、開発で失敗しないためのベストプラクティスを紹介します。
列挙時の一貫性
上記コレクションでは列挙(foreachなど)を行っている間、他スレッドがコレクションを変更しても例外は発生しません。ただし列挙結果に古い要素や追加された新しい要素が含まれることがあります。順序や完全性を期待する処理では、別途スナップショットを作成するか整合性を保証する設計が必要です。
たとえば、ConcurrentDictionaryのKeysやValuesなどを取得する際、途中でAddやRemoveが発生すると表示内容に齟齬が出る可能性があります。完全なスナップショットが必要な場合はToArrayメソッド等を活用します。
複数操作の原子性を確保する
AddOrUpdateのようなメソッドは原子的ですが、存在チェックと更新を別々に行うと安全性は失われます。操作をまとめて行いたい場合は、専用メソッドやロックを併用することを検討してください。
たとえばキーの存在チェックをしてから値を更新するケースでは、ConcurrentDictionaryのGetOrAddやAddOrUpdateを活用し、別スレッドでの競合による不整合を防ぎます。複数ステップで処理するならロック付き領域で一連操作を囲むことが有効です。
パフォーマンスモニタリングと最適化
スレッド数・コア数・負荷のかかり方によって性能が大きく変わるため、実際の環境でプロファイルを取ることが重要です。単純な操作では通常コレクション+ロックより高速な場合もあれば、逆のケースもあります。
また境界付き容量を持つBlockingCollectionや、スレッドローカルを活かすBag系では、競合を最小化する設計を意識することで性能が改善します。CPUキャッシュの局所性やデータアクセスパターンも影響するため、使い方次第でスレッドセーフコレクションの真価が発揮されます。
C# スレッドセーフ コレクション 種類ごとの実装例とコードスニペット
実際にそれぞれのスレッドセーフコレクションを使うコード例を見て、どういう操作が可能かを理解してください。用途に応じて適切なメソッドや構成を選ぶ参考になります。
ConcurrentDictionaryの使用例
以下はキーと値を同時に更新・取得する典型的な例です。複数スレッドからTryAdd、AddOrUpdate、GetOrAddを使うことで安全に値を扱います。ロングランのキャッシュや設定マップでよく用いられます。
コード例:
var dict = new System.Collections.Concurrent.ConcurrentDictionary<int, string>();
Parallel.For(0, 10, i => { dict.AddOrUpdate(i, "初期値", (key, old) => old + "更新"); });
foreach(var kv in dict) { var v = kv.Value; }
ConcurrentQueueを使った生産者/消費者パターン
生産者スレッドがEnqueueでアイテムを追加し、消費者スレッドがTryDequeueで取り出す典型例です。FIFOの順序とスレッド安全性を両立させたい処理で重宝します。
コード例:
var queue = new System.Collections.Concurrent.ConcurrentQueue<string>();
Task.Run(() => { for(int i =0; i<100;i++) queue.Enqueue("仕事" + i); });
Task.Run(() => { string item; while(!queue.IsEmpty) if(queue.TryDequeue(out item)) Process(item); });
BlockingCollectionでと限界付きワークキューを作る
処理量やキューの大きさを制限したい場面ではBlockingCollectionを使います。既定では内部にConcurrentQueueを使うことが多く、Add/Take/CompleteAddingなどで終了検知も行えます。
コード例:
var bc = new System.Collections.Concurrent.BlockingCollection<int>(boundedCapacity: 50);
// 生産者側
Task.Run(() => { for(int i =0;i<1000;i++) bc.Add(i); bc.CompleteAdding(); });
// 消費者側
foreach(var item in bc.GetConsumingEnumerable()) Process(item);
まとめ
C#には安全に並列処理を行うためのスレッドセーフなコレクションが標準で豊富に用意されています。種類としては、辞書型のConcurrentDictionary、順序を守るQueueやStack系、順序を気にしないBag型、さらに境界や待機機構を備えたBlockingCollectionなどがあります。
これらを正しく選択・使用することで、レースコンディションやデッドロックを避け、高速かつ安全なアプリケーションが構築できます。性能面では操作パターンやスレッド数・コア数に大きく依存するため、実際のユースケースでベンチマークを取ることが最も重要です。
コメント