← Back to Articles         
Concurrent and Thread-Safe Collections

Concurrent and Thread-Safe Collections

Published on 12 Oct 2026     11 min read C# Collections & Generics
Concurrent and Thread-Safe Collections

Concurrent collections are designed to support safe access to shared data from multiple threads.

In multithreaded applications, multiple threads may read, add, update, or remove elements simultaneously. Without proper synchronization, this can cause race conditions, lost updates, inconsistent results, or exceptions.

C# provides thread-safe collections in the System.Collections.Concurrent namespace.

1. Are List<T> and Dictionary<TKey, TValue> thread-safe?

What is it?

No. List<T> and Dictionary<TKey, TValue> are not thread-safe for concurrent modifications.

Multiple threads can generally read these collections safely when no thread modifies them. However, concurrent writes or reading while another thread modifies the collection require synchronization.

Example: Without thread safety

List<int> numbers = new();

Parallel.For(0, 1000, i =>
{
    numbers.Add(i);
});

Console.WriteLine(numbers.Count);

Expected output:

1000

Possible actual output:

997

The actual result is unpredictable. The program may produce an incorrect count, throw an exception, or exhibit other unsafe behavior.

Why does this happen?

Thread 1                 Thread 2
   |                        |
   v                        v
numbers.Add(10)         numbers.Add(20)
   |                        |
   v                        v
Access shared List      Access shared List
   |                        |
   +-----------+------------+
               |
               v
      Concurrent Modification
               |
               v
        Race Condition
               |
               v
       Inconsistent Results

Correct approach: Using lock

List<int> numbers = new();

object lockObject = new();

Parallel.For(0, 1000, i =>
{
    lock (lockObject)
    {
        numbers.Add(i);
    }
});

Console.WriteLine(numbers.Count);

Output:

1000

Explanation

  1. Multiple threads execute Parallel.For().

  2. Each thread attempts to add an element.

  3. lock allows only one thread at a time to execute the protected block.

  4. Other threads wait until the lock becomes available.

  5. All 1000 elements are safely added.

Key Points

  • List<T> and Dictionary<TKey, TValue> are not designed for unsynchronized concurrent writes.

  • Concurrent read-only access is generally safe when no modifications occur.

  • Use lock to protect shared mutable collections.

  • Every access that requires synchronization must follow the same locking strategy.

  • Alternatively, use an appropriate concurrent collection.

 

2. What are concurrent collections in C#?

What is it?

Concurrent collections are thread-safe collection classes designed for access by multiple threads.

They are available in:

using System.Collections.Concurrent;

They provide built-in synchronization mechanisms, reducing the need for developers to manually use lock for individual collection operations.

Important Concurrent Collections

Collection

Purpose

ConcurrentDictionary<TKey, TValue>

Thread-safe key-value storage

ConcurrentQueue<T>

Thread-safe FIFO queue

ConcurrentStack<T>

Thread-safe LIFO stack

ConcurrentBag<T>

Thread-safe unordered collection

BlockingCollection<T>

Producer-consumer collection with blocking and bounded-capacity support

How do they work?

          Multiple Threads
                 |
       +---------+---------+
       |         |         |
    Thread 1  Thread 2  Thread 3
       |         |         |
       +---------+---------+
                 |
                 v
        Concurrent Collection
                 |
                 v
       Built-in Synchronization
                 |
                 v
          Shared Data

Concurrent collections use different synchronization strategies depending on the collection and operation. Some operations are lock-free, while others use internal locks.

Example Program: ConcurrentBag

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;

ConcurrentBag<int> numbers = new();

Parallel.For(0, 1000, i =>
{
    numbers.Add(i);
});

Console.WriteLine(numbers.Count);

Output:

1000

Explanation

  1. ConcurrentBag<int> creates a thread-safe unordered collection.

  2. Multiple threads add elements simultaneously.

  3. The collection handles synchronization internally.

  4. All 1000 elements are stored safely.

The order of elements is not guaranteed.

Key Points

  • Concurrent collections are available in System.Collections.Concurrent.

  • They support safe concurrent access.

  • Different collections support different access patterns.

  • They reduce the need for explicit synchronization for individual operations.

  • Thread safety does not automatically make a sequence of multiple operations atomic.

 

