1. Background of the problem
In a single-threaded app collections like List<T>, Dictionary<T> behave predictably. But once multiple threads start accessing the same collection at the same time, a familiar problem appears: race conditions.
If several threads try to read and/or write to the same collection without proper synchronization, you can get:
- Incorrect data: an item could be removed by one thread while another was trying to update it.
- Data loss: one thread added an item and another overwrote it without knowing about the previous write.
- Exceptions: the collection can end up in an invalid state, and you'll get InvalidOperationException (for example, "Collection was modified; enumeration operation may not execute.") or even NullReferenceException.
Example 1: Race in List<T> (simple increment)
Two threads increment the same element of the list at the same time.
using System.Collections.Generic;
using System.Threading.Tasks; // For Task.Run
class RaceConditionExample
{
static List<int> numbers = new List<int> { 0 }; // List with one element
static void Main(string[] args)
{
Console.WriteLine("Initial value: " + numbers[0]); // 0
// Start two threads, each incrementing numbers[0]
Task task1 = Task.Run(() => IncrementNumbers(500_000));
Task task2 = Task.Run(() => IncrementNumbers(500_000));
Task.WaitAll(task1, task2); // Wait for both threads
Console.WriteLine("Final value: " + numbers[0]); // Expect 1_000_000, but...
// The result will almost always be less than 1_000_000!
}
static void IncrementNumbers(int count)
{
for (int i = 0; i < count; i++)
{
// This operation "numbers[0]++" actually consists of 3 steps:
// 1. Read numbers[0]
// 2. Increment the value by 1
// 3. Write the new value back to numbers[0]
numbers[0]++;
}
}
}
Why is this a race? If thread A reads numbers[0] (value 0), and then thread B reads numbers[0] (also 0) before A writes 1, both threads will increment 0 to 1 and write 1. One increment is lost. The operation numbers[0]++ is not atomic.
Example 2: InvalidOperationException when modifying a Dictionary
One thread iterates the dictionary, another modifies it.
using System.Collections.Generic;
using System.Threading; // For Thread.Sleep
class DictionaryRaceExample
{
static Dictionary<int, string> users = new Dictionary<int, string>();
static void Main(string[] args)
{
// Initialize the dictionary
for (int i = 0; i < 5; i++) users.Add(i, $"User {i}");
// Reader thread
Thread readerThread = new Thread(() =>
{
try
{
foreach (var user in users) // Iterate over the dictionary
{
Console.WriteLine($"Reader: {user.Key} - {user.Value}");
Thread.Sleep(10); // Simulate work
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Reader: ERROR! {ex.Message}");
}
});
// Writer thread
Thread writerThread = new Thread(() =>
{
Thread.Sleep(5); // Give the reader a bit of time to start
for (int i = 5; i < 10; i++)
{
users.Add(i, $"New User {i}"); // Add items
Console.WriteLine($"Writer: Added User {i}");
Thread.Sleep(15);
}
});
readerThread.Start();
writerThread.Start();
readerThread.Join(); // Wait for threads to finish
writerThread.Join();
Console.WriteLine("Example finished.");
}
}
Why does the error happen? Dictionary<TKey, TValue> (like List<T>) is not designed for concurrent reads and writes from different threads without synchronization. When the writer thread changes the internal structure, the reader continues the foreach over already-modified data, which leads to InvalidOperationException.
2. Why simple locks (lock) aren't always optimal?
The idea of "wrap everything in a lock" looks simple, but has downsides:
// Bad example: too much locking
// (Only for demonstration, don't do this!)
static object _lock = new object();
static List<int> _sharedList = new List<int>();
void AddItem(int item)
{
lock (_lock)
{
_sharedList.Add(item);
}
}
int GetItemCount()
{
lock (_lock)
{
return _sharedList.Count;
}
}
- Performance (bottleneck): lock blocks access to the entire collection. With 100 threads, 99 will wait for one, even if operations don't directly conflict.
- Complexity: you must remember to use lock everywhere the collection is used. One forgotten spot — and the race is back.
- Deadlocks: multiple lock on different objects can easily lead to deadlock.
- Iterators: foreach doesn't save you if another thread modifies the collection.
That's why .NET introduced specialized thread-safe collections.
Atomic operations
A thread-safe collection guarantees correct behavior under concurrent access from multiple threads without external locks from the user. The key is atomic operations: the action either happens completely or not at all — other threads don't see "half-states".
- Add, remove, read — behave as if executed one at a time.
- Under the hood they use low-level techniques: interlocked operations (Interlocked), Compare-And-Swap (CAS), fine-grained locks — instead of a global lock on the whole collection.
3. Overview of System.Collections.Concurrent
The namespace System.Collections.Concurrent provides a set of collections built from the ground up for multithreading. Their philosophy is maximum parallelism and minimal locking.
- Performance: they scale with the number of cores.
- Simplicity: you don't need to manually put lock around every operation.
- Fewer bugs: a whole class of manual-synchronization errors disappears.
- Optimized for contention: they work efficiently under concurrent adds/removes.
4. Main classes
ConcurrentQueue<T> (thread-safe queue)
Principle: FIFO — first in, first out. Scenarios: producer–consumer, logging, task queues.
using System.Collections.Concurrent;
ConcurrentQueue<string> messageQueue = new ConcurrentQueue<string>();
void Producer() => messageQueue.Enqueue("Message 1");
void Consumer()
{
if (messageQueue.TryDequeue(out string message))
{
Console.WriteLine($"Processed: {message}");
}
else
{
Console.WriteLine("Queue is empty.");
}
}
ConcurrentStack<T> (thread-safe stack)
Principle: LIFO — last in, first out. Scenarios: action history, DFS traversal, object pools.
using System.Collections.Concurrent;
ConcurrentStack<int> historyStack = new ConcurrentStack<int>();
void PushAction(int value) => historyStack.Push(value);
void PopAction()
{
if (historyStack.TryPop(out int action))
{
Console.WriteLine($"Undone action: {action}");
}
else
{
Console.WriteLine("Stack is empty.");
}
}
ConcurrentBag<T> (thread-safe "bag")
An unordered collection — order is not guaranteed. Optimized for the scenario "a thread often takes what it put itself". Great for pools.
using System.Collections.Concurrent;
ConcurrentBag<System.Guid> objectPool = new ConcurrentBag<System.Guid>();
void AddObject() => objectPool.Add(System.Guid.NewGuid());
void TakeObject()
{
if (objectPool.TryTake(out System.Guid obj))
{
Console.WriteLine($"Took object: {obj}");
}
else
{
Console.WriteLine("Pool is empty.");
}
}
ConcurrentDictionary<TKey, TValue> (thread-safe dictionary)
Supports atomic add, update and get-by-key operations. Great for caches, sessions, counters.
using System.Collections.Concurrent;
ConcurrentDictionary<string, int> userScores = new ConcurrentDictionary<string, int>();
void UpdateScore(string user, int score)
{
// Atomically add if missing, or update if present
userScores.AddOrUpdate(user, score, (key, existingVal) => existingVal + score);
Console.WriteLine($"Score {user}: {userScores[user]}");
}
void GetScore(string user)
{
if (userScores.TryGetValue(user, out int score))
{
Console.WriteLine($"Current score {user}: {score}");
}
else
{
Console.WriteLine($"User {user} not found.");
}
}
5. When to use these collections instead of the regular ones?
- The app is multithreaded: with a single thread regular collections are faster (no overhead).
- One shared collection for multiple threads: a key sign to use System.Collections.Concurrent.
- You need high performance and scalability: the collections are designed for minimal waiting.
- You want simpler code: no manual lock blocks around every operation.
- You need atomic operations: add/remove/get won't leave the collection in an inconsistent state.
Don't use Concurrent collections when:
- The application is strictly single-threaded.
- You need "transactionality" across multiple related operations (external synchronization or other mechanisms might be required).
- Strict retrieval order matters where it's not guaranteed (for example, in ConcurrentBag<T>).
GO TO FULL VERSION