DEV Community

Cover image for C# Concurrent Collections: A Practical Guide
StepOne
StepOne

Posted on

C# Concurrent Collections: A Practical Guide

Choosing a thread-safe collection is not simply a matter of replacing Dictionary<TKey, TValue> with ConcurrentDictionary<TKey, TValue>. The right choice depends on the operations you need to make atomic, the ratio of reads to writes, whether consumers must block, and whether the data can become immutable after construction.

This guide explains how ordinary generic collections fail under concurrent access, then compares the main types in System.Collections.Concurrent with immutable and frozen collections. The goal is to give you enough mechanical detail to defend the choice in code review—not just a catalog of APIs.

C# Concurrent Collections: Quick Selection Guide

Requirement Start with
Concurrent FIFO processing ConcurrentQueue<T>
Concurrent LIFO processing ConcurrentStack<T>
Concurrent key-based reads and updates ConcurrentDictionary<TKey, TValue>
Unordered items produced and consumed by the same workers ConcurrentBag<T>
Blocking or bounded producer-consumer flow BlockingCollection<T>
Snapshot-style updates System.Collections.Immutable
Build-once, read-many lookup data System.Collections.Frozen

The table is a starting point, not a substitute for checking which compound operations must be atomic. The sections below explain the mechanics and tradeoffs behind each choice.

Why C# Needs Thread-Safe Collections

C# 1.0 introduced System.Collections, which includes ArrayList, Hashtable, Stack, Queue, and other collection classes. The problem is that these collections are not type-safe. They store elements as object, which can lead to type-mismatch exceptions and to performance costs from boxing and unboxing.

C# 2.0 then introduced the System.Collections.Generic namespace and collection classes such as List<T>, Dictionary<TKey, TValue>, Stack<T>, and Queue<T>. These collections are type-safe, but not thread-safe. Type safety means that when you create a generic collection, you specify the type it stores as a generic type parameter. Reading an element then returns its actual type, so no boxing or unboxing is required.

Generic collections do not guarantee thread safety, however. Developers must provide it themselves. Suppose several threads share a dictionary. Concurrency problems can arise when two or more threads access its elements at the same time—for example, when they add or remove items concurrently.

Why Generic Collections Are Not Thread-Safe

The following example creates one Dictionary<int, string> instance with integer keys and string values. It then defines Method1 and Method2; both methods try to add entries to dictionary. Two threads, t1 and t2, execute these methods concurrently after Start is called.

Dictionary<int, string> dictionary = [];
var t1 = new Thread(Method1);
var t2 = new Thread(Method2);
t1.Start();
t2.Start();

void Method1()
{
    for (var i = 0; i < 10; i++)
    {
        dictionary.Add(i, "Added By Method1 " + i);
        Thread.Sleep(100);
    }
}

void Method2()
{
    for (var i = 0; i < 10; i++)
    {
        dictionary.Add(i, "Added By Method2 " + i);
        Thread.Sleep(100);
    }
}
Enter fullscreen mode Exit fullscreen mode

Running this code will most likely produce a System.ArgumentException:

Unhandled exception. System.ArgumentException: An item with the same key has already been added. Key: 0
   at System.Collections.Generic.Dictionary`2.TryInsert(TKey key, TValue value, InsertionBehavior behavior)
   at System.Collections.Generic.Dictionary`2.Add(TKey key, TValue value)
   at Program.<>c__DisplayClass0_0.<<Main>$>g__Method2|1() in /Users/stepanminin/RiderProjects/ConsoleApp1/ConsoleApp1/Program.cs:line 22
   at System.Threading.Thread.StartCallback()
Enter fullscreen mode Exit fullscreen mode

Dictionary keys must be unique, and one method duplicated a key inserted by the other. The error occurs because a generic dictionary does not provide thread safety by default.

The question is: how do we guarantee thread safety? We could use synchronization primitives, but locking the entire collection for every operation is not always the most efficient solution.

This is where the collections in System.Collections.Concurrent, introduced with C# 4, come in. They support multithreaded access to shared resources without explicit locking and can outperform a hand-written solution based on synchronization primitives.

We can rewrite the example with ConcurrentDictionary<TKey, TValue>, allowing the program to finish successfully:

