Lazy thread-safe delegate for ConcurrentDictionary in C#
Aleksei Zagoskin
Posted on January 26, 2023
The ConcurrentDictionary<TKey, TValue>
type is commonly used as a thread-safe alternative to the Dictionary<TKey, TValue>
type. While the methods of this type are atomic, it's important to keep in mind that when the AddOrUpdate
and GetOrAdd
methods are called from multiple threads, the delegate (valueFactory
parameter) may be executed more than once.
This is what official documentation says about it:
Let's take a look at this example:
using System.Collections.Concurrent;
var usersById = new ConcurrentDictionary<int, User>();
// Get or create a user with ID = 1 two times from different threads
var getUserTask1 = Task.Run(() => usersById.GetOrAdd(1, User.GenerateUserWithId));
var getUserTask2 = Task.Run(() => usersById.GetOrAdd(1, User.GenerateUserWithId));
await Task.WhenAll(getUserTask1, getUserTask2);
Console.WriteLine($"User 1: {getUserTask1.Result}");
Console.WriteLine($"User 2: {getUserTask2.Result}");
public record User(int Id, int Age)
{
private static readonly Random _randomizer = Random.Shared;
public static User GenerateUserWithId(int id)
{
Thread.Sleep(TimeSpan.FromSeconds(1));
// Generate and return a new user with a given ID and random age.
var user = new User(id, _randomizer.Next(1, 100));
Console.WriteLine($"Created a new user: {user}");
return user;
}
}
Output:
Created a new user: User { Id = 1, Age = 91 }
Created a new user: User { Id = 1, Age = 30 }
Instance 1: User { Id = 1, Age = 91 }
Instance 2: User { Id = 1, Age = 91 }
As we can see, two User
objects were created, but only one of them was added to the ConcurrentDictionary
. While it may be acceptable for the delegate to be called multiple times in some cases, there may be situations where it is necessary to ensure that the delegate is only called once.
Solution
using System.Collections.Concurrent;
//! Replace User with Lazy<User>
var usersById = new ConcurrentDictionary<int, Lazy<User>>();
var getUserTask1 = Task.Run(() => usersById.GetOrAdd(1, x => new Lazy<User>(() => User.GetUserById(x))).Value);
var getUserTask2 = Task.Run(() => usersById.GetOrAdd(1, x => new Lazy<User>(() => User.GetUserById(x))).Value);
await Task.WhenAll(getUserTask1, getUserTask2);
Console.WriteLine($"Instance 1: {getUserTask1.Result}");
Console.WriteLine($"Instance 2: {getUserTask2.Result}");
Output:
Created a new user: User { Id = 1, Age = 99 }
Instance 1: User { Id = 1, Age = 99 }
Instance 2: User { Id = 1, Age = 99 }
We changed two things: (1) the type of the value of our dictionary (User
became Lazy<User>
) and (2) made the factory method lazy. So now instead of running the valueFactory
delegate twice (and generating two users with the same ID), two Lazy
instances are being created, and just like in the first example, only one of them becomes a member of the ConcurrentDictionary
. This Lazy
object will be returned by both calls to the GetOrAdd
method made from two parallel threads, which, in turn, ensures that the delegate that generates our users will be called only once.
Thank you for reading
Posted on January 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.