Concurrent Collections in C# are a set of collections designed for synchronized multi-thread access. We can find concurrent collections under the System.Collections.Concurrent namespace. Nowadays, where it is getting increasingly important and accessible to work in a multi-threaded environment, concurrent collections are essential. .NET provides a set of concurrent collections. In this article, we will try to understand what concurrent collections are, the collections that have thread-safe variants, and the constraints of concurrent collections.

To download the source code for this article, you can visit our GitHub repository.

Let’s dig in.

What Are Concurrent Collections?

Concurrent Collections are thread-safe collection classes that we should use in a scenario where our code involves simultaneous access to a collection. Unlike collections, concurrent collections have a reliable behavior in a multi-threaded environment(with concurrent access to the collection).

Before we start looking at the different types of concurrent collections available in C# please refer to our previous articles: .NET Collections, How to Execute Multiple Tasks Asynchronously in C#, and How to Run Code in a New Thread in C# to refresh your memory about collections and concurrency.

The Problem of Generic Collections in Multi-Threading

All generic collections in .NET (such as Stack, Queue, List, Dictionary, and more) are not thread-safe. These collections address different data structure problems very well. However, they are all bound to a single thread. If we start using these collections in a multi-threaded environment, they produce inconsistent results. To better understand the thread safety problem of generic collections, we will use Dictionary<TKey,TValue>  as an example.

Thread Safety Problem Example

Let’s create RoleManagerDictionary class that contains a member of type Dictionary<TKey,TValue>:

public class RoleManagerDictionary
{
    private readonly Dictionary<string, User> _record;

    public RoleManagerDictionary()
    {
        _record = new();
    }

    public async Task<bool> TryAssign(string role, int userId)
    {
        var user = await UserProvider.GetUser(userId);

        return _record.TryAdd(role, user);
    }

    public IDictionary<string, User> GetAll()
    {
        return _record;
    }

    public void PrintAll()
    {
        Console.WriteLine($"No. of Records: {_record.Count()}");
        foreach (var (role, user) in _record)
        {
            Console.WriteLine($"Role: {role}, User: {user}");
        }
    }
}

The RoleManagerDictionary class contains one private member of type Dictionary<TKey,TValue> and a few methods. The TryAssign method is our area of interest.

Let’s inspect the program flow.

First, the method receives two arguments role and userId. In the next line, we use userId to get user information (from the UserProvider local class). Once user information is retrieved, we try to add the provided role and user information to the dictionary and return true. However, if the role already exists in the _record it simply skips the add operation and returns false. The GetAll method returns the value of the private member, namely _record. Finally, the PrintAll method prints the values of _record to the console.

Let’s consume RoleManagerDictionary:

static async Task RunRoleManagerDictionaryExampleSync()
{
    var roleManager = new RoleManagerDictionary();
    await roleManager.TryAssign("Admin", 1);
    await roleManager.TryAssign("Admin", 2);
    await roleManager.TryAssign("Admin", 3);

    roleManager.PrintAll();
}

All the tasks inside RunRoleManagerDictionaryExampleSync are running one after another, and there is no parallel operation taking place. Because our code doesn’t involve any concurrent access to _record the TryAssign method behaves as we expect it and when we invoke PrintAll we get one admin user.

Let’s consume RoleManagerDictionary in parallel:

static async Task RunRoleManagerDictionaryExampleConcurrent()
{
    var roleManager = new RoleManagerDictionary();
    var task1 = roleManager.TryAssign("Admin", 1);
    var task2 = roleManager.TryAssign("Admin", 2);
    var task3 = roleManager.TryAssign("Admin", 3);

    await Task.WhenAll(task1, task2, task3);
    roleManager.PrintAll();
}

Inside RunRoleManagerDictionaryExample we create three tasks, and all of them have the same role. However, now because we are accessing the generic collection _record concurrently we start to get unpredictable outputs.

Let’s look at some of the outputs :

Dictionary-Muti-threading-Output

The outputs are far from our expectations and demonstrate the shortcomings of collections. Depending on the number of times we run the program, we will get different results: sometimes we get an exception, sometimes we get entries with the same dictionary key, and sometimes we get empty entries. All of this inconsistency is because Dictionary<TKey,TValue>  are not thread-safe. This is the right place to use the concurrent variant of Dictionary<TKey,TValue>, which is ConcurrentDictionary<TKey,TValue>.