using System.Collections.Concurrent;
ConcurrentDictionary<int, string> dictionary = [];
var t1 = new Thread(Method1);
var t2 = new Thread(Method2);
t1.Start();
t2.Start();
// Не даём программе завершиться до завершения потоков t1 и t2
t1.Join();
t2.Join();
foreach (var item in dictionary)
    Console.WriteLine($"Key:{item.Key}, Value:{item.Value}");

void Method1()
{
    for (var i = 0; i < 10; i++)
    {
        dictionary.TryAdd(i, "Added By Method1 " + i);
        Thread.Sleep(100);
    }
}

void Method2()
{
    for (var i = 0; i < 10; i++)
    {
        dictionary.TryAdd(i, "Added By Method2 " + i);
        Thread.Sleep(100);
    }
}
Enter fullscreen mode Exit fullscreen mode

The program completes and prints the dictionary. Your exact distribution between methods may vary:

Key:0, Value:Added By Method2 0
Key:1, Value:Added By Method2 1
Key:2, Value:Added By Method2 2
Key:3, Value:Added By Method2 3
Key:4, Value:Added By Method1 4
Key:5, Value:Added By Method1 5
Key:6, Value:Added By Method2 6
Key:7, Value:Added By Method1 7
Key:8, Value:Added By Method1 8
Key:9, Value:Added By Method2 9
Enter fullscreen mode Exit fullscreen mode

Choosing a System.Collections.Concurrent Type

The collection classes in this namespace are designed for multithreaded scenarios. Several threads may access them concurrently, and the collections allow those threads to share data safely. We will cover:

  • ConcurrentQueue<T>
  • ConcurrentStack<T>
  • ConcurrentDictionary<TKey, TValue>
  • ConcurrentBag<T>
  • BlockingCollection<T>

First, a few implementation details matter.

These types achieve thread safety through different efficient synchronization mechanisms, including lock-free algorithms. Some low-level primitives rely on spinning rather than blocking: a thread waiting for a lock repeatedly checks whether it has become available.

In general, a spin lock checks a condition in a tight loop. If the wait is brief, execution can resume on a subsequent CPU cycle without an operating-system context switch. In the worst case, however, the spinning thread consumes CPU time that could have been used for other work, leaving other threads waiting much longer. Spin locks are therefore best suited to short reads or writes of critical data structures. Microsoft provides more detail in its SpinLock guidance.

Some concurrent collections use lightweight synchronization primitives such as SpinLock, SpinWait, SemaphoreSlim, and CountdownEvent. ConcurrentQueue<T> and ConcurrentStack<T> use no locks at all; they rely on Interlocked operations for thread safety.

For example, this is the implementation of ConcurrentStack<T>.TryPop:

public bool TryPop([MaybeNullWhen(false)] out T result)
{
    Node? head = _head;
    //stack is empty
    if (head == null)
    {
        result = default(T)!;
        return false;
    }
    if (Interlocked.CompareExchange(ref _head, head._next, head) == head)
    {
        result = head._value;
        return true;
    }
    // Fall through to the slow path.
    return TryPopCore(out result);
}
Enter fullscreen mode Exit fullscreen mode

Synchronization adds overhead. Its cost depends on the synchronization mechanism, the operations being performed, the number of threads contending for the collection, and other factors.

In some scenarios, that overhead is negligible and the concurrent type is substantially faster and more scalable than a non-thread-safe equivalent protected by an external lock. Elsewhere, the thread-safe type may perform about the same as—or even worse than—the externally locked version.

If performance matters, choose a collection based on the actual access pattern:

  • Pure producer-consumer: Each thread either adds or removes elements, but never does both.
  • Mixed producer-consumer: Each thread both adds and removes elements.
  • Speedup: Better algorithm performance than another type under the same workload.
  • Scalability: Performance increases with the number of CPU cores. A scalable algorithm runs faster on eight cores than on two.

Now let’s examine the concurrent collection classes themselves.

ConcurrentQueue

This is the thread-safe counterpart of the generic FIFO collection Queue<T>. Its important methods are:

  • Enqueue(T element): Adds an element of type T.
  • TryPeek(out T): Attempts to read the next element without removing it. On success, the value is assigned to the out parameter; otherwise, the method returns false.
  • TryDequeue(out T): Attempts to read and remove the first element. On success, the value is assigned to the out parameter; otherwise, the method returns false.

