【C#】ConcurrentDictionaryでマルチスレッド環境でも安全にデータを追加・取得する方法

マルチスレッド処理において、標準の 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): キーがなければ追加し、あれば既存の値を基に新しい値を計算して更新します(アップサート処理)。統計情報のカウントアップなどに適しています。
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

私が勉強したこと、実践したこと、してることを書いているブログです。
主に資産運用について書いていたのですが、
最近はプログラミングに興味があるので、今はそればっかりです。

目次