DEV Community

Aleksei Zagoskin
Aleksei Zagoskin

Posted on • Originally published at zagosk.in on

Lazy thread-safe delegate for ConcurrentDictionary in C#

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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 }

Enter fullscreen mode Exit fullscreen mode

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}");

Enter fullscreen mode Exit fullscreen mode

Output:

Created a new user: User { Id = 1, Age = 99 }
Instance 1: User { Id = 1, Age = 99 }
Instance 2: User { Id = 1, Age = 99 }

Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)