The Try prefix means callers must be prepared for the requested element to be unavailable. When several threads remove elements from the same queue, a thread cannot know what will remain by the time it performs its read.

The following example demonstrates the basic API. After creating and filling the queue, twenty tasks drain it. A counter verifies that all elements were processed.

The while loop continues until the collection is empty. Once every task has completed, the program prints the number of processed elements, which should match the original queue size.

ConcurrentQueue<int> concurrentQueue = [];
for (var i = 0; i < 5000; i++)
    concurrentQueue.Enqueue(i);
var counter = 0;
var queueTasks = new Task[20];
for (var i = 0; i < queueTasks.Length; i++)
    queueTasks[i] = Task.Factory.StartNew(() =>
    {
        while (!concurrentQueue.IsEmpty)
        {
            var success = concurrentQueue.TryDequeue(out _);
            if (success)
                Interlocked.Increment(ref counter);
        }
    });
await Task.WhenAll(queueTasks);
Console.WriteLine($"Counter: {counter}");
Enter fullscreen mode Exit fullscreen mode

In a pure producer-consumer scenario with very little work per element, ConcurrentQueue<T> may offer a modest performance advantage over an externally locked Queue<T>. It performs best with one dedicated enqueueing thread and one dedicated dequeueing thread. Outside that pattern, Queue<T> may even be slightly faster on multicore machines.

When processing costs roughly 500 FLOPS (floating-point operations) or more, the two-thread restriction no longer applies: ConcurrentQueue<T> scales very well, while Queue<T> does not scale as effectively.

In a mixed producer-consumer scenario with very little processing, an externally locked Queue<T> scales better. At roughly 500 FLOPS or more per item, ConcurrentQueue<T> scales better.

ConcurrentStack

This is the thread-safe counterpart of the generic LIFO collection Stack<T>. Its important methods are:

  • Push(T element): Adds an element of type T.
  • PushRange(T[] elements) and PushRange(T[] elements, int, int): Add an array or range of elements.
  • TryPeek(out T): Attempts to read the next element without removing it; returns false if no element is available.
  • TryPop(out T): Attempts to read and remove the first element; returns false if no element is available.
  • TryPopRange(out T[] elements) and TryPopRange(out T[], int, int): Range-oriented equivalents of TryPop.

Here is an example similar to the queue example:

ConcurrentStack<int> concurrentStack = [];
concurrentStack.PushRange(Enumerable.Range(0, 5000).ToArray());
var counter = 0;
var stackTasks = new Task[20];
for (var i = 0; i < stackTasks.Length; i++)
    stackTasks[i] = Task.Factory.StartNew(() =>
    {
        while (!concurrentStack.IsEmpty)
        {
            var success = concurrentStack.TryPop(out _);
            if (success)
                Interlocked.Increment(ref counter);
        }
    });
await Task.WhenAll(stackTasks);
Console.WriteLine($"Counter: {counter}");
Enter fullscreen mode Exit fullscreen mode

In a pure producer-consumer workload with very little processing, ConcurrentStack<T> and an externally locked Stack<T> perform about the same when one dedicated thread pushes and one dedicated thread pops. As thread count grows, contention slows both types down, and Stack<T> may outperform ConcurrentStack<T>. At about 500 FLOPS or more per item, the two scale similarly.

In a mixed producer-consumer scenario, ConcurrentStack<T> is faster for both small and large workloads.

Using PushRange and TryPopRange can significantly reduce access time.

ConcurrentDictionary

This is the thread-safe counterpart of the standard key-value collection Dictionary<TKey, TValue>. It is arguably the most versatile type in System.Collections.Concurrent. There is no ConcurrentList or ConcurrentSet, but you can simulate a list or set with ConcurrentDictionary. A quick thread-safe-list substitute, for example, can use int keys as positions and values of any required type.

The following example creates twenty tasks. Each task increments a value in the shared dictionary 1,000 times, so the expected total is 20,000. The task array is populated in a loop, and the tasks execute independently.