Solving The Example Thread Safety Problem

Dictionary<TKey,TValue> in .NET have a concurrent variant called ConcurrentDictionary<TKey,TValue>. It solves the same problem as Dictionary<TKey,TValue>. The main difference is thread safety. ConcurrentDictionary<TKey,TValue> are thread-safe, therefore they don’t yield any error due to concurrent access.

Let’s create RoleManagerConcurrentDictionary class that is similar to RoleManagerDictionary from our previous example. But this time, we will change the type of _record from Dictionary<TKey,TValue> to ConcurrentDictionary<TKey,TValue>:

public class RoleManagerConcurrentDictionary
{
    private readonly ConcurrentDictionary<string, User> _record;
    ...
}

By changing the type of _record from Dictionary<TKey,TValue> to ConcurrentDictionary<TKey,TValue> we have extended our code to be thread-safe. Now we will consume RoleManagerConcurrentDictionary concurrently:

static async Task RunRoleManagerConcurrentDictionaryExampleConcurrent()
{
    var concurrentRoleManager = new RoleManagerConcurrentDictionary();
    var task1 = concurrentRoleManager.TryAssign("Admin", 1);
    var task2 = concurrentRoleManager.TryAssign("Admin", 2);
    var task3 = concurrentRoleManager.TryAssign("Admin", 3);

    await Task.WhenAll(task1, task2, task3);
    concurrentRoleManager.PrintAll();
}

Now regardless of the number of times we invoke the RunRoleManagerConcurrentDictionaryExampleConcurrent method, we will have only one admin user:

Concurrent Dictionary Sample Output

What Are The Available Concurrent Collections?

To diagnose the issue with collections and concurrent access, .NET provides a concurrent variant of these collections. The available concurrent collections include ConcurrentDictionary<T>, ConcurrentQueue<T>, ConcurrentStack<T>, ConcurrentBag<T>, and more that are beyond the scope of the article. It is important to note that not all collection classes have concurrent variants.

Concurrent Queue

Similar to Queue<T> the ConcurrentQueue<T> helps us to implement the FIFO data structure. The main difference is,  ConcurrentQueue<T> is thread-safe. And it works reliably in multi-thread code. You can read more about a queue and the FIFO data structure in our previous article, Queue in C#. In this section, we will look at how we can add, remove and get the state of ConcurrentQueue<T>.

Let’s start by creating UserLineConcurrentQueue class:

public class UserLineConcurrentQueue
{
    private readonly ConcurrentQueue<User> _users;

    public UserLineConcurrentQueue()
    {
        _users = new();
    }
}

We will use the UserLineConcurrentQueue class to add users to a line, and serve the users inline. Once users are served they will be removed from the queue.

To add users to a line:

...
public async Task AddUser(int userId)
{
    var user = await UserProvider.GetUser(userId);
    _users.Enqueue(user);
}
...

ConcurrentQueue<T> contains Enqueue method to add elements to the queue.

Next, let’s add a method to serve users:

...
public bool TryServeUser(out User? user)
{
    var isRemoved = _users.TryDequeue(out user);
    if (isRemoved)
    {
        Console.WriteLine($"SERVED: {user}");
    }

    return isRemoved;
}
...

The TryServeUser method first tries to get the first user in the queue, and then we serve the user. For simplicity, we are not doing anything other than logging the user’s information.

It is important to note that unlike Queue<T>, ConcurrentQueue<T> doesn’t contain Dequeue method. Instead, it contains TryDequeue to get the first entry in the queue. Similarly, ConcurrentQueue<T> have TryPeek method but not Peek. This helps us to consider the possibility that the state of ConcurrentQueue<T> could have been updated by another thread accessing the ConcurrentQueue<T> at the same time.

Let’s add one more method to our example class to see who is first in line:

...
public User? LookWhoIsFirst()
{
    if (_users.TryPeek(out User? user))
    {
        return user;
    }

    return default;
}
...

The TryPeek method retrieves the first element in the queue without removing it from the queue.

Concurrent Stack

The ConcurrentStack<T> is a thread-safe variant of Stack<T>. It helps us implement a LIFO data structure in a multi-threaded environment easily. To learn more about LIFO data structure and stack, please refer to our previous article, Stack in C#.

To understand the usage of ConcurrentStack<T> let’s use a printer as an example, and we will add colored paper to a printer. The printer will then use the paper on top:

public class PrinterColorPaperConcurrentStack
{
    private readonly ConcurrentStack<string> _paperColor;

    public PrinterColorPaperConcurrentStack()
    {
        _paperColor = new ConcurrentStack<string>();
    }

    public void Add(string color)
    {
        _paperColor.Push(color);
    }

    public bool TryUse(out string? color)
    {
        return _paperColor.TryPop(out color);
    }

    public bool TryGetTop(out string? color)
    {
        return _paperColor.TryPeek(out color);
    }    
}

First, we start by defining and initializing the ConcurrentStack<string> instance. The Add method helps us to push the paper color to the paper stack. When the printer uses paper on top, that means we should remove it from the concurrent stack, the TryUse method helps us achieve exactly that. Last but not least, the TryGetTop allows us to know the color of the paper on top without removing it from the stack.

In ConcurrentStack<string> there is neither Pop nor Peek methods, instead we have TryPop and TryPeek.

Concurrent Bag

Unlike stack and queue, ConcurrentBag<T> store entries without any form of order. Moreover, it is possible to store duplicate entries and null values in ConcurrentBag<T>.

ConcurrentBag<T> are optimized for situations where the same thread will both produce and consume the data from the bag.

Let’s look at how we can use ConcurrentBag<T>:

public class StorageConcurrentBag
{
    private readonly ConcurrentBag<string> _files;

    public StorageConcurrentBag()
    {
        _files = new();
    }

    public void AddNew(string fileName)
    {
        _files.Add(fileName);
    }

    public bool TryRemove(out string? removedFileName)
    {
        return _files.TryTake(out removedFileName);
    }

    public bool TryGet(out string? fileName)
    {
        return _files.TryPeek(out fileName);
    }
}

The StorageConcurrentBag is a simple class we are using to manage files. We don’t care about the order of the files therefore, we will use the _files private member of type ConcurrentBag<string> to store the files.

ConcurrentBag<T contain’s Add method to add new entries, we are leveraging this method to add new files inside AddNew method.

Next, inside TryRemove we are calling the TryTake method to remove a file. The TryTake method retrieves one item from the bag and removes it from the bag. It returns true if there is an item otherwise false.

Finally, inside TryGet we are calling the TryPeek mathod. The TryPeek method retrieves an item from the bag but doesn’t remove it. It returns true if there is an item otherwise false.

Benefits of Concurrent Collections

In the previous example, we saw the problem of using generic collections in a multi-threaded environment. We can resolve the issue by simply changing the type from Dictionary<TKey,TValue> to ConcurrentDictionary<TKey,TValue>. Obviously, this is an oversimplified example, and real-life scenarios are likely to require more work. However, in both cases, concurrent collections make our life as a developer easier by providing us with thread-safe collections that don’t need us to write any additional code for thread synchronization and data safety across multiple threads.

Concurrent Collection Caveats

Concurrent collections make it a breeze to use collections in a multi-threaded environment. However, when we are using concurrent collections, it is important to take into account a few things:

  • In a single-threaded environment, there is a downside to using concurrent collections. Compared to generic collections, concurrent collections carry out a lot of additional work under the hood to ensure data is safe and synchronized across threads. As a result, this can impact the single-threaded application’s performance.
  • Our code should have the least possible amount of access to the concurrent collection state because getting the state of the concurrent collection is expensive as it requires synchronization among all the threads accessing the collection.
  • When there is a simultaneous update on the state of concurrent collections by multiple threads, concurrent collections don’t guarantee which change is going to take precedence.
  • Access to concurrent collections from extension methods or explicit interfaces is not guaranteed to be thread-safe.

Conclusion

In this article, we have learned what concurrent collections are, the problem of using generic collections in a multi-threaded environment,  how concurrent collections help us address thread-safety problems, concurrent collections that are available in .NET with usage examples on some of the available collections, benefits of concurrent collections, and finally the caveats involved when using collections.