thread safety
.NET collections
concurrent programming
software development
C#

Thread safe collections in .NET

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

The System.Collections.Concurrent namespace in .NET provides thread-safe collections designed for multi-threaded access without requiring external locks. The main types are ConcurrentDictionary<TKey, TValue>, ConcurrentQueue<T>, ConcurrentStack<T>, ConcurrentBag<T>, and BlockingCollection<T>. These collections use fine-grained locking and lock-free algorithms internally, making them significantly more efficient than wrapping standard collections with lock statements.

Why Standard Collections Are Not Thread-Safe

csharp
1// DANGEROUS: List<T> is not thread-safe
2var list = new List<int>();
3
4Parallel.For(0, 10000, i =>
5{
6    list.Add(i);  // Race condition — may throw or corrupt data
7});
8
9// list.Count might be less than 10000, or Add() might throw

Standard collections like List<T> and Dictionary<TKey, TValue> are designed for single-threaded access. Concurrent modifications can corrupt internal state, throw exceptions, or produce incorrect results.

ConcurrentDictionary

The most commonly used concurrent collection — a thread-safe hash table:

csharp
1using System.Collections.Concurrent;
2
3var cache = new ConcurrentDictionary<string, int>();
4
5// Thread-safe add or update
6cache.TryAdd("counter", 0);
7cache.AddOrUpdate("counter", 1, (key, oldValue) => oldValue + 1);
8
9// Thread-safe get
10if (cache.TryGetValue("counter", out int value))
11{
12    Console.WriteLine(value);  // 1
13}
14
15// GetOrAdd: return existing or add new atomically
16int result = cache.GetOrAdd("total", key => ExpensiveComputation(key));
17
18// Parallel access
19Parallel.For(0, 10000, i =>
20{
21    cache.AddOrUpdate("counter", 1, (key, old) => old + 1);
22});
23
24Console.WriteLine(cache["counter"]);  // 10001

AddOrUpdate and GetOrAdd are atomic operations. The factory delegate may be called multiple times under contention, but only one result is stored.

ConcurrentQueue

A thread-safe FIFO (first-in, first-out) queue:

csharp
1var queue = new ConcurrentQueue<string>();
2
3// Producer threads
4Parallel.For(0, 100, i =>
5{
6    queue.Enqueue($"item-{i}");
7});
8
9// Consumer thread
10while (queue.TryDequeue(out string item))
11{
12    Console.WriteLine(item);
13}
14
15// Check without removing
16if (queue.TryPeek(out string next))
17{
18    Console.WriteLine($"Next item: {next}");
19}

ConcurrentQueue<T> is lock-free internally, making it the fastest concurrent collection for producer-consumer scenarios.

ConcurrentStack

A thread-safe LIFO (last-in, first-out) stack:

csharp
1var stack = new ConcurrentStack<int>();
2
3// Push items
4stack.Push(1);
5stack.Push(2);
6stack.Push(3);
7
8// Pop single item
9if (stack.TryPop(out int top))
10{
11    Console.WriteLine(top);  // 3
12}
13
14// Pop multiple items at once
15int[] items = new int[2];
16int count = stack.TryPopRange(items);
17// items: [2, 1], count: 2

TryPopRange is more efficient than calling TryPop in a loop because it reduces contention.

ConcurrentBag

An unordered collection optimized for scenarios where the same thread both produces and consumes items:

csharp
1var bag = new ConcurrentBag<int>();
2
3// Add items from multiple threads
4Parallel.For(0, 1000, i =>
5{
6    bag.Add(i);
7});
8
9Console.WriteLine(bag.Count);  // 1000
10
11// Take items (no guaranteed order)
12if (bag.TryTake(out int item))
13{
14    Console.WriteLine(item);
15}
16
17// Peek at an item without removing
18if (bag.TryPeek(out int peeked))
19{
20    Console.WriteLine(peeked);
21}

