15 minlesson

Concurrent Collections

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:

csharp
1// 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:

csharp
1// SAFE: Thread-safe by design
2var 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:

csharp
1var cache = new ConcurrentDictionary<string, Data>();
2
3// Add or get existing
4Data data = cache.GetOrAdd("key", key => LoadData(key));
5
6// Add or update
7cache.AddOrUpdate(
8 "key",
9 key => CreateNew(key), // Add factory
10 (key, existing) => Update(existing) // Update factory
11);
12
13// Try operations
14if (cache.TryGetValue("key", out var value))
15{
16 Console.WriteLine(value);
17}
18
19if (cache.TryAdd("newKey", newValue))
20{
21 Console.WriteLine("Added");
22}
23
24if (cache.TryRemove("key", out var removed))
25{
26 Console.WriteLine($"Removed: {removed}");
27}
28
29if (cache.TryUpdate("key", newValue, expectedOldValue))
30{
31 Console.WriteLine("Updated");
32}

GetOrAdd for Caching

csharp
1class DataCache
2{
3 private readonly ConcurrentDictionary<int, User> _users = new();
4
5 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

csharp
1var wordCounts = new ConcurrentDictionary<string, int>();
2
3foreach (var word in words)
4{
5 wordCounts.AddOrUpdate(word, 1, (key, count) => count + 1);
6}

ConcurrentQueue

Thread-safe FIFO (First-In-First-Out) queue:

csharp
1var queue = new ConcurrentQueue<WorkItem>();
2
3// Producer
4queue.Enqueue(new WorkItem());
5
6// Consumer
7if (queue.TryDequeue(out var item))
8{
9 ProcessItem(item);
10}
11
12// Peek without removing
13if (queue.TryPeek(out var next))
14{
15 Console.WriteLine($"Next: {next}");
16}
17
18// Check count and empty
19int count = queue.Count;
20bool isEmpty = queue.IsEmpty;

Producer-Consumer with ConcurrentQueue

csharp
1var queue = new ConcurrentQueue<int>();
2var cts = new CancellationTokenSource();
3
4// Producer
5var 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});
13
14// Consumer
15var 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 else
24 {
25 await Task.Delay(5); // Wait for items
26 }
27 }
28});

ConcurrentStack

Thread-safe LIFO (Last-In-First-Out) stack:

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

ConcurrentBag

Thread-safe unordered collection, optimized for scenarios where the same thread adds and removes:

csharp
1var bag = new ConcurrentBag<WorkItem>();
2
3// Add items
4bag.Add(new WorkItem());
5
6// Try to take an item
7if (bag.TryTake(out var item))
8{
9 ProcessItem(item);
10}
11
12// Check contents
13if (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:

csharp
1// Bounded collection - blocks when full
2var collection = new BlockingCollection<int>(boundedCapacity: 10);
3
4// Producer
5Task.Run(() =>
6{
7 for (int i = 0; i < 100; i++)
8 {
9 collection.Add(i); // Blocks if full
10 Console.WriteLine($"Added: {i}");
11 }
12 collection.CompleteAdding();
13});
14
15// Consumer
16Task.Run(() =>
17{
18 foreach (var item in collection.GetConsumingEnumerable())
19 {
20 Console.WriteLine($"Consumed: {item}");
21 }
22 // Loop exits when CompleteAdding() called and collection empty
23});

Note: BlockingCollection is covered in detail in Topic 6.

Comparison Table

CollectionOrderBest Use Case
ConcurrentDictionaryKey-basedCaches, lookups
ConcurrentQueueFIFOWork queues, messages
ConcurrentStackLIFOUndo operations, parsing
ConcurrentBagNoneThread-local pools
BlockingCollectionConfigurableProducer-consumer

Thread Safety Boundaries

Individual operations are atomic, but compound operations are NOT:

csharp
1var dict = new ConcurrentDictionary<string, int>();
2
3// SAFE: Single atomic operation
4dict.AddOrUpdate("key", 1, (k, v) => v + 1);
5
6// NOT SAFE: Two separate operations
7if (dict.ContainsKey("key")) // Race condition here!
8{
9 dict["key"]++;
10}

Performance Considerations

csharp
1// ConcurrentDictionary is optimized for reads
2// Many concurrent reads + few writes = excellent performance
3var cache = new ConcurrentDictionary<string, Data>();
4var data = cache.GetOrAdd(key, k => LoadData(k)); // Fast
5
6// For write-heavy scenarios, consider partitioning
7// or other strategies

Key Takeaways

  • Concurrent collections provide thread-safe operations
  • Individual operations are atomic; compound operations need care
  • ConcurrentDictionary is the most versatile
  • ConcurrentQueue for FIFO producer-consumer
  • ConcurrentBag for unordered, thread-local scenarios
  • BlockingCollection adds blocking for producer-consumer
  • Always prefer concurrent collections over lock + regular collection