.NET
ConcurrentDictionary
thread-safe
multi-threading
synchronization

Which members of .NET's ConcurrentDictionary are thread-safe?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

ConcurrentDictionary is built for concurrent access, but the phrase thread-safe is often misunderstood. In .NET, individual dictionary operations are synchronized, while multi-step workflows are still your responsibility. Knowing that boundary helps you avoid subtle race conditions even when using the right collection type.

What Is Thread-Safe in ConcurrentDictionary

The core methods on ConcurrentDictionary are designed for concurrent reads and writes. In practice, the following operations are safe to call from many threads at the same time:

  • TryAdd
  • TryRemove
  • TryGetValue
  • TryUpdate
  • GetOrAdd
  • AddOrUpdate
  • Indexer reads and writes, such as dict[key]

The important point is that each of these operations is atomic at the dictionary level. If two threads call TryAdd with the same key, only one succeeds.

csharp
1using System;
2using System.Collections.Concurrent;
3using System.Threading.Tasks;
4
5class Program
6{
7    static void Main()
8    {
9        var counts = new ConcurrentDictionary<string, int>();
10
11        Parallel.For(0, 10000, _ =>
12        {
13            counts.AddOrUpdate(
14                "requests",
15                1,
16                (_, current) => current + 1);
17        });
18
19        Console.WriteLine(counts["requests"]); // 10000
20    }
21}

In this example, AddOrUpdate safely handles heavy parallel updates without external locks.

Delegates and Atomicity Boundaries

GetOrAdd and AddOrUpdate accept delegates. The dictionary guarantees the final mutation is thread-safe, but it does not guarantee your delegate runs only once globally. Under contention, a value factory can be executed more than once, and only one result may win.

That means delegates should be side-effect free whenever possible.

csharp
1using System;
2using System.Collections.Concurrent;
3using System.Threading;
4using System.Threading.Tasks;
5
6class Program
7{
8    static void Main()
9    {
10        var cache = new ConcurrentDictionary<string, string>();
11        int factoryCalls = 0;
12
13        Parallel.For(0, 100, _ =>
14        {
15            cache.GetOrAdd("token", _ =>
16            {
17                Interlocked.Increment(ref factoryCalls);
18                return Guid.NewGuid().ToString("N");
19            });
20        });
21
22        Console.WriteLine($"Factory calls: {factoryCalls}");
23        Console.WriteLine($"Stored token: {cache["token"]}");
24    }
25}

You may see factoryCalls greater than 1. This is expected behavior and not a bug in ConcurrentDictionary.

Enumeration and Snapshot Semantics

Enumeration is safe in the sense that it will not corrupt internal state or throw because of concurrent writes. However, it is not a transactionally consistent snapshot of all writes that happen during the loop.

csharp
1using System;
2using System.Collections.Concurrent;
3using System.Threading.Tasks;
4
5class Program
6{
7    static void Main()
8    {
9        var dict = new ConcurrentDictionary<int, int>();
10        for (int i = 0; i < 10; i++) dict[i] = i;
11
12        Task writer = Task.Run(() =>
13        {
14            for (int i = 10; i < 20; i++) dict[i] = i;
15        });
16
17        foreach (var pair in dict)
18        {
19            Console.WriteLine($"{pair.Key} -> {pair.Value}");
20        }
21
22        writer.Wait();
23    }
24}

If you need a stable view for reporting or serialization, materialize a copy first with ToArray() and process that copy.

Extension Methods and Interface-Based Access

The thread-safety guarantees apply to the concrete ConcurrentDictionary members. If you cast to interfaces like IDictionary<TKey, TValue> and call members not designed for concurrent mutation, behavior can be less obvious and may involve external assumptions.

Similarly, LINQ over a live dictionary is safe for simple projection but still subject to changing data during query execution. For deterministic results, snapshot first.

csharp
1using System.Linq;
2
3var snapshot = dict.ToArray();
4var topKeys = snapshot
5    .OrderByDescending(kv => kv.Value)
6    .Take(5)
7    .Select(kv => kv.Key)
8    .ToList();

Common Pitfalls

  • Assuming thread-safe means multi-step logic is automatically safe. Fix: Use one atomic method when possible, or add your own lock around the full sequence.
  • Putting side effects inside GetOrAdd or AddOrUpdate delegates. Fix: Keep delegates pure, or make side effects idempotent.
  • Treating enumeration as a transactionally complete snapshot. Fix: Use ToArray() before analysis or export.
  • Mixing ContainsKey followed by indexer assignment in separate steps. Fix: Prefer TryAdd, TryUpdate, or AddOrUpdate to avoid races.
  • Using external mutable objects as dictionary values without their own synchronization. Fix: Store immutable values, or protect mutable internals separately.

Summary

  • ConcurrentDictionary provides thread-safe atomic operations for its key methods.
  • TryAdd, TryRemove, TryGetValue, TryUpdate, GetOrAdd, and AddOrUpdate are safe under concurrency.
  • Delegate factories may run multiple times, so avoid side effects in those delegates.
  • Enumeration is safe but not a strict point-in-time transaction snapshot.
  • Multi-step workflows still need explicit synchronization or redesign around single atomic calls.

Course illustration
Course illustration

All Rights Reserved.