High-Performance .NET: Async, Multithreading, and Parallel Programming Concurrent Collections Created: 25 Jul 2026 Updated: 26 Jul 2026

ConcurrentDictionary

Using ConcurrentDictionary of TKey and TValue

Overview

A dictionary stores key/value pairs and lets you find a value by its key almost instantly. Every key is unique, so a dictionary is the natural choice for caches, counters, lookup tables and any "find X by its name or id" scenario.

ConcurrentDictionary<TKey, TValue> is the thread-safe dictionary implementation provided by .NET, found in the System.Collections.Concurrent namespace. Multiple threads can read, add, update and remove entries at the same time without any external locking.

This article follows an easy-to-harder path with four examples:

  1. Step 1: creating the dictionary and using its members on a single thread.
  2. Step 2: two tasks adding entries at the same time.
  3. Step 3: two tasks updating the same key at the same time.
  4. Step 4: a bad example with a plain Dictionary<TKey, TValue> and a good example with ConcurrentDictionary, showing the problem the type actually solves.

Features and Design

Internally the dictionary splits its data into several buckets and locks only the bucket that is being written to, so two threads writing different keys usually do not block each other at all. Reads are lock-free.

The most important design idea is that the interesting operations are atomic: TryAdd(), TryUpdate(), TryRemove(), GetOrAdd() and AddOrUpdate() each perform their check and their write as one indivisible step. This is what removes the classic "check the value, then change it" race condition from your own code.

One caveat to remember: the delegates you pass to GetOrAdd() and AddOrUpdate() run outside the internal lock. Under heavy concurrency the same delegate may run more than once for the same key, even though only one result is finally stored. So those delegates must be cheap, side-effect free and safe to run repeatedly.

Enumeration with foreach or LINQ works over a snapshot, so it never throws even when other threads are modifying the dictionary; the snapshot simply reflects a moment in time. Count has the same nature.

Creating a ConcurrentDictionary and Its Main Members

The common ways to create one:

  1. new ConcurrentDictionary<TKey, TValue>() creates an empty dictionary.
  2. new ConcurrentDictionary<TKey, TValue>(IEnumerable<KeyValuePair<TKey, TValue>>) starts from an existing set of pairs.
  3. Overloads also accept an IEqualityComparer<TKey>, for example StringComparer.OrdinalIgnoreCase for case-insensitive keys.

The members you will use most often are:

  1. TryAdd(key, value) – adds the entry only when the key is missing.
  2. the indexer dictionary[key] – reads a value (throws when the key is missing) or adds/overwrites one.
  3. ContainsKey(key) – asks whether the key exists.
  4. TryGetValue(key, out value) – reads a value without throwing.
  5. GetOrAdd(key, value) / GetOrAdd(key, factory) – returns the existing value or adds a new one.
  6. AddOrUpdate(key, addValue, updateFactory) – the atomic "insert or modify" operation.
  7. TryUpdate(key, newValue, comparisonValue) – updates only when the current value still matches.
  8. TryRemove(key, out value) – removes an entry and hands back its value.
  9. Count, IsEmpty, Keys, Values, Clear().

Step 1: Code Example – Creating the Dictionary and Using Its Members