ConcurrentDictionary<int, int> concurrentDictionary = [];
var taskArray = new Task<int>[20];
for (var i = 0; i < taskArray.Length; i++)
{
    concurrentDictionary.TryAdd(i, 0);
    taskArray[i] = Task.Factory.StartNew(taskParameter =>
    {
        var key = Convert.ToInt32(taskParameter);
        for (var j = 0; j < 1000; j++)
        {
            concurrentDictionary.TryGetValue(key, out var current);
            concurrentDictionary.TryUpdate(key, current + 1, current);
        }
        var valueRetrieved = concurrentDictionary.TryGetValue(key, out var result);
        if (valueRetrieved)
            return result;
        throw new Exception($"No data item available for key {taskParameter}");
    }, i);
}
var resultArray = await Task.WhenAll(taskArray);
Console.WriteLine($"Expected value 20000, Actual: {resultArray.Sum()}");
Enter fullscreen mode Exit fullscreen mode

During execution, the key-value pairs may look like this:

key: 0, value: 40
key: 1, value 46
key: 2: value 43
.
.
.
key: 19, value 45
Enter fullscreen mode Exit fullscreen mode

As a rule, use ConcurrentDictionary<TKey, TValue> whenever several threads add and update keys or values concurrently. With frequent updates and relatively few reads, it usually provides a modest advantage. With many reads and many updates, it is usually substantially faster regardless of core count.

For update-heavy workloads, you can increase the dictionary’s concurrency level and measure whether that improves performance on machines with more cores. If you change the concurrency level, avoid global operations where possible.

If threads only read keys or values, Dictionary<TKey, TValue> is faster because an unmodified dictionary needs no synchronization.

ConcurrentBag

ConcurrentBag<T> is also thread-safe, but it has no exact single-threaded counterpart. It is unordered: there is no predefined order in which items are removed. Internally and operationally, it has some similarities to other concurrent collections.

Like ConcurrentStack and ConcurrentQueue, the bag stores items for concurrent access, but insertion order is not preserved. This makes ConcurrentBag useful when several tasks need to share a pool of objects and ordering does not matter.

Its important methods are:

  • Add(T element): Adds an element of type T.
  • TryPeek(out T): Attempts to read the next element without removing it; returns false when no element is available.
  • TryTake(out T): Attempts to read and remove an element; returns false when no element is available.

The example mirrors the queue and stack versions:

ConcurrentBag<int> concurrentBag = [];
for (var i = 0; i < 5000; i++)
    concurrentBag.Add(i);
var counter = 0;
var bagTasks = new Task[20];
for (var i = 0; i < bagTasks.Length; i++)
    bagTasks[i] = Task.Factory.StartNew(() =>
    {
        while (!concurrentBag.IsEmpty)
        {
            var success = concurrentBag.TryTake(out _);
            if (success)
                Interlocked.Increment(ref counter);
        }
    });
await Task.WhenAll(bagTasks);
Console.WriteLine($"Counter: {counter}");
Enter fullscreen mode Exit fullscreen mode

In a pure producer-consumer scenario, ConcurrentBag<T> will probably be slower than the other concurrent collection types.

In a mixed producer-consumer scenario, it is generally much faster and scales better than the alternatives for both small and large workloads.

BlockingCollection

BlockingCollection<T> is another thread-safe concurrent collection. Several threads may add and remove objects at the same time.

It implements the producer-consumer pattern in C#. A producer thread generates data and a consumer thread consumes it; both use a shared resource to exchange that data. BlockingCollection<T> can serve as that shared resource.

The collection can also have a bounded capacity. Once the limit is reached, the producer cannot add another object. Likewise, a consumer cannot remove data from an empty collection.

What distinguishes BlockingCollection<T> from the other concurrent collections is its support for bounding and blocking semantics:

  • Bounding limits the number of stored items. When the producer reaches the capacity, it blocks while trying to add another object. The producer sleeps until the consumer removes an item.
  • Blocking means that when the collection is empty, the consumer waits until the producer adds an item.

Eventually, the producer calls CompleteAdding, which sets IsCompleted to true. The consumer uses IsCompleted to determine whether any more items can arrive.

The first example has a producer add items and mark adding as complete. The consumer reads in a loop until IsCompleted becomes true:

BlockingCollection<int> blockingCollection = [];
var producerThread = Task.Factory.StartNew(() =>
{
    for (var i = 0; i < 10; i++)
        blockingCollection.Add(i);
    blockingCollection.CompleteAdding();
});
var consumerThread = Task.Factory.StartNew(() =>
{
    while (!blockingCollection.IsCompleted)
    {
        var item = blockingCollection.Take();
        Console.Write($"{item} ");
    }
});
await Task.WhenAll(producerThread, consumerThread);
Enter fullscreen mode Exit fullscreen mode

To see bounding and blocking in action, the next program gives the collection a capacity of ten, has the producer add twenty items, and makes the consumer process them slowly. The collection therefore fills quickly:

var dataItems = new BlockingCollection<Data>(10);
var consumer = Task.Run(() =>
{
    var counter = 0;
    while (!dataItems.IsCompleted)
    {
        Data? data = null;
        try
        {
            data = dataItems.Take();
            counter++;
        }
        catch (InvalidOperationException)
        {
        }
        if (data != null)
            Console.WriteLine($"{counter}: {data}");
        Thread.SpinWait(100000);
    }
    Console.WriteLine("No more items to take.");
});
var producer = Task.Run(() =>
{
    for (var i = 0; i < 20; i++)
    {
        var data = Data.New();
        dataItems.Add(data);
        Console.WriteLine($"Add:{data} Number={dataItems.Count + 1}");
    }
    dataItems.CompleteAdding();
});
await Task.WhenAll(producer, consumer);
// ReSharper disable once NotAccessedPositionalProperty.Global
internal record Data(Guid Content)
{
    public static Data New() =>
        new(Content: Guid.NewGuid());
}
Enter fullscreen mode Exit fullscreen mode

Sometimes an application has several producers and consumers. The following example starts three producer tasks that add items to three BlockingCollection<T> instances. The final while loop calls TryTakeFromAny to remove one item from any collection and print it:

BlockingCollection<int>[] producers =
[
    new BlockingCollection<int>(boundedCapacity: 10),
    new BlockingCollection<int>(boundedCapacity: 10),
    new BlockingCollection<int>(boundedCapacity: 10)
];
Task.Factory.StartNew(() =>
{
    for (var i = 1; i <= 10; i++)
    {
        producers[0].Add(i);
        Thread.Sleep(100);
    }
    producers[0].CompleteAdding();
});
Task.Factory.StartNew(() =>
{
    for (var i = 11; i <= 20; i++)
    {
        producers[1].Add(i);
        Thread.Sleep(150);
    }
    producers[1].CompleteAdding();
});
Task.Factory.StartNew(() =>
{
    for (var i = 21; i <= 30; i++)
    {
        producers[2].Add(i);
        Thread.Sleep(250);
    }
    producers[2].CompleteAdding();
});
while (!producers.All(producer => producer.IsCompleted))
{
    BlockingCollection<int>.TryTakeFromAny(
        producers,
        out var item,
        TimeSpan.FromSeconds(1));
    if (item != default)
    {
        Console.Write($"{item} ");
    }
}
Enter fullscreen mode Exit fullscreen mode

When you need bounding and blocking semantics, BlockingCollection<T> will probably outperform a custom implementation.

Immutable vs. Frozen Collections in .NET

After reading all this, someone will inevitably ask: “What about immutable collections?”

System.Collections.Immutable does provide counterparts to the standard collections, but update operations behave differently. Consider an immutable stack:

var s1 = ImmutableStack<int>.Empty;
var s2 = s1.Push(1);
// s2 = [1]
var s3 = s2.Push(2);
// s2 = [1]
// s3 = [1,2]
// заметьте, что в s2 всё ещё один элемент
var s4 = s3.Pop(ref var i);
// s2 = [1];
// у s2 всё так же один элемент
Enter fullscreen mode Exit fullscreen mode

I will not dive into their internal implementation here. You can learn more from this article and the related talk referenced in the original publication.

Two things are clear. Immutability provides thread safety as a side effect, but update operations are substantially slower. These collections are most interesting for read-only scenarios or infrequent updates where their overhead is acceptable.

A simple dictionary-read benchmark nevertheless shows that ImmutableDictionary is much slower than ConcurrentDictionary. That does not mean you should avoid immutable collections. Quite the opposite: they make code easier to reason about by guaranteeing that the data structure cannot change and by expressing those constraints through the API.