3. What is the difference between Dictionary and ConcurrentDictionary?

What is Dictionary<TKey, TValue>?

Dictionary<TKey, TValue> stores data as key-value pairs.

It provides fast key-based lookup but does not support concurrent writes without synchronization.

What is ConcurrentDictionary<TKey, TValue>?

ConcurrentDictionary<TKey, TValue> is a thread-safe dictionary designed for concurrent reading, adding, updating, and removing of key-value pairs.

Comparison

Feature

Dictionary

ConcurrentDictionary

Thread-safe writes

No

Yes

Multiple readers

Yes, without writes

Yes

Concurrent writes

Requires synchronization

Supported

Built-in synchronization

No

Yes

TryAdd()

No

Yes

GetOrAdd()

No

Yes

AddOrUpdate()

No

Yes

Typical use

Single-threaded/shared synchronized data

Shared concurrent data

Example Program

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;

ConcurrentDictionary<int, string> employees = new();

Parallel.For(1, 4, i =>
{
    employees.TryAdd(i, $"Employee {i}");
});

foreach (var employee in employees)
{
    Console.WriteLine(
        $"{employee.Key} - {employee.Value}");
}

Possible output:

1 - Employee 1
2 - Employee 2
3 - Employee 3

The iteration order is not guaranteed.

Explanation

  1. Multiple threads execute TryAdd().

  2. Each thread attempts to add an employee.

  3. ConcurrentDictionary manages synchronization.

  4. Each unique key is safely added.

  5. Duplicate keys are rejected by TryAdd().

Important Methods

1. TryAdd() – Add only if the key does not exist

employees.TryAdd(4, "Robert");

Returns true when the key is added.

2. TryRemove() – Remove an element

employees.TryRemove(4, out string? removed);

3. GetOrAdd() – Retrieve an existing value or add a new value

string employee = employees.GetOrAdd(
    5,
    id => $"Employee {id}");

4. AddOrUpdate() – Add or update an element

employees.AddOrUpdate(
    1,
    "New Employee",
    (key, oldValue) => "Updated Employee");

Real-Time Scenario

Suppose an ASP.NET Core application maintains request counts for different API endpoints.

ConcurrentDictionary<string, int> requestCounts = new();

requestCounts.AddOrUpdate(
    "/api/employees",
    1,
    (key, count) => count + 1);

AddOrUpdate() safely performs the dictionary update even when multiple threads access the same key.

Important: The delegates supplied to GetOrAdd() and AddOrUpdate() may execute multiple times under contention. Avoid assuming those delegates execute exactly once.

Key Points

  • Dictionary requires external synchronization for concurrent writes.

  • ConcurrentDictionary provides thread-safe dictionary operations.

  • TryAdd() prevents duplicate keys.

  • GetOrAdd() supports conditional insertion.

  • AddOrUpdate() supports atomic dictionary updates.

  • Concurrent dictionary enumeration is safe, but it does not represent a fixed point-in-time snapshot.

 

4. What is the difference between ConcurrentQueue<T> and ConcurrentStack<T>?

What is ConcurrentQueue<T>?

ConcurrentQueue<T> is a thread-safe collection that follows FIFO (First In, First Out).

The first element added is the first element removed.

What is ConcurrentStack<T>?

ConcurrentStack<T> is a thread-safe collection that follows LIFO (Last In, First Out).

The last element added is the first element removed.

Comparison

Feature

ConcurrentQueue<T>

ConcurrentStack<T>

Principle

FIFO

LIFO

Add method

Enqueue()

Push()

Remove method

TryDequeue()

TryPop()

Read next item

TryPeek()

TryPeek()

Thread-safe

Yes

Yes

Use case

Background job queue

Stack-based work processing

Example 1: ConcurrentQueue

using System;
using System.Collections.Concurrent;

ConcurrentQueue<string> queue = new();

queue.Enqueue("Task 1");
queue.Enqueue("Task 2");
queue.Enqueue("Task 3");

while (queue.TryDequeue(out string? task))
{
    Console.WriteLine(task);
}

Output:

Task 1
Task 2
Task 3

Execution Flow

ConcurrentQueue (FIFO)