public static void RunBasics()
{
Console.WriteLine("=== Step 1: Creating a ConcurrentDictionary<TKey, TValue> and using its members ===");

// Way 1: create an empty dictionary with the parameterless constructor.
// TKey is the lookup key (here: a product name), TValue is the stored value (here: a stock count).
ConcurrentDictionary<string, int> stock = new ConcurrentDictionary<string, int>();

// IsEmpty: a fast way to ask "is there at least one entry?".
Console.WriteLine($"IsEmpty right after creation: {stock.IsEmpty}");

// TryAdd(key, value): adds the entry only if the key does NOT exist yet.
// Returns true when the entry was added, false when the key was already there.
bool addedApple = stock.TryAdd("Apple", 10);
bool addedAppleAgain = stock.TryAdd("Apple", 999); // key exists, so nothing changes
Console.WriteLine($"TryAdd(\"Apple\", 10) succeeded: {addedApple}");
Console.WriteLine($"TryAdd(\"Apple\", 999) succeeded (expected False): {addedAppleAgain}");

// Indexer: adds the entry when the key is missing and overwrites it when
// the key already exists. Unlike TryAdd, it never tells you which happened.
stock["Banana"] = 5;
Console.WriteLine($"Value of \"Banana\" set with the indexer: {stock["Banana"]}");

// ContainsKey(key): simply asks whether the key exists.
Console.WriteLine($"ContainsKey(\"Apple\"): {stock.ContainsKey("Apple")}");

// TryGetValue(key, out value): reads a value without throwing when the key
// is missing. Prefer it over the indexer for reads, because the indexer
// throws a KeyNotFoundException for an unknown key.
if (stock.TryGetValue("Apple", out int appleStock))
{
Console.WriteLine($"TryGetValue(\"Apple\") returned: {appleStock}");
}

// GetOrAdd(key, value): returns the existing value when the key is there,
// otherwise adds the given value and returns it.
int cherryStock = stock.GetOrAdd("Cherry", 7);
Console.WriteLine($"GetOrAdd(\"Cherry\", 7) returned: {cherryStock}");

// GetOrAdd(key, factory): same idea, but the starting value is produced by
// a delegate that runs ONLY when the key is missing. Use this overload when
// creating the value is expensive and you want to avoid the cost on a hit.
int dateStock = stock.GetOrAdd("Date", key =>
{
Console.WriteLine($" (factory delegate ran to create the first value for \"{key}\")");
return 3;
});
Console.WriteLine($"GetOrAdd(\"Date\", factory) returned: {dateStock}");

// AddOrUpdate(key, addValue, updateFactory): the "upsert" of this type.
// If the key is missing, addValue is stored. If it exists, updateFactory
// receives the key and the current value and returns the new value.
// The whole read-modify-write happens as one atomic operation.
int newAppleStock = stock.AddOrUpdate("Apple", 1, (key, currentValue) => currentValue + 1);
Console.WriteLine($"AddOrUpdate(\"Apple\") increased the value to: {newAppleStock}");

// TryUpdate(key, newValue, comparisonValue): updates only when the current
// value still equals comparisonValue. This prevents overwriting a change
// that another thread made between your read and your write.
bool updated = stock.TryUpdate("Banana", 8, 5);
bool updateFailed = stock.TryUpdate("Banana", 99, 5); // value is 8 now, so 5 no longer matches
Console.WriteLine($"TryUpdate(\"Banana\", 8, comparisonValue: 5) succeeded: {updated}");
Console.WriteLine($"TryUpdate(\"Banana\", 99, comparisonValue: 5) succeeded (expected False): {updateFailed}");

// TryRemove(key, out value): removes the entry and hands back its value.
bool removed = stock.TryRemove("Cherry", out int removedValue);
Console.WriteLine($"TryRemove(\"Cherry\") succeeded: {removed}, removed value: {removedValue}");

// Count: how many entries the dictionary currently holds.
Console.WriteLine($"Count: {stock.Count}");

// ConcurrentDictionary<TKey, TValue> implements IEnumerable<KeyValuePair<TKey, TValue>>,
// so foreach works over a snapshot taken when the loop starts.
// We order by key only so the output is the same on every run.
Console.WriteLine("All entries:");
foreach (KeyValuePair<string, int> entry in stock.OrderBy(pair => pair.Key))
{
Console.WriteLine($" - {entry.Key}: {entry.Value}");
}

// Clear(): removes every entry at once.
stock.Clear();
Console.WriteLine($"IsEmpty after Clear: {stock.IsEmpty}");
Console.WriteLine();
}

How It Works

This first example runs entirely on the main thread, so nothing about concurrency can distract from the API itself. The pairs of calls are chosen to show the difference between the members that look similar: TryAdd() refuses to overwrite while the indexer overwrites silently; GetOrAdd() only creates a value when the key is missing (notice that the factory delegate message is printed exactly once); and TryUpdate() succeeds the first time but fails the second time, because the current value no longer matches the comparison value.

Sample Output

=== Step 1: Creating a ConcurrentDictionary<TKey, TValue> and using its members ===
IsEmpty right after creation: True
TryAdd("Apple", 10) succeeded: True
TryAdd("Apple", 999) succeeded (expected False): False
Value of "Banana" set with the indexer: 5
ContainsKey("Apple"): True
TryGetValue("Apple") returned: 10
GetOrAdd("Cherry", 7) returned: 7
(factory delegate ran to create the first value for "Date")
GetOrAdd("Date", factory) returned: 3
AddOrUpdate("Apple") increased the value to: 11
TryUpdate("Banana", 8, comparisonValue: 5) succeeded: True
TryUpdate("Banana", 99, comparisonValue: 5) succeeded (expected False): False
TryRemove("Cherry") succeeded: True, removed value: 7
Count: 3
All entries:
- Apple: 11
- Banana: 8
- Date: 3
IsEmpty after Clear: True

Step 2: Code Example – Two Tasks Adding Entries

