Concurrent Collections

Vaibhav • September 11, 2025

In the previous article, we explored task parallelism-how to run multiple tasks concurrently and coordinate their results. But as soon as you start working with multiple threads or tasks, you run into a classic problem: shared data. If two tasks try to read and write to the same collection at the same time, you risk corrupting the data or crashing your application. That’s where concurrent collections come in.

In this article, we’ll explore the thread-safe collection types provided by .NET, including ConcurrentDictionary, ConcurrentQueue, ConcurrentStack, and ConcurrentBag. We’ll learn how they work, when to use each one, and how they help you avoid race conditions and synchronization headaches in multi-threaded environments.

Why Regular Collections Aren’t Safe

Most of the collections you’ve used so far-like List<T>, Dictionary<K,V>, and Queue<T>-are not thread-safe. If two threads try to modify a List<T> at the same time, you might get exceptions like InvalidOperationException, or worse, corrupted data.

You could use locks to protect access, but that adds complexity and can lead to deadlocks if not done carefully. Instead, .NET provides a set of collections designed specifically for concurrent access.

Concurrent collections use internal synchronization mechanisms like fine-grained locking or lock-free algorithms to ensure thread safety without requiring you to write your own locking code.

ConcurrentDictionary

ConcurrentDictionary<TKey, TValue> is a thread-safe version of Dictionary<TKey, TValue>. It allows multiple threads to read and write without corrupting the data. It’s ideal for scenarios where you need fast lookups and updates from multiple tasks.


ConcurrentDictionary scores = new ConcurrentDictionary();

scores["Alice"] = 10;
scores["Bob"] = 15;

// Safe update
scores.AddOrUpdate("Alice", 5, (key, oldValue) => oldValue + 5);

// Safe read
int bobScore = scores.GetOrAdd("Bob", 0);

Console.WriteLine($"Alice: {scores["Alice"]}, Bob: {bobScore}");
    

AddOrUpdate ensures that the update is atomic-no other thread can interfere during the operation. GetOrAdd adds a value if the key doesn’t exist, or returns the existing value.

ConcurrentQueue

ConcurrentQueue<T> is a thread-safe FIFO (first-in, first-out) queue. It’s perfect for producer-consumer scenarios where one task adds items and another removes them.


ConcurrentQueue messages = new ConcurrentQueue();

messages.Enqueue("Hello");
messages.Enqueue("World");

if (messages.TryDequeue(out string msg))
{
    Console.WriteLine("Dequeued: " + msg);
}
    

TryDequeue safely removes an item from the queue. If the queue is empty, it returns false. You don’t need to worry about locking or synchronization.

ConcurrentStack

ConcurrentStack<T> is a thread-safe LIFO (last-in, first-out) stack. It’s useful when you need to reverse order or backtrack operations in a multi-threaded context.


ConcurrentStack history = new ConcurrentStack();

history.Push(1);
history.Push(2);
history.Push(3);

if (history.TryPop(out int last))
{
    Console.WriteLine("Popped: " + last);
}
    

Push adds an item to the top of the stack, and TryPop removes it safely. You can also use TryPeek to look at the top item without removing it.

ConcurrentBag

ConcurrentBag<T> is a thread-safe unordered collection. It’s optimized for scenarios where multiple threads add and remove items frequently, and order doesn’t matter.


ConcurrentBag tasks = new ConcurrentBag();

tasks.Add("Task1");
tasks.Add("Task2");

if (tasks.TryTake(out string task))
{
    Console.WriteLine("Taken: " + task);
}
    

ConcurrentBag is especially efficient when each thread works with its own local copy. It uses thread-local storage internally to reduce contention.

Choosing the Right Concurrent Collection

Each concurrent collection is optimized for a specific access pattern. Choosing the right one depends on how you plan to use it:

Use ConcurrentDictionary when you need key-based access and atomic updates. Use ConcurrentQueue for FIFO processing, ConcurrentStack for LIFO, and ConcurrentBag when order doesn’t matter and performance is critical.

Avoid using regular collections like List<T> or Dictionary<K,V> in multi-threaded code unless you wrap them in locks. Prefer concurrent collections for simplicity and safety.

Using Concurrent Collections with Parallel Tasks

Concurrent collections shine when used with parallel tasks. Here’s an example using ConcurrentBag to collect results from multiple tasks:


ConcurrentBag results = new ConcurrentBag();

List tasks = new List();

for (int i = 0; i < 5; i++)
{
    int value = i;
    tasks.Add(Task.Run(() =>
    {
        int result = value * 10;
        results.Add(result);
    }));
}

await Task.WhenAll(tasks);

foreach (int r in results)
{
    Console.WriteLine("Result: " + r);
}
    

Each task computes a value and adds it to the bag. Because ConcurrentBag is thread-safe, you don’t need to worry about synchronization.

Avoiding Common Pitfalls

While concurrent collections are safe, they’re not magic. You still need to think about your access patterns. For example, don’t assume that TryDequeue or TryPop will always succeed-check the return value.

Also, avoid mixing concurrent and non-concurrent collections in the same workflow. If you copy data from a ConcurrentQueue to a List<T>, make sure no other thread is modifying the queue during the copy.

You can use BlockingCollection<T> to combine a concurrent collection with blocking behavior. It’s useful for producer-consumer scenarios where you want to wait until an item is available.

Summary

Concurrent collections are essential tools for writing safe and scalable multi-threaded applications. They eliminate the need for manual locking and make it easy to share data across tasks. You’ve learned how to use ConcurrentDictionary, ConcurrentQueue, ConcurrentStack, and ConcurrentBag, and how to choose the right one for your scenario.

In the next article, we’ll explore Synchronization Primitives, which give you fine-grained control over thread coordination using tools like Mutex, Semaphore, and Monitor. These are useful when concurrent collections aren’t enough and you need custom synchronization logic.