Enqueue:
Task 1 -> Task 2 -> Task 3

Dequeue:
Task 1
   |
   v
Task 2
   |
   v
Task 3

Example 2: ConcurrentStack

using System;
using System.Collections.Concurrent;

ConcurrentStack<string> stack = new();

stack.Push("Task 1");
stack.Push("Task 2");
stack.Push("Task 3");

while (stack.TryPop(out string? task))
{
    Console.WriteLine(task);
}

Output:

Task 3
Task 2
Task 1

Execution Flow

ConcurrentStack (LIFO)

Push:
+--------+
| Task 3 | <- Top
+--------+
| Task 2 |
+--------+
| Task 1 |
+--------+

Pop:
Task 3 -> Task 2 -> Task 1

Real-Time Scenario

ConcurrentQueue:

An application receives orders and places them into a queue for background processing.

Order 1 -> Order 2 -> Order 3
                |
                v
          Process in FIFO

ConcurrentStack:

An application maintains a collection of work items where the most recently added item should be processed first.

Key Points

  • ConcurrentQueue<T> uses FIFO.

  • ConcurrentStack<T> uses LIFO.

  • Both are thread-safe.

  • TryDequeue() and TryPop() return false when no item is available.

  • Both are useful in multithreaded applications.

  • Neither automatically waits for a new item when empty.

 

5. When should you use concurrent collections instead of lock?

What is it?

Both concurrent collections and lock provide ways to coordinate access to shared data.

However, they solve different problems.

Use concurrent collections when multiple threads need safe access to a shared collection through supported individual operations.

Use lock when multiple operations must be performed together as one synchronized unit.

Comparison

Feature

Concurrent Collections

lock

Synchronization

Built-in

Manual

Thread-safe operations

Yes

Inside protected section

Code complexity

Usually lower

More control required

Multiple-operation atomicity

Not generally guaranteed

Yes, within same lock

Performance

Often better under contention

Depends on lock contention

Best use

Shared collections

Compound operations and invariants

Example 1: Using lock

using System;
using System.Collections.Generic;
using System.Threading.Tasks;

Dictionary<int, string> employees = new();

object lockObject = new();

Parallel.For(1, 101, i =>
{
    lock (lockObject)
    {
        employees.Add(i, $"Employee {i}");
    }
});

Console.WriteLine(employees.Count);

Output:

100

Example 2: Using ConcurrentDictionary

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;

ConcurrentDictionary<int, string> employees = new();

Parallel.For(1, 101, i =>
{
    employees.TryAdd(i, $"Employee {i}");
});

Console.WriteLine(employees.Count);

Output:

100

How do they differ?

Using lock:

Thread 1 ----+
Thread 2 ----+--> lock --> Dictionary
Thread 3 ----+     |
                   v
             One thread at a time
             in critical section


Using ConcurrentDictionary:

Thread 1 ----+
Thread 2 ----+--> ConcurrentDictionary
Thread 3 ----+          |
                          v
                Internal synchronization
                allows safe operations

When is lock still necessary?

Consider transferring money between two account balances.

lock (lockObject)
{
    accounts[fromAccount] -= 100;
    accounts[toAccount] += 100;
}

The two updates must be performed together.

Even if accounts were a ConcurrentDictionary, performing two independent updates would not make the entire transfer atomic.

In production, a database transaction would normally be required for persistent financial transfers.

Key Points

  • Prefer concurrent collections for supported independent operations.

  • Use lock when multiple actions must maintain a shared invariant.

  • Concurrent collections do not eliminate every need for synchronization.

  • Avoid unnecessary locks around already atomic collection operations.

  • Measure performance rather than assuming concurrent collections are always faster.

  • For asynchronous coordination, consider SemaphoreSlim rather than holding a lock across an await.

 

6. What is BlockingCollection<T>, and when is it used?

What is it?

BlockingCollection<T> is a thread-safe collection designed for the producer-consumer pattern.

It supports:

  • Adding items from producer threads.

  • Removing items from consumer threads.

  • Waiting when the collection is empty.

  • Restricting the maximum number of stored items.

  • Signaling when no more items will be added.

It is available in:

using System.Collections.Concurrent;

What is the producer-consumer pattern?

A producer creates work items, while a consumer processes them.

