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
-
Multiple threads execute
Parallel.For(). -
Each thread attempts to add an element.
-
lockallows only one thread at a time to execute the protected block. -
Other threads wait until the lock becomes available.
-
All 1000 elements are safely added.
Key Points
-
List<T>andDictionary<TKey, TValue>are not designed for unsynchronized concurrent writes. -
Concurrent read-only access is generally safe when no modifications occur.
-
Use
lockto 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 |
|---|---|
|
|
Thread-safe key-value storage |
|
|
Thread-safe FIFO queue |
|
|
Thread-safe LIFO stack |
|
|
Thread-safe unordered collection |
|
|
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
-
ConcurrentBag<int>creates a thread-safe unordered collection. -
Multiple threads add elements simultaneously.
-
The collection handles synchronization internally.
-
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 |
|
|
|---|---|---|
|
Thread-safe writes |
No |
Yes |
|
Multiple readers |
Yes, without writes |
Yes |
|
Concurrent writes |
Requires synchronization |
Supported |
|
Built-in synchronization |
No |
Yes |
|
|
No |
Yes |
|
|
No |
Yes |
|
|
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
-
Multiple threads execute
TryAdd(). -
Each thread attempts to add an employee.
-
ConcurrentDictionarymanages synchronization. -
Each unique key is safely added.
-
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
-
Dictionaryrequires external synchronization for concurrent writes. -
ConcurrentDictionaryprovides 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 |
|
|
|---|---|---|
|
Principle |
FIFO |
LIFO |
|
Add method |
|
|
|
Remove method |
|
|
|
Read next item |
|
|
|
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()andTryPop()returnfalsewhen 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 |
|
|---|---|---|
|
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
lockwhen 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
SemaphoreSlimrather than holding alockacross anawait.
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
-
BlockingCollection<int>(3)creates a bounded collection with capacity 3. -
The producer adds five items.
-
If the collection reaches its capacity,
Add()waits until space becomes available. -
The consumer retrieves items using
GetConsumingEnumerable(). -
If the collection is empty and adding is not completed, the consumer waits.
-
CompleteAdding()signals that no more items will be produced. -
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()andTake()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 |
No, not for concurrent modifications. |
|
Is |
No, concurrent writes require synchronization. |
|
What are concurrent collections? |
Thread-safe collections for multithreaded access. |
|
Namespace for concurrent collections? |
|
|
|
Non-thread-safe vs thread-safe concurrent operations. |
|
What is |
Adds an item only if its key does not exist. |
|
What is |
Atomically adds or updates a dictionary entry. |
|
|
FIFO vs LIFO. |
|
What is |
A thread-safe unordered collection. |
|
Concurrent collection vs |
Built-in collection synchronization vs manually synchronized critical sections. |
|
What is |
A producer-consumer collection supporting blocking operations. |
|
What is bounded capacity? |
A maximum number of items allowed in the collection. |
|
What is |
Signals that no more items will be added. |
|
|
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.