public static void RunTwoTasksAdding()
{
Console.WriteLine("=== Step 2: Two tasks adding entries at the same time ===");

ConcurrentDictionary<string, int> dictionary = new ConcurrentDictionary<string, int>();

// Task.Run schedules the work on a thread pool thread.
// It is the modern way of starting background work; you do not have to
// create and manage a Thread object yourself.
Task firstTask = Task.Run(() => AddEntries(dictionary, "Task-1"));
Task secondTask = Task.Run(() => AddEntries(dictionary, "Task-2"));

// Task.WaitAll blocks the main thread until both tasks are finished,
// so the results below are printed only after all entries were added.
Task.WaitAll(firstTask, secondTask);

// 2 tasks * 3 entries each = 6 entries, and none is lost because
// ConcurrentDictionary handles the concurrent TryAdd calls internally.
Console.WriteLine($"Total entries after both tasks finished: {dictionary.Count}");

// We order by key only to make the console output deterministic; the
// dictionary itself keeps no insertion order.
foreach (KeyValuePair<string, int> entry in dictionary.OrderBy(pair => pair.Key))
{
Console.WriteLine($" - {entry.Key}: {entry.Value}");
}

Console.WriteLine();
}

// Adds a few entries to the shared dictionary.
// dictionary: the shared collection both tasks write to.
// taskName: used as a key prefix so the two tasks never collide on a key.
private static void AddEntries(ConcurrentDictionary<string, int> dictionary, string taskName)
{
// 3 entries keep the output short and easy to read for a beginner.
for (int entryNumber = 1; entryNumber <= 3; entryNumber++)
{
dictionary.TryAdd($"{taskName}-Key{entryNumber}", entryNumber);
}
}

How It Works

Two background operations are started with Task.Run(), which queues the work on a thread pool thread. Compared with creating a Thread object manually, this is shorter, reuses pooled threads, and gives you a Task you can wait on. Each task uses its own key prefix, so the two never fight over the same key – this is the simple case, where the dictionary only has to protect its internal buckets. All six entries survive and no lock is used anywhere.

Sample Output

=== Step 2: Two tasks adding entries at the same time ===
Total entries after both tasks finished: 6
- Task-1-Key1: 1
- Task-1-Key2: 2
- Task-1-Key3: 3
- Task-2-Key1: 1
- Task-2-Key2: 2
- Task-2-Key3: 3

Step 3: Code Example – Two Tasks Updating the Same Key

public static void RunTwoTasksUpdating()
{
Console.WriteLine("=== Step 3: Two tasks updating the same key at the same time ===");

ConcurrentDictionary<string, int> pageViewCounts = new ConcurrentDictionary<string, int>();

// Both tasks count views for the very same page, so they always fight
// over the same key. This is the situation a normal Dictionary cannot handle.
const string pageName = "/home";
const int viewsPerTask = 500;

Task firstTask = Task.Run(() => CountPageViews(pageViewCounts, pageName, viewsPerTask));
Task secondTask = Task.Run(() => CountPageViews(pageViewCounts, pageName, viewsPerTask));

Task.WaitAll(firstTask, secondTask);

// 2 tasks * 500 views = 1000, and the result is always exactly 1000
// because AddOrUpdate never loses an increment.
Console.WriteLine($"View count for {pageName}: {pageViewCounts[pageName]} (expected {viewsPerTask * 2})");
Console.WriteLine();
}

// Increases the view counter of one page a number of times.
// pageViewCounts: the shared dictionary both tasks update.
// pageName: the key both tasks compete for.
// viewCount: how many times this task increases the counter.
private static void CountPageViews(ConcurrentDictionary<string, int> pageViewCounts, string pageName, int viewCount)
{
for (int viewNumber = 0; viewNumber < viewCount; viewNumber++)
{
// AddOrUpdate stores 1 the first time the key is seen, and afterwards
// runs the update delegate to produce the new value. Writing
// "dictionary[key] = dictionary[key] + 1" instead would be a bug,
// because another task can change the value between the read and the write.
pageViewCounts.AddOrUpdate(
pageName,
addValue: 1,
updateValueFactory: (key, currentCount) => currentCount + 1);
}
}

How It Works

This is the harder and far more interesting case: both tasks hammer the same key. The tempting but wrong way to write this is dictionary[key] = dictionary[key] + 1;. That is three separate operations – read, add one, write – and another task can change the value in between, so increments silently disappear.

AddOrUpdate() solves this: it stores addValue when the key is missing and otherwise calls the update delegate, and it applies the result as one atomic operation, retrying internally if another thread got there first. That is why the final number is always exactly 1000.

Sample Output

=== Step 3: Two tasks updating the same key at the same time ===
View count for /home: 1000 (expected 1000)

Step 4: Code Example – Plain Dictionary (bad) vs ConcurrentDictionary (good)

The first three steps used ConcurrentDictionary without showing what goes wrong without it. This last example runs exactly the same work twice: once with a plain Dictionary<string, int> and once with a ConcurrentDictionary.

