マルチスレッド処理において、標準の Dictionary<TKey, TValue> を複数のスレッドから同時に操作(追加・削除)すると、データ競合が発生し例外がスローされるか、内部データの整合性が破壊されます。
.NETには、スレッドセーフなコレクションクラス群 System.Collections.Concurrent が用意されており、その中でも頻繁に使用されるのが ConcurrentDictionary<TKey, TValue> です。
今回は、複数のタスクから同時に共有キャッシュへデータを書き込むシナリオを例に、TryAdd メソッドを用いた安全な実装方法を解説します。
ConcurrentDictionaryによる安全な実装
以下のサンプルコードでは、Webサーバーのセッション管理を想定し、2つの異なるスレッド(タスク)が同時に「ユーザーセッション」をキャッシュに登録しようとする状況をシミュレーションします。
通常の Dictionary では lock が必要になる処理も、ConcurrentDictionary を使えばロック記述なしで安全に実行できます。
サンプルコード
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
public class Program
{
// スレッドセーフな辞書コレクション
// キー: ユーザーID (string), 値: セッション情報 (UserSession)
private static ConcurrentDictionary<string, UserSession> _sessionCache = new();
public static async Task Main()
{
// 登録対象のユーザーリスト
var newUsers = new UserSession[]
{
new UserSession { UserId = "U001", UserName = "Alice", LoginTime = DateTime.Now },
new UserSession { UserId = "U002", UserName = "Bob", LoginTime = DateTime.Now },
new UserSession { UserId = "U003", UserName = "Charlie", LoginTime = DateTime.Now },
new UserSession { UserId = "U004", UserName = "Dave", LoginTime = DateTime.Now },
new UserSession { UserId = "U005", UserName = "Ellen", LoginTime = DateTime.Now },
};
Console.WriteLine("--- セッション登録処理を開始します ---");
// 2つのタスクを並列に起動し、同じユーザーリストの登録を試みる(競合状態の再現)
var task1 = Task.Run(() => RegisterSessions(newUsers, 1));
var task2 = Task.Run(() => RegisterSessions(newUsers, 2));
// 両方のタスクが完了するのを待機
await Task.WhenAll(task1, task2);
Console.WriteLine("\n--- 最終的なキャッシュ内容 ---");
foreach (var entry in _sessionCache)
{
Console.WriteLine($"Key: {entry.Key}, User: {entry.Value.UserName}");
}
Console.WriteLine($"合計登録数: {_sessionCache.Count}");
}
/// <summary>
/// ユーザーリストをキャッシュに登録する処理
/// </summary>
/// <param name="users">ユーザーリスト</param>
/// <param name="workerId">実行タスクID</param>
static void RegisterSessions(IEnumerable<UserSession> users, int workerId)
{
foreach (var user in users)
{
// TryAdd: キーが存在しなければ追加し true を返す。
// 既に存在していれば何もせず false を返す。この操作は原子的(Atomic)に行われる。
if (_sessionCache.TryAdd(user.UserId, user))
{
Console.WriteLine($"[Task-{workerId}] 成功: {user.UserName} を登録しました。");
}
else
{
Console.WriteLine($"[Task-{workerId}] 失敗: {user.UserName} は既に登録済みです。");
}
}
}
}
// データクラス(不変プロパティを持つレコード的なクラス)
public class UserSession
{
public string UserId { get; init; }
public string UserName { get; init; }
public DateTime LoginTime { get; init; }
}
解説と技術的なポイント
1. ConcurrentDictionaryの特徴
System.Collections.Concurrent 名前空間にあるこのクラスは、内部できめ細かいロック(Fine-grained locking)やロックフリーアルゴリズムを使用しており、スレッドセーフでありながら高いパフォーマンスを発揮するように設計されています。開発者が自前で lock ステートメントを書く必要はありません。
2. TryAdd メソッドの挙動
マルチスレッド環境では、「キーの存在確認(ContainsKey)」と「追加(Add)」の間に別のスレッドが割り込む可能性があります。 TryAdd メソッドは、この「確認して追加する」という一連の流れを**原子的操作(アトミックオペレーション)**として実行します。
- 戻り値 true: 辞書にキーが存在せず、追加に成功した場合。
- 戻り値 false: 既に別のスレッドによって同じキーが追加されていた場合。例外は発生しません。
3. その他の便利なメソッド
ConcurrentDictionary には TryAdd 以外にも並列処理に特化した便利なメソッドがあります。
GetOrAdd(key, value): キーがあればその値を返し、なければ指定した値を追加してその値を返します。キャッシュの取得ロジックなどで頻繁に使用されます。AddOrUpdate(key, addValue, updateFunc): キーがなければ追加し、あれば既存の値を基に新しい値を計算して更新します(アップサート処理)。統計情報のカウントアップなどに適しています。