Producer Thread
      |
      v
Generate Tasks
      |
      v
+----------------------+
| BlockingCollection   |
|----------------------|
| Task 1               |
| Task 2               |
| Task 3               |
+----------------------+
      |
      v
Consumer Thread
      |
      v
Process Tasks

Example Program

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;

using BlockingCollection<int> queue = new(3);

// Producer
Task producer = Task.Run(() =>
{
    for (int i = 1; i <= 5; i++)
    {
        queue.Add(i);

        Console.WriteLine($"Produced: {i}");
    }

    queue.CompleteAdding();
});

// Consumer
Task consumer = Task.Run(() =>
{
    foreach (int item in queue.GetConsumingEnumerable())
    {
        Console.WriteLine($"Consumed: {item}");
    }
});

await Task.WhenAll(producer, consumer);

Possible output:

Produced: 1
Produced: 2
Produced: 3
Consumed: 1
Consumed: 2
Produced: 4
Produced: 5
Consumed: 3
Consumed: 4
Consumed: 5

The order of the log messages can vary because the producer and consumer run concurrently.

Explanation

  1. BlockingCollection<int>(3) creates a bounded collection with capacity 3.

  2. The producer adds five items.

  3. If the collection reaches its capacity, Add() waits until space becomes available.

  4. The consumer retrieves items using GetConsumingEnumerable().

  5. If the collection is empty and adding is not completed, the consumer waits.

  6. CompleteAdding() signals that no more items will be produced.

  7. The consumer finishes after all remaining items are processed.

How does blocking work?

Producer
   |
   v
Add Item
   |
   v
Collection Full?
   |
   +-- No --> Add Item
   |
   +-- Yes --> Wait for Space
                  |
                  v
             Consumer Removes
                  |
                  v
              Add Item


Consumer
   |
   v
Take Item
   |
   v
Collection Empty?
   |
   +-- No --> Process Item
   |
   +-- Yes --> Wait for Item
                  |
                  v
              Producer Adds
                  |
                  v
              Process Item

Real-Time Scenario

Consider a background service that processes invoice generation requests.

ASP.NET Core API
      |
      v
Invoice Requests
      |
      v
BlockingCollection
      |
      v
Background Worker
      |
      v
Generate Invoice
      |
      v
Save to Database

For modern asynchronous ASP.NET Core applications, Channel<T> is often a better choice because it supports asynchronous reads and writes without blocking threads.

Key Points

  • BlockingCollection<T> supports producer-consumer processing.

  • It provides blocking Add() and Take() operations.

  • Bounded capacity prevents unlimited queue growth.

  • CompleteAdding() signals that production has finished.

  • GetConsumingEnumerable() processes items until completion.

  • It is primarily suited to synchronous, thread-based processing.

  • Consider Channel<T> for modern asynchronous producer-consumer workflows.

 

Important Interview Revision

Question

Key Answer

Is List<T> thread-safe?

No, not for concurrent modifications.

Is Dictionary<TKey, TValue> thread-safe?

No, concurrent writes require synchronization.

What are concurrent collections?

Thread-safe collections for multithreaded access.

Namespace for concurrent collections?

System.Collections.Concurrent.

Dictionary vs ConcurrentDictionary?

Non-thread-safe vs thread-safe concurrent operations.

What is TryAdd()?

Adds an item only if its key does not exist.

What is AddOrUpdate()?

Atomically adds or updates a dictionary entry.

ConcurrentQueue vs ConcurrentStack?

FIFO vs LIFO.

What is ConcurrentBag?

A thread-safe unordered collection.

Concurrent collection vs lock?

Built-in collection synchronization vs manually synchronized critical sections.

What is BlockingCollection?

A producer-consumer collection supporting blocking operations.

What is bounded capacity?

A maximum number of items allowed in the collection.

What is CompleteAdding()?

Signals that no more items will be added.

BlockingCollection vs Channel<T>?

Primarily blocking synchronous operations vs modern asynchronous producer-consumer operations.

Most important interview concepts: Thread safety, race conditions, ConcurrentDictionary, ConcurrentQueue, ConcurrentStack, lock versus concurrent collections, and the producer-consumer pattern.

← Back to Articles