ConcurrentBag<T> uses thread-local storage internally, making it fastest when each thread adds and removes its own items (e.g., in work-stealing algorithms).

BlockingCollection

A wrapper that adds blocking and bounding to any IProducerConsumerCollection<T>:

csharp
1// Bounded buffer with max 10 items
2using var collection = new BlockingCollection<string>(boundedCapacity: 10);
3
4// Producer (blocks when full)
5Task.Run(() =>
6{
7    for (int i = 0; i < 100; i++)
8    {
9        collection.Add($"item-{i}");  // Blocks if 10 items are queued
10    }
11    collection.CompleteAdding();  // Signal no more items
12});
13
14// Consumer (blocks when empty)
15Task.Run(() =>
16{
17    foreach (string item in collection.GetConsumingEnumerable())
18    {
19        Console.WriteLine(item);  // Blocks until an item is available
20    }
21    // Loop exits when CompleteAdding() is called and collection is empty
22});

BlockingCollection<T> is ideal for producer-consumer patterns. The bounded capacity provides backpressure to prevent producers from overwhelming consumers.

ImmutableCollections (Alternative Approach)

Instead of concurrent mutation, use immutable collections that create new versions on each change:

csharp
1using System.Collections.Immutable;
2
3var list = ImmutableList<int>.Empty;
4list = list.Add(1).Add(2).Add(3);
5
6// Thread-safe because the original is never modified
7var newList = list.Add(4);
8// list still has [1, 2, 3]
9// newList has [1, 2, 3, 4]
10
11// Atomic update pattern with Interlocked
12ImmutableList<int> shared = ImmutableList<int>.Empty;
13
14ImmutableList<int> original, updated;
15do
16{
17    original = shared;
18    updated = original.Add(42);
19} while (Interlocked.CompareExchange(ref shared, updated, original) != original);

Choosing the Right Collection

ScenarioCollection
Key-value cacheConcurrentDictionary<TKey, TValue>
Producer-consumer queueConcurrentQueue<T> or BlockingCollection<T>
Work-stealing poolConcurrentBag<T>
Undo stackConcurrentStack<T>
Bounded buffer with backpressureBlockingCollection<T>
Read-heavy, rare writesImmutableDictionary<TKey, TValue>

Common Pitfalls

  • Using lock around concurrent collections: ConcurrentDictionary and other concurrent collections handle synchronization internally. Wrapping them in lock statements adds unnecessary overhead and can cause deadlocks if mixed with the collection's internal locking.
  • Non-atomic compound operations on ConcurrentDictionary: if (!dict.ContainsKey(key)) dict.TryAdd(key, value) is not atomic — another thread can add the key between the check and the add. Use GetOrAdd or AddOrUpdate instead, which are atomic.
  • ConcurrentBag for FIFO ordering: ConcurrentBag<T> provides no ordering guarantees. If you need items processed in order, use ConcurrentQueue<T>.
  • Forgetting CompleteAdding on BlockingCollection: If the producer never calls CompleteAdding(), consumers using GetConsumingEnumerable() block forever. Always call CompleteAdding() in a finally block or when the producer finishes.
  • Assuming Count is accurate: The Count property on concurrent collections is a snapshot that may be stale by the time you use it. Never use if (queue.Count > 0) queue.TryDequeue(...) — use TryDequeue directly, as it atomically checks and removes.

Summary

  • Use ConcurrentDictionary<TKey, TValue> for thread-safe key-value operations with GetOrAdd and AddOrUpdate
  • Use ConcurrentQueue<T> for lock-free FIFO producer-consumer patterns
  • Use BlockingCollection<T> when consumers need to block while waiting for items
  • Use ConcurrentBag<T> when the same thread produces and consumes items
  • Avoid locking around concurrent collections — they handle synchronization internally
  • Use atomic methods (TryAdd, TryDequeue, GetOrAdd) instead of check-then-act patterns

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.