.NET 8 also introduced System.Collections.Frozen, with FrozenDictionary and FrozenSet. These collections allow no mutation at all. They are intended for data—such as configuration—that is loaded once and then read frequently:

private readonly FrozenDictionary<string, bool> _configuration = LoadConfiguration().ToFrozenDictionary();
// ...
if (_configuration.TryGetValue(key, out var setting) && setting)
    DoSomething();
Enter fullscreen mode Exit fullscreen mode

In the read-only benchmark below, FrozenDictionary performs much better than ImmutableDictionary. Treat the result as workload-specific and benchmark your own key types, collection sizes, and access patterns.

public class DictionariesBenchmark
{
    private class Item
    {
        public int Id { get; set; }

        public int Value { get; set; }
    }

    [Params(1_000_000, 10_000_000)]
    public int ItemCount;
    private Item[] _itemList = [];
    private int[] _searchIds = [];
    private ConcurrentDictionary<int, Item> _concurrentDictionary = [];
    private ImmutableDictionary<int, Item> _immutableDictionary = ImmutableDictionary<int, Item>.Empty;
    private FrozenDictionary<int, Item> _frozenDictionary = FrozenDictionary<int, Item>.Empty;
    private Consumer _consumer = new();

    [GlobalSetup]
    public void Setup()
    {
        _itemList = new Item[ItemCount];
        _searchIds = new int[ItemCount];
        var rand = new Random();
        for (var i = 0; i < _itemList.Length; i++)
        {
            var item = new Item
            {
                Id = i,
                Value = Random.Shared.Next()
            };
            _searchIds[i] = item.Id;
            _itemList[i] = item;
            _concurrentDictionary.TryAdd(item.Id, item);
        }
        _immutableDictionary = _concurrentDictionary.ToImmutableDictionary();
        _frozenDictionary = _concurrentDictionary.ToFrozenDictionary();
        rand.Shuffle(_itemList);
    }

    [Benchmark]
    public void ConcurrentDictionarySearch()
    {
        foreach (var id in _searchIds)
        {
            if (!_concurrentDictionary.TryGetValue(id, out var result))
                continue;
            _consumer.Consume(result);
        }
    }

    [Benchmark]
    public void ImmutableDictionarySearch()
    {
        foreach (var id in _searchIds)
        {
            if (!_immutableDictionary.TryGetValue(id, out var result))
                continue;
            _consumer.Consume(result);
        }
    }

    [Benchmark]
    public void FrozenDictionarySearch()
    {
        foreach (var id in _searchIds)
        {
            if (!_frozenDictionary.TryGetValue(id, out var result))
                continue;
            _consumer.Consume(result);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The benchmark produced the following results; treat them as measurements of this workload, not universal rankings:

Method ItemCount Mean Error StdDev
ConcurrentDictionarySearch 1000000 3.434 ms 0.0682 ms 0.0638 ms
ImmutableDictionarySearch 1000000 48.573 ms 0.8981 ms 0.8820 ms
FrozenDictionarySearch 1000000 2.534 ms 0.0486 ms 0.1117 ms
ConcurrentDictionarySearch 10000000 34.719 ms 0.6822 ms 0.9784 ms
ImmutableDictionarySearch 10000000 565.183 ms 10.7288 ms 12.7719 ms
FrozenDictionarySearch 10000000 25.017 ms 0.2762 ms 0.2306 ms

How to Choose a Concurrent Collection

Concurrent collections protect individual operations; they do not automatically make a multi-step workflow atomic. Start from the invariant your code must preserve, then choose the narrowest abstraction that provides it.

  • Use ConcurrentDictionary<TKey, TValue> for concurrent key-based access, but prefer its atomic methods over a separate check followed by an update.
  • Use ConcurrentQueue<T> or ConcurrentStack<T> when ordering is part of the contract.
  • Use BlockingCollection<T> when producers must apply backpressure to synchronous consumers.
  • Prefer immutable data when updates are rare and reasoning about snapshots matters more than mutation cost.
  • Prefer frozen collections for build-once, read-many data such as configuration and lookup tables.

Related .NET Guides

Follow StepOne on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.

Top comments (0)