Concurrent Collections
The System.Collections.Concurrent namespace provides thread-safe collection classes that don't require external locking for individual operations.
Why Concurrent Collections?
Regular collections are NOT thread-safe:
csharp1// DANGEROUS: Race condition!2var list = new List<int>();3Parallel.For(0, 1000, i => list.Add(i));4// May throw, lose items, or corrupt the list
With concurrent collections:
csharp1// SAFE: Thread-safe by design2var bag = new ConcurrentBag<int>();3Parallel.For(0, 1000, i => bag.Add(i));4// All 1000 items safely added
ConcurrentDictionary<TKey, TValue>
The most commonly used concurrent collection:
csharp1var cache = new ConcurrentDictionary<string, Data>();23// Add or get existing4Data data = cache.GetOrAdd("key", key => LoadData(key));56// Add or update7cache.AddOrUpdate(8 "key",9 key => CreateNew(key), // Add factory10 (key, existing) => Update(existing) // Update factory11);1213// Try operations14if (cache.TryGetValue("key", out var value))15{16 Console.WriteLine(value);17}1819if (cache.TryAdd("newKey", newValue))20{21 Console.WriteLine("Added");22}2324if (cache.TryRemove("key", out var removed))25{26 Console.WriteLine($"Removed: {removed}");27}2829if (cache.TryUpdate("key", newValue, expectedOldValue))30{31 Console.WriteLine("Updated");32}
GetOrAdd for Caching
csharp1class DataCache2{3 private readonly ConcurrentDictionary<int, User> _users = new();45 public User GetUser(int id)6 {7 return _users.GetOrAdd(id, id => LoadUserFromDatabase(id));8 }9}
Note: The factory may be called multiple times if multiple threads access the same key simultaneously. Only one result is stored.
AddOrUpdate for Counters
csharp1var wordCounts = new ConcurrentDictionary<string, int>();23foreach (var word in words)4{5 wordCounts.AddOrUpdate(word, 1, (key, count) => count + 1);6}
ConcurrentQueue
Thread-safe FIFO (First-In-First-Out) queue:
csharp1var queue = new ConcurrentQueue<WorkItem>();23// Producer4queue.Enqueue(new WorkItem());56// Consumer7if (queue.TryDequeue(out var item))8{9 ProcessItem(item);10}1112// Peek without removing13if (queue.TryPeek(out var next))14{15 Console.WriteLine($"Next: {next}");16}1718// Check count and empty19int count = queue.Count;20bool isEmpty = queue.IsEmpty;
Producer-Consumer with ConcurrentQueue
csharp1var queue = new ConcurrentQueue<int>();2var cts = new CancellationTokenSource();34// Producer5var producer = Task.Run(async () =>6{7 for (int i = 0; i < 100; i++)8 {9 queue.Enqueue(i);10 await Task.Delay(10);11 }12});1314// Consumer15var consumer = Task.Run(async () =>16{17 while (!cts.Token.IsCancellationRequested)18 {19 if (queue.TryDequeue(out var item))20 {21 Console.WriteLine($"Processed: {item}");22 }23 else24 {25 await Task.Delay(5); // Wait for items26 }27 }28});
ConcurrentStack
Thread-safe LIFO (Last-In-First-Out) stack:
csharp1var stack = new ConcurrentStack<int>();23stack.Push(1);4stack.Push(2);5stack.Push(3);67if (stack.TryPop(out var top))8{9 Console.WriteLine(top); // 310}1112// Push multiple at once13stack.PushRange(new[] { 4, 5, 6 });1415// Pop multiple at once16var results = new int[2];17int popped = stack.TryPopRange(results);
ConcurrentBag
Thread-safe unordered collection, optimized for scenarios where the same thread adds and removes:
csharp1var bag = new ConcurrentBag<WorkItem>();23// Add items4bag.Add(new WorkItem());56// Try to take an item7if (bag.TryTake(out var item))8{9 ProcessItem(item);10}1112// Check contents13if (bag.TryPeek(out var next))14{15 Console.WriteLine($"Next available: {next}");16}
When to Use ConcurrentBag
- Order doesn't matter
- Same thread often adds and removes its own items
- Example: Thread-local work pools
BlockingCollection
Adds blocking and bounding to any IProducerConsumerCollection:
csharp1// Bounded collection - blocks when full2var collection = new BlockingCollection<int>(boundedCapacity: 10);34// Producer5Task.Run(() =>6{7 for (int i = 0; i < 100; i++)8 {9 collection.Add(i); // Blocks if full10 Console.WriteLine($"Added: {i}");11 }12 collection.CompleteAdding();13});1415// Consumer16Task.Run(() =>17{18 foreach (var item in collection.GetConsumingEnumerable())19 {20 Console.WriteLine($"Consumed: {item}");21 }22 // Loop exits when CompleteAdding() called and collection empty23});
Note: BlockingCollection is covered in detail in Topic 6.
Comparison Table
| Collection | Order | Best Use Case |
|---|---|---|
| ConcurrentDictionary | Key-based | Caches, lookups |
| ConcurrentQueue | FIFO | Work queues, messages |
| ConcurrentStack | LIFO | Undo operations, parsing |
| ConcurrentBag | None | Thread-local pools |
| BlockingCollection | Configurable | Producer-consumer |
Thread Safety Boundaries
Individual operations are atomic, but compound operations are NOT:
csharp1var dict = new ConcurrentDictionary<string, int>();23// SAFE: Single atomic operation4dict.AddOrUpdate("key", 1, (k, v) => v + 1);56// NOT SAFE: Two separate operations7if (dict.ContainsKey("key")) // Race condition here!8{9 dict["key"]++;10}
Performance Considerations
csharp1// ConcurrentDictionary is optimized for reads2// Many concurrent reads + few writes = excellent performance3var cache = new ConcurrentDictionary<string, Data>();4var data = cache.GetOrAdd(key, k => LoadData(k)); // Fast56// For write-heavy scenarios, consider partitioning7// or other strategies
Key Takeaways
- Concurrent collections provide thread-safe operations
- Individual operations are atomic; compound operations need care
ConcurrentDictionaryis the most versatileConcurrentQueuefor FIFO producer-consumerConcurrentBagfor unordered, thread-local scenariosBlockingCollectionadds blocking for producer-consumer- Always prefer concurrent collections over lock + regular collection