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.
