Back to Blog
C#

Using C# ConcurrentDictionary for Thread-Safe Collections

Learn how to use C# ConcurrentDictionary for thread-safe operations, including atomic methods, performance tradeoffs, and when to choose it over other approaches.

ConcurrencyThread SafetyDictionaryC# CollectionsParallel Programming
A visual representation of a ConcurrentDictionary with multiple threads accessing it safely, showing atomic operations and locking segments.

When multiple threads read and write a shared dictionary in C#, a standard Dictionary<TKey, TValue> can throw exceptions or produce corrupted state. The ConcurrentDictionary<TKey, TValue> class in System.Collections.Concurrent provides a thread-safe collection designed for concurrent access. This article explains how to use ConcurrentDictionary effectively, including its atomic operations, performance tradeoffs, and when to choose it over other synchronization approaches.

What ConcurrentDictionary Solves

A regular Dictionary<TKey, TValue> is not safe for concurrent reads and writes. If one thread modifies the dictionary while another iterates or reads, you can get InvalidOperationException or undefined behavior. Locking every access with a lock statement works but adds contention and complicates code. ConcurrentDictionary handles this internally, allowing multiple threads to read and write without explicit locking in most scenarios.

The class is part of the .NET Base Class Library and lives in the System.Collections.Concurrent namespace. It uses fine-grained locking and lock-free techniques to provide thread safety while minimizing contention. This makes it a practical choice for caches, configuration stores, and other shared state in multithreaded applications.

Basic Thread-Safe Operations

The simplest way to create a ConcurrentDictionary is to instantiate it and use its methods. The indexer is safe for simple get and set operations; it can add or update a key atomically. For conditional operations, such as adding only when the key is absent or removing an existing key, use methods like TryAdd, TryGetValue, and TryRemove.

using System.Collections.Concurrent; var dict = new ConcurrentDictionary<string, int>(); // Add a key-value pair atomically bool added = dict.TryAdd("key", 42); // Retrieve a value safely if (dict.TryGetValue("key", out int value)) { Console.WriteLine(value); } // Remove a key-value pair atomically bool removed = dict.TryRemove("key", out int removedValue);

TryAdd returns true if the key was added and false if the key already exists. TryRemove removes the pair and returns the value. These methods are atomic, meaning no other thread can interfere between the check and the operation.

Atomic Add and Update Methods

One of the main advantages of ConcurrentDictionary is its atomic methods for updating existing keys. GetOrAdd returns the existing value for a key or adds a new one if the key does not exist. AddOrUpdate either adds a new value or updates an existing one based on a delegate.

// Get existing value or add a new one int current = dict.GetOrAdd("counter", 0); // Add or update using a factory delegate int updated = dict.AddOrUpdate("counter", 0, (key, oldValue) => oldValue + 1);

In the AddOrUpdate example, the update delegate receives the key and the current value, and returns the new value. AddOrUpdate performs the dictionary add-or-update atomically, so you do not need a separate lock around the call. Keep the update delegate side-effect free: under contention, it can run more than once before the final value is stored.

GetOrAdd also has an overload that takes a factory delegate to generate the value only if the key is missing. This avoids unnecessary computation when the key already exists.

var expensiveData = dict.GetOrAdd("data", key => LoadData(key));

The factory delegate is invoked only when the key is absent at check time. Under high contention, however, multiple threads can all see the key as absent and invoke their own factories, so the delegate may run more than once. Only one key-value pair is added to the dictionary; the losing threads' created values are not stored. If the factory has side effects or is expensive, consider using Lazy<T> to avoid redundant work.

Performance and Locking Behavior

ConcurrentDictionary is optimized for scenarios with many reads and occasional writes. Reads are typically lock-free, meaning they do not block other threads. Writes use striped locking: multiple locks protect different parts of the internal hash table, so different threads can update different buckets concurrently.

However, this design has tradeoffs. The internal structure is more complex than a regular dictionary, so even single-threaded access has higher overhead. If a dictionary is only accessed by one thread, a standard Dictionary will be faster. Also, Count and enumeration are not consistent point-in-time snapshots. Count can change as other threads update the collection, and the enumerator is a live, thread-safe view that may reflect changes made after enumeration starts.

For high-throughput scenarios, consider whether you actually need thread safety. If you can partition data by thread or use immutable snapshots, you might avoid the overhead entirely.

When to Use ConcurrentDictionary

Use ConcurrentDictionary when you have shared state that is frequently read and occasionally updated from multiple threads. Common examples include:

  • Caches that are populated lazily and accessed concurrently.
  • Configuration settings that can change at runtime.
  • Aggregating metrics or counters from parallel tasks.
  • Maintaining a registry of services or connections.

If your access pattern is write-heavy or requires complex multi-step transactions, a lock-based approach might be more appropriate. ConcurrentDictionary provides atomic operations for individual keys, but it does not support atomic operations across multiple keys. For example, transferring a value from one key to another requires additional synchronization.

Common Pitfalls and Misconceptions

A common mistake is treating separate reads and writes as one atomic operation. For example, dict["key"] = dict["key"] + 1; can lose updates because another thread can change the key between the read and the write. Use AddOrUpdate when you need a read-modify-write.

Another misconception is that GetOrAdd guarantees the factory delegate runs only once. As mentioned, under contention it may run multiple times. If you need a single initialization, use Lazy<T> as the value and call GetOrAdd with that.

Also, iterating over the dictionary while other threads modify it is safe, but the enumerator is not a snapshot. It can reflect updates made after enumeration started, and the Count property can change during iteration. Do not assume that Count is stable.

Alternatives and Tradeoffs

If you need thread safety but have a small number of keys or very high write contention, a simple lock around a regular Dictionary might be simpler and faster. Similarly, newer .NET versions include Dictionary<TKey, TValue>.TryAdd, but it is not thread-safe.

For read-mostly scenarios, ImmutableDictionary from System.Collections.Immutable can be a better choice. It is thread-safe because it never changes after creation; updates return a new instance. This works well if you can tolerate occasional allocations and need a consistent snapshot.

The decision ultimately depends on your workload. Measure the actual contention and access patterns. ConcurrentDictionary shines when you have many readers and occasional writers, but it is not a universal replacement for all synchronized collections.

C# ConcurrentDictionary: Atomic Methods, Performance, and When to Use It | RYUSLOG DEV