public static void RunUnsafeDictionaryVersusConcurrentDictionary()
{
Console.WriteLine("=== Step 4: Plain Dictionary<TKey, TValue> (bad) vs ConcurrentDictionary (good) ===");

// 4 tasks * 10000 entries = 40000 expected entries. Every task uses its own
// key prefix, so the keys never overlap and the expected result is exact.
const int taskCount = 4;
const int entriesPerTask = 10_000;
const int expectedEntryCount = taskCount * entriesPerTask;

// --- BAD EXAMPLE: Dictionary<TKey, TValue> is NOT thread-safe ---
// A Dictionary stores its entries in internal buckets. Adding an entry
// updates several fields (bucket list, entry array, count). When two tasks
// do this at the same time, the internal structure becomes inconsistent:
// entries get lost, and the dictionary can throw while it is resizing.
Dictionary<string, int> unsafeDictionary = new Dictionary<string, int>();
string unsafeResult;

try
{
Task[] unsafeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
int currentTaskIndex = taskIndex; // copy the loop variable for the delegate
unsafeTasks[taskIndex] = Task.Run(() =>
{
for (int entryNumber = 0; entryNumber < entriesPerTask; entryNumber++)
{
unsafeDictionary[$"Task{currentTaskIndex}-Key{entryNumber}"] = entryNumber; // not safe
}
});
}

Task.WaitAll(unsafeTasks);
unsafeResult = $"{unsafeDictionary.Count} entries (expected {expectedEntryCount})";
}
catch (AggregateException aggregateException)
{
// Losing entries is only one possible result; a crash is the other one.
// Task.WaitAll wraps errors coming from the tasks, so we unwrap the
// first one to show what really went wrong inside Dictionary<TKey, TValue>.
unsafeResult = $"crashed with {aggregateException.InnerExceptions[0].GetType().Name}";
}

Console.WriteLine($"Dictionary<string, int> result: {unsafeResult}");

// --- GOOD EXAMPLE: ConcurrentDictionary IS thread-safe ---
// The exact same work, but the collection itself protects its internal
// state, so no lock is needed in our code and no entry is ever lost.
ConcurrentDictionary<string, int> safeDictionary = new ConcurrentDictionary<string, int>();
Task[] safeTasks = new Task[taskCount];
for (int taskIndex = 0; taskIndex < taskCount; taskIndex++)
{
int currentTaskIndex = taskIndex;
safeTasks[taskIndex] = Task.Run(() =>
{
for (int entryNumber = 0; entryNumber < entriesPerTask; entryNumber++)
{
safeDictionary[$"Task{currentTaskIndex}-Key{entryNumber}"] = entryNumber; // safe
}
});
}

Task.WaitAll(safeTasks);

// This line always prints 40000, no matter how many times you run the program.
Console.WriteLine($"ConcurrentDictionary result: {safeDictionary.Count} entries " +
$"(expected {expectedEntryCount})");
Console.WriteLine();
}

How It Works

Adding an entry to a Dictionary<TKey, TValue> touches several internal fields: the bucket array, the entry array, the free list and the count. None of that is protected, so when several tasks add at the same time the internal structure becomes inconsistent. The visible symptom is either a count that is lower than expected or an exception such as IndexOutOfRangeException or InvalidOperationException, which is why the bad example is wrapped in a try/catch block. Because Task.WaitAll() wraps every task error in an AggregateException, the catch block unwraps the first inner exception to show the real problem.

A corrupted dictionary is even worse than a lost entry: a broken bucket chain can send a later lookup into an endless loop, so the bug may show up in a completely different part of the program.

The second half runs the same loops against a ConcurrentDictionary, which locks only the bucket it writes to. No lock statement appears anywhere in our code, and the result is always exactly 40000 entries.

Sample Output

=== Step 4: Plain Dictionary<TKey, TValue> (bad) vs ConcurrentDictionary (good) ===
Dictionary<string, int> result: crashed with InvalidOperationException
ConcurrentDictionary result: 40000 entries (expected 40000)

The Dictionary<string, int> line changes from run to run: sometimes an exception name, sometimes a number that is lower than 40000. The ConcurrentDictionary line never changes.

When to Use

Use ConcurrentDictionary<TKey, TValue> when:

  1. Multiple threads or tasks read and write the same key/value data.
  2. You need in-memory caches, counters, rate limits or session state.
  3. You need atomic "insert or modify" behaviour through GetOrAdd() or AddOrUpdate().

Prefer another type when:

  1. You do not need keys at all – use ConcurrentQueue<T>, ConcurrentStack<T> or ConcurrentBag<T>.
  2. The data never changes after startup – a plain Dictionary<TKey, TValue> that is only read is already safe and faster.
  3. Only a single thread touches the dictionary – a plain Dictionary<TKey, TValue> is faster and uses less memory.
  4. You need several entries to change together as one transaction – the per-entry atomicity of this type is not enough, so use your own lock.


Share this lesson: