How C# Concurrent Collections Achieve Thread Safety
This article explores how .NET's concurrent collections—such as
ConcurrentDictionary<TKey, TValue>,
ConcurrentQueue<T>,
ConcurrentStack<T>, and
ConcurrentBag<T>—provide thread-safe data access
without the performance penalties of global locks. By examining internal
mechanisms like fine-grained bucket striping, lock-free Compare-And-Swap
(CAS) operations, and thread-local work-stealing algorithms, you will
understand how these structures balance concurrency, data integrity, and
throughput.
The Problem with Standard Collections
Standard collections like Dictionary<TKey, TValue>
and List<T> are not thread-safe. When multiple
threads mutate a standard collection simultaneously, internal arrays can
resize improperly, pointers can corrupt, or reads can enter infinite
loops due to race conditions during rehashing. Protecting these
structures with simple lock statements creates contention,
forcing threads to block and serializing execution even when operations
target unrelated parts of the collection.
The System.Collections.Concurrent namespace solves this
by employing targeted synchronization strategies tailored to specific
access patterns.
ConcurrentDictionary<TKey, TValue>: Fine-Grained Locking and Lock-Free Reads
ConcurrentDictionary<TKey, TValue> handles
concurrent access through a combination of lock-striping and volatile
memory reads.
Lock-Free Reads
Read operations (TryGetValue, indexing,
ContainsKey) are completely lock-free. Internally, bucket
tables are implemented as arrays of linked node lists. Nodes are treated
as immutable with respect to their link pointers. When a read traverses
a bucket chain, it reads volatile references to ensure CPU
cache coherency without acquiring a mutex.
Striped Locks for Writes
Modifications (TryAdd, TryUpdate,
TryRemove) do not lock the entire dictionary. Instead, the
dictionary maintains an array of lock objects known as lock stripes:
- The key’s hash code determines which bucket it belongs to.
- The bucket index maps directly to a specific lock in the lock array.
- Only the lock responsible for that subset of buckets is acquired.
This design allows multiple threads to insert or update items simultaneously, provided those keys resolve to different lock stripes. By default, the number of locks scales with the number of logical processor cores on the machine.
Table Resizing
When the ratio of entries to buckets exceeds a predefined threshold, the table must grow. Resizing requires acquiring every lock in the striped array simultaneously. Once all locks are held, the internal bucket array is reallocated, existing nodes are redistributed, and the locks are released. Because reads do not check locks, reads during a resize continue reading from the old array until the pointer to the new bucket array is swapped atomically.
ConcurrentQueue<T> and ConcurrentStack<T>: Lock-Free CAS Operations
ConcurrentQueue<T> and
ConcurrentStack<T> avoid OS-level locks entirely,
relying instead on CPU-level atomic instructions through the
Interlocked class.
ConcurrentStack<T>
ConcurrentStack<T> implements a lock-free linked
list using the Treiber Stack algorithm:
- A node is created containing the new value.
- The node's
Nextpointer is set to the currentHead. - An
Interlocked.CompareExchangeatomically compares the currentHeadwith the observedHead. - If
Headhas not changed, it is updated to point to the new node. If another thread modifiedHeadfirst, the operation fails, and the loop retries with the updatedHead.
Popping an element follows the same pattern: read Head,
read Head.Next, and attempt a CompareExchange
to point Head to Head.Next.
ConcurrentQueue<T>
Modern implementations of ConcurrentQueue<T> use
an array-based segmented ring buffer. Instead of allocating a node for
every enqueue operation, the queue allocates a linked list of fixed-size
array segments:
- Each segment manages its own
HeadandTailindexes using atomic interlocked operations. - Enqueueing increments the
Tailindex atomically. - Dequeueing increments the
Headindex atomically. - When a segment fills, a new segment is allocated and linked. This prevents unbounded allocations and drastically reduces garbage collection pressure compared to node-per-element approaches.
ConcurrentBag<T>: Thread-Local Storage and Work-Stealing
ConcurrentBag<T> is an unordered collection
optimized for scenarios where the thread adding an item is often the
same thread consuming it (such as parallel processing loops).
- Thread-Local Queues: Each thread that interacts
with a
ConcurrentBag<T>is assigned its own doubly-linked list (deque). - Local Access: When a thread adds or removes an item, it targets its own local deque. If only the owning thread operates on its list, operations require zero synchronization locks.
- Work-Stealing: If a thread attempts to take an item but its local deque is empty, it attempts to "steal" an item from another thread's deque. Stealing requires taking a lock on the target thread's deque, but contention remains minimal because stealing only occurs when a thread runs out of work.
Snapshot and Enumeration Semantics
Concurrent collections do not throw
InvalidOperationException when enumerated while being
modified. However, their enumeration behavior differs significantly from
standard collections:
- Moment-in-Time Iteration:
ConcurrentDictionary<TKey, TValue>andConcurrentBag<T>provide an "eventually consistent" view. An iterator represents a snapshot of the state at the moment enumeration began, but subsequent concurrent modifications may or may not be reflected in the remaining loop. - Whole-Collection Properties: Properties such as
CountandIsEmptyare not instantaneous constants. InConcurrentDictionary, calculatingCountrequires acquiring all lock stripes to sum the entries accurately. InConcurrentQueue, checkingIsEmptychecks whether the current head has caught up to the tail without locking the entire structure.