[C#] How to Safely Add and Retrieve Data in Multi-Threaded Environments with ConcurrentDictionary

In multi-threaded processing, simultaneously operating (adding/deleting) on a standard Dictionary<TKey, TValue> from multiple threads causes data races. This leads to exceptions being thrown or internal data corruption.

.NET provides a set of thread-safe collection classes in System.Collections.Concurrent. Among them, ConcurrentDictionary<TKey, TValue> is frequently used.

This article explains how to safely implement data writing using the TryAdd method, simulating a scenario where multiple tasks write to a shared cache simultaneously.

目次

Safe Implementation with ConcurrentDictionary

The sample code below simulates web server session management. Two different threads (tasks) attempt to register “User Sessions” into a cache at the same time.

Operations that would require a lock statement with a normal Dictionary can be executed safely and efficiently without manual locking using ConcurrentDictionary.

Sample Code

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

public class Program
{
    // Thread-safe dictionary collection
    // Key: User ID (string), Value: Session Info (UserSession)
    private static ConcurrentDictionary<string, UserSession> _sessionCache = new();

    public static async Task Main()
    {
        // User list to register
        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("--- Starting Session Registration ---");

        // Start two tasks in parallel trying to register the same user list
        // (Simulating a race condition)
        var task1 = Task.Run(() => RegisterSessions(newUsers, 1));
        var task2 = Task.Run(() => RegisterSessions(newUsers, 2));

        // Wait for both tasks to complete
        await Task.WhenAll(task1, task2);

        Console.WriteLine("\n--- Final Cache Content ---");
        foreach (var entry in _sessionCache)
        {
            Console.WriteLine($"Key: {entry.Key}, User: {entry.Value.UserName}");
        }
        Console.WriteLine($"Total Registered: {_sessionCache.Count}");
    }

    /// <summary>
    /// Process to register user list into cache
    /// </summary>
    /// <param name="users">User list</param>
    /// <param name="workerId">Execution Task ID</param>
    static void RegisterSessions(IEnumerable<UserSession> users, int workerId)
    {
        foreach (var user in users)
        {
            // TryAdd: Adds if key does not exist and returns true.
            // If it already exists, does nothing and returns false.
            // This operation is performed atomically.
            if (_sessionCache.TryAdd(user.UserId, user))
            {
                Console.WriteLine($"[Task-{workerId}] Success: Registered {user.UserName}.");
            }
            else
            {
                Console.WriteLine($"[Task-{workerId}] Failed: {user.UserName} is already registered.");
            }
        }
    }
}

// Data class (Record-like class with immutable properties)
public class UserSession
{
    public string UserId { get; init; }
    public string UserName { get; init; }
    public DateTime LoginTime { get; init; }
}

Explanation and Technical Points

1. Features of ConcurrentDictionary

Located in the System.Collections.Concurrent namespace, this class is designed for high performance while being thread-safe. It uses internal fine-grained locking or lock-free algorithms. Developers do not need to write manual lock statements.

2. Behavior of the TryAdd Method

In a multi-threaded environment, another thread might interrupt the process between checking for a key (ContainsKey) and adding it (Add).

The TryAdd method executes this “check and add” flow as an Atomic Operation.

  • Returns true: If the key did not exist and was successfully added.
  • Returns false: If the key was already added by another thread. No exception is thrown.

3. Other Useful Methods

ConcurrentDictionary offers other methods optimized for parallel processing:

  • GetOrAdd(key, value): Returns the value if the key exists; otherwise, adds the specified value and returns it. This is frequently used for cache retrieval logic.
  • AddOrUpdate(key, addValue, updateFunc): Adds the value if the key is missing, or calculates a new value based on the existing one if the key is present (Upsert). This is suitable for counting statistics or updating timestamps.
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

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

目次