C# Concurrent Collections: A Practical Guide
Choosing a thread-safe collection is not simply a matter of replacing Dictionary with ConcurrentDictionary . The right choice depends on the operations you need to make atomic, the ratio of reads to writes, whether consumers must block, and whether the data can become immutable after construction. This guide explains how ordinary generic collections fail under concurrent access, then compares the main types in System.Collections.Concurrent with immutable and frozen collections. The goal is to give you enough mechanical detail to defend the choice in code review-not just a catalog of APIs. C# Concurrent Collections: Quick Selection Guide | Requirement | Start with | |---|---| | Concurrent FIFO processing | ConcurrentQueue | | Concurrent LIFO processing | ConcurrentStack | | Concurrent key-based reads and updates | ConcurrentDictionary | | Unordered items produced and consumed by the same workers | ConcurrentBag | | Blocking or bounded producer-consumer flow | BlockingCollection | | Snapshot-style updates | System.Collections.Immutable | | Build-once, read-many lookup data | System.Collections.Frozen | The table is a starting point, not a substitute for checking which compound operations must be atomic. The sections below explain the mechanics and tradeoffs behind each choice. Why C# Needs Thread-Safe Collections C# 1.0 introduced System.Collections , which includes ArrayList , Hashtable , Stack , Queue , and other collection classes. The problem is that these collections are not type-safe. They store elements as object , which can lead to type-mismatch exceptions and to performance costs from boxing and unboxing. C# 2.0 then introduced the System.Collections.Generic namespace and collection classes such as List , Dictionary , Stack , and Queue . These collections are type-safe, but not thread-safe. Type safety means that when you create a generic collection, you specify the type it stores as a generic type parameter. Reading an element then returns its actual type, so no boxing or unboxing is required. Generic collections do not guarantee thread safety, however. Developers must provide it themselves. Suppose several threads share a dictionary. Concurrency problems can arise when two or more threads access its elements at the same time-for example, when they add or remove items concurrently. Why Generic Collections Are Not Thread-Safe The following example creates one Dictionary instance with integer keys and string values. It then defines Method1 and Method2 ; both methods try to add entries to dictionary . Two threads, t1 and t2 , execute these methods concurrently after Start is called. Dictionary dictionary = []; var t1 = new Thread(Method1); var t2 = new Thread(Method2); t1.Start(); t2.Start(); void Method1() { for (var i = 0; i c__DisplayClass0_0. $>g__Method2|1() in /Users/stepanminin/RiderProjects/ConsoleApp1/ConsoleApp1/Program.cs:line 22 at System.Threading.Thread.StartCallback() Dictionary keys must be unique, and one method duplicated a key inserted by the other. The error occurs because a generic dictionary does not provide thread safety by default. The question is: how do we guarantee thread safety? We could use synchronization primitives, but locking the entire collection for every operation is not always the most efficient solution. This is where the collections in System.Collections.Concurrent , introduced with C# 4, come in. They support multithreaded access to shared resources without explicit locking and can outperform a hand-written solution based on synchronization primitives. We can rewrite the example with ConcurrentDictionary , allowing the program to finish successfully: using System.Collections.Concurrent; ConcurrentDictionary dictionary = []; var t1 = new Thread(Method1); var t2 = new Thread(Method2); t1.Start(); t2.Start(); // Не даём программе завершиться до завершения потоков t1 и t2 t1.Join(); t2.Join(); foreach (var item in dictionary) Console.WriteLine($"Key:{item.Key}, Value:{item.Value}"); void Method1() { for (var i = 0; i ConcurrentStack ConcurrentDictionary ConcurrentBag BlockingCollection First, a few implementation details matter. These types achieve thread safety through different efficient synchronization mechanisms, including lock-free algorithms. Some low-level primitives rely on spinning rather than blocking: a thread waiting for a lock repeatedly checks whether it has become available. In general, a spin lock checks a condition in a tight loop. If the wait is brief, execution can resume on a subsequent CPU cycle without an operating-system context switch. In the worst case, however, the spinning thread consumes CPU time that could have been used for other work, leaving other threads waiting much longer. Spin locks are therefore best suited to short reads or writes of critical data structures. Microsoft provides more detail in its SpinLock guidance. Some concurrent collections use lightweight synchronization primitives such as SpinLock , SpinWait , SemaphoreSlim , and CountdownEvent . ConcurrentQueue and ConcurrentStack use no locks at all; they rely on Interlocked operations for thread safety. For example, this is the implementation of ConcurrentStack .TryPop : public bool TryPop([MaybeNullWhen(false)] out T result) { Node? head = _head; //stack is empty if (head == null) { result = default(T)!; return false; } if (Interlocked.CompareExchange(ref _head, head._next, head) == head) { result = head._value; return true; } // Fall through to the slow path. return TryPopCore(out result); } Synchronization adds overhead. Its cost depends on the synchronization mechanism, the operations being performed, the number of threads contending for the collection, and other factors. In some scenarios, that overhead is negligible and the concurrent type is substantially faster and more scalable than a non-thread-safe equivalent protected by an external lock. Elsewhere, the thread-safe type may perform about the same as-or even worse than-the externally locked version. If performance matters, choose a collection based on the actual access pattern: - Pure producer-consumer: Each thread either adds or removes elements, but never does both. - Mixed producer-consumer: Each thread both adds and removes elements. - Speedup: Better algorithm performance than another type under the same workload. - Scalability: Performance increases with the number of CPU cores. A scalable algorithm runs faster on eight cores than on two. Now let’s examine the concurrent collection classes themselves. ConcurrentQueue This is the thread-safe counterpart of the generic FIFO collection Queue . Its important methods are: - Enqueue(T element) : Adds an element of typeT . - TryPeek(out T) : Attempts to read the next element without removing it. On success, the value is assigned to theout parameter; otherwise, the method returnsfalse . - TryDequeue(out T) : Attempts to read and remove the first element. On success, the value is assigned to theout parameter; otherwise, the method returnsfalse . The Try prefix means callers must be prepared for the requested element to be unavailable. When several threads remove elements from the same queue, a thread cannot know what will remain by the time it performs its read. The following example demonstrates the basic API. After creating and filling the queue, twenty tasks drain it. A counter verifies that all elements were processed. The while loop continues until the collection is empty. Once every task has completed, the program prints the number of processed elements, which should match the original queue size. ConcurrentQueue concurrentQueue = []; for (var i = 0; i { while (!concurrentQueue.IsEmpty) { var success = concurrentQueue.TryDequeue(out _); if (success) Interlocked.Increment(ref counter); } }); await Task.WhenAll(queueTasks); Console.WriteLine($"Counter: {counter}"); In a pure producer-consumer scenario with very little work per element, ConcurrentQueue may offer a modest performance advantage over an externally locked Queue . It performs best with one dedicated enqueueing thread and one dedicated dequeueing thread. Outside that pattern, Queue may even be slightly faster on multicore machines. When processing costs roughly 500 FLOPS (floating-point operations) or more, the two-thread restriction no longer applies: ConcurrentQueue scales very well, while Queue does not scale as effectively. In a mixed producer-consumer scenario with very little processing, an externally locked Queue scales better. At roughly 500 FLOPS or more per item, ConcurrentQueue scales better. ConcurrentStack This is the thread-safe counterpart of the generic LIFO collection Stack . Its important methods are: - Push(T element) : Adds an element of typeT . - PushRange(T[] elements) andPushRange(T[] elements, int, int) : Add an array or range of elements. - TryPeek(out T) : Attempts to read the next element without removing it; returnsfalse if no element is available. - TryPop(out T) : Attempts to read and remove the first element; returnsfalse if no element is available. - TryPopRange(out T[] elements) andTryPopRange(out T[], int, int) : Range-oriented equivalents ofTryPop . Here is an example similar to the queue example: ConcurrentStack concurrentStack = []; concurrentStack.PushRange(Enumerable.Range(0, 5000).ToArray()); var counter = 0; var stackTasks = new Task[20]; for (var i = 0; i { while (!concurrentStack.IsEmpty) { var success = concurrentStack.TryPop(out _); if (success) Interlocked.Increment(ref counter); } }); await Task.WhenAll(stackTasks); Console.WriteLine($"Counter: {counter}"); In a pure producer-consumer workload with very little processing, ConcurrentStack and an externally locked Stack perform about the same when one dedicated thread pushes and one dedicated thread pops. As thread count grows, contention slows both types down, and Stack may outperform ConcurrentStack . At about 500 FLOPS or more per item, the two scale similarly. In a mixed producer-consumer scenario, Concur
Comments
No comments yet. Start the discussion.