A Dictionary<TKey,TValue> wasn't built with concurrent mutation in mind. The types in System.Collections.Concurrent were.
Back in the Dictionary internals lesson, you saw exactly how Dictionary<TKey,TValue> achieves fast average-case lookups — hash codes, buckets, and a chained-entry structure it quietly grows and rehashes as it fills up. All of that internal bookkeeping — resizing the bucket array, relinking chains, moving entries around — assumes one thing that was never stated out loud: that only one piece of code is touching the dictionary at any given moment.
Now put that same Dictionary<TKey,TValue> inside a Parallel.ForEach loop body, or share it across several worker tasks, and let more than one thread write to it at the same instant. The assumption breaks — and the results range from wrong values to a genuinely corrupted internal structure. In this lesson, you'll learn why ordinary collections aren't safe for concurrent mutation, meet the purpose-built alternatives in System.Collections.Concurrent — ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T>, ConcurrentBag<T>, and ConcurrentStack<T> — and see a real, well-documented gotcha in ConcurrentDictionary.GetOrAdd that surprises even experienced developers.
A concurrent collection is a collection type deliberately engineered so that multiple threads can safely read from and write to it at the same time, without you having to wrap every access in your own locking code. You use it exactly like a regular collection — add, remove, look up — but the type itself handles the coordination needed to keep its internal state correct when several threads touch it simultaneously.
System.Collections.Concurrent is a namespace of thread-safe collection types, each the concurrent counterpart of a familiar collection:
| Concurrent type | Familiar counterpart | Shape |
|---|---|---|
ConcurrentDictionary<TKey,TValue> | Dictionary<TKey,TValue> | Key/value lookup |
ConcurrentQueue<T> | Queue<T> | First-in, first-out |
ConcurrentStack<T> | Stack<T> | Last-in, first-out |
ConcurrentBag<T> | (no direct plain equivalent) | Unordered collection, optimized for the same thread adding and removing its own items |
Every one of these guarantees that any single operation — an add, a remove, a lookup — completes correctly and leaves the collection in a valid state, no matter how many threads call into it at the same instant. That guarantee is the entire reason this namespace exists.
Dictionary<TKey,TValue>, List<T>, and every other collection in System.Collections.Generic were designed and optimized for the overwhelmingly common case: one thread using the collection at a time. That design choice buys real performance — no locking overhead on every single operation — but it means these types provide no protection at all when multiple threads mutate them concurrently.
What actually goes wrong is worth being concrete about, because "not thread-safe" can sound like a vague warning rather than a real, mechanical failure:
InvalidOperationException with a message like "Collection was modified; enumeration operation may not execute" — one thread iterating a List<T> or Dictionary<TKey,TValue> while another thread adds or removes an item at the same time.The precise mechanics of why concurrent, unsynchronized access to shared state produces these symptoms — what a race condition actually is, step by step — gets its own full, dedicated treatment elsewhere in this Part. What matters here is simply this: plain collections were never built to survive concurrent mutation, and reaching for one under those conditions is a real, common source of production bugs.
What's needed, whenever multiple threads genuinely need to read and write the same collection concurrently — say, several Parallel.ForEach worker threads all recording results into one shared structure — is a collection type that has already solved the coordination problem internally, so every caller can just use it normally without hand-rolling locking logic themselves.
That's exactly what System.Collections.Concurrent provides: collections engineered from the ground up to keep their internal state correct under concurrent access, using synchronization techniques far more refined than "wrap every operation in one big lock" — which is exactly what makes them worth reaching for instead of just locking a plain collection yourself, covered next.
The simplest way to make any collection thread-safe would be to wrap a plain Dictionary<TKey,TValue> with a single lock around every operation — one gate that only one thread can pass through at a time, for any access, anywhere in the dictionary. That works, but it means every operation, even two completely unrelated ones touching different keys, has to wait its turn behind the same single gate. Under real concurrent load, that single gate becomes the bottleneck.
ConcurrentDictionary<TKey,TValue> takes a more refined approach: internally, it divides its storage into multiple segments, each with its own, finer-grained synchronization. Conceptually, two threads writing to keys that land in different internal segments can often proceed genuinely concurrently, without waiting on each other at all — only threads contending for the same segment (or the same key) actually need to coordinate. This is what makes ConcurrentDictionary scale meaningfully better under concurrent load than "a plain Dictionary plus one big lock" — it isn't just thread-safe, it's designed to let genuinely unrelated operations avoid blocking each other in the first place.
This is the single most important gotcha in this lesson, and it trips up developers who reasonably assume more than the API actually guarantees. ConcurrentDictionary<TKey,TValue>.GetOrAdd(key, valueFactory) looks like it should mean: "if the key exists, return its value; otherwise, run valueFactory exactly once to compute the value, store it, and return it." The first half of that is true. The second half is not guaranteed.
ConcurrentDictionary<string, ExpensiveObject> cache = new();
// If two threads call this for the SAME missing key at the same moment,
// valueFactory can run on BOTH threads — even though only one result
// actually ends up stored in the dictionary.
ExpensiveObject value = cache.GetOrAdd(key, k => new ExpensiveObject(k));Here's what actually happens, and it's a documented, real characteristic of the type — not a bug: under contention, when two or more threads call GetOrAdd for the same missing key at close to the same instant, ConcurrentDictionary may invoke valueFactory more than once — once per racing thread. Only one of those computed values actually gets stored in the dictionary; the rest are simply discarded once the "winning" thread's value is committed. Every caller does still get back the same, single, correctly-stored value — the dictionary's contents stay consistent — but if valueFactory has side effects (incrementing a counter, writing a log line, opening a network connection, allocating an expensive unmanaged resource), those side effects can happen more than once, for no visible reason at the call site.
This isn't sloppy engineering — it's a deliberate trade-off. Guaranteeing valueFactory runs exactly once per key, always, would require holding a lock across the entire "check if present, then compute, then store" sequence for that key — including however long the factory itself takes to run, which could be arbitrarily slow. ConcurrentDictionary instead prioritizes not blocking other threads for that entire duration, accepting that the factory might occasionally run redundantly under contention as the cost of staying fast and non-blocking for everyone else.
Several worker threads (via Parallel.ForEach) tallying word counts into a shared dictionary — a genuinely common shape:
var wordCounts = new ConcurrentDictionary<string, int>();
Parallel.ForEach(documents, document =>
{
foreach (string word in Tokenize(document))
{
// AddOrUpdate: if "word" is missing, start it at 1;
// if it already exists, atomically increment the existing count
wordCounts.AddOrUpdate(
word,
addValue: 1,
updateValueFactory: (key, existingCount) => existingCount + 1);
}
});Walking through it:
wordCounts dictionary.AddOrUpdate handles both the "first time seeing this word" and "seen it before, bump the count" cases atomically — no separate check-then-act steps that could race against another thread in between.Dictionary<string, int> here would risk exactly the corruption and lost-update problems described above — this is a textbook case for a concurrent collection.A per-key caching layer, where the GetOrAdd gotcha actually matters — and how to work around it when it does:
public class ExchangeRateCache
{
private readonly ConcurrentDictionary<string, Lazy<decimal>> _cache = new();
private readonly IExchangeRateProvider _provider;
public ExchangeRateCache(IExchangeRateProvider provider) => _provider = provider;
public decimal GetRate(string currencyPair)
{
// Wrapping the value in Lazy<T> means GetOrAdd may still construct
// more than one Lazy<decimal> instance under contention — but only
// the WINNING Lazy<T> instance ever has its .Value evaluated,
// so the actual expensive fetch only really runs once.
Lazy<decimal> lazyRate = _cache.GetOrAdd(
currencyPair,
key => new Lazy<decimal>(() => _provider.FetchLiveRate(key)));
return lazyRate.Value;
}
}Without the Lazy<T> wrapper, calling _provider.FetchLiveRate(key) directly inside valueFactory would risk that expensive, possibly rate-limited network call firing more than once under contention for a currency pair nobody has cached yet — exactly the gotcha described above, now with a real, costly side effect attached. Wrapping the factory's result in Lazy<T> is a well-known, practical mitigation: GetOrAdd might still construct a few "loser" Lazy<decimal> instances under contention, but constructing a Lazy<T> itself does nothing expensive — the actual fetch only happens when .Value is read on whichever Lazy<T> instance actually won and got stored.
| Type | Typical use |
|---|---|
ConcurrentQueue<T> | Multiple threads enqueueing work items; other threads dequeueing them in roughly first-in-first-out order. |
ConcurrentStack<T> | Multiple threads pushing/popping when last-in-first-out order is what's actually needed (e.g. a pool of reusable objects). |
ConcurrentBag<T> | Collecting results from parallel work (like the failure list in the Parallel.ForEach lesson's real-world example) when order genuinely doesn't matter — it's specifically optimized for the common pattern where each thread mostly adds and removes its own items. |
A plain collection protected by one big lock is like a filing cabinet with a single master key — only one person can be doing anything with the cabinet at a time, even if two people just want completely different drawers. Everyone else stands in line, no matter how unrelated their task actually is.
ConcurrentDictionary is more like a filing cabinet with many separate drawers, each with its own lock. Two people wanting different drawers can work at the same time without ever getting in each other's way; only two people reaching for the exact same drawer actually have to wait their turn. That's the practical benefit of fine-grained internal synchronization over one big lock around everything.
Recall from the Dictionary internals lesson that a plain Dictionary<TKey,TValue> uses a hash code to route each key to a bucket, then chains together any entries that collide within that bucket. ConcurrentDictionary builds on that same fundamental hash-table idea, but internally partitions its buckets into a number of segments, each guarded by its own lock — rather than one lock protecting the entire bucket array. Whether a given operation on a particular key needs to wait for another thread depends on whether that other thread happens to be working in the same segment; unrelated keys in different segments generally don't contend with each other at all.
The exact number of internal segments and the precise resizing behavior are implementation details that can (and have) evolved across .NET versions — deliberately not part of the guaranteed public contract. What is a stable, documented guarantee is the behavioral contract: every public member is safe to call from multiple threads concurrently, the collection's own internal state will never be corrupted by that concurrent use, and — as covered above — certain methods like GetOrAdd and AddOrUpdate may invoke your supplied delegate more than once under contention, because achieving "runs exactly once, always" would require sacrificing the very concurrency the type exists to provide.
Each individual method call on a concurrent collection is safe and internally consistent. But a sequence of separate calls — like checking ContainsKey, then separately calling Add — is not automatically atomic as a whole, even on a concurrent collection: another thread can slip in between those two calls and change things. That's exactly why ConcurrentDictionary provides combined, genuinely atomic operations like GetOrAdd and AddOrUpdate instead of expecting you to compose "check" and "act" yourself.
It's easy to assume the redundant-execution gotcha means the dictionary ends up in a corrupted or duplicated state. It does not — the dictionary's actual contents stay perfectly consistent; exactly one value ends up stored per key, and every caller (even the "losing" ones) receives that same single, correct value back. What can happen more than once is purely the side effects of computing the discarded candidate values — not the dictionary's own data integrity.
Assuming that as long as most threads only read a shared plain collection, it's safe even though one thread occasionally writes to it too.
Even a single concurrent writer, alongside any readers, is enough to trigger corruption or an "enumeration failed" exception on a plain collection. If any thread might mutate a collection while other threads use it concurrently, that collection needs to be a concurrent type (or protected by a lock).
Wrong — a factory that performs a network call or increments a shared counter directly:
var cache = new ConcurrentDictionary<string, Data>();
Data data = cache.GetOrAdd(key, k =>
{
_fetchCounter++; // can run more than once under contention!
return FetchFromDatabase(k); // can genuinely hit the database more than once!
}); Correct — wrap the expensive work in Lazy<T> so only the winning value is ever actually evaluated, as shown in the real-world example above, or accept and design around the possibility of redundant execution when the factory truly must run eagerly.
Defaulting to ConcurrentDictionary everywhere "just in case," even for a collection that only ever needs a single, well-placed critical section around a handful of related operations.
Concurrent collections shine when multiple threads genuinely need to read and write the same collection concurrently, independently of each other. When you actually need several related operations to happen together as one atomic unit — not just one call at a time — a plain collection guarded by a single, well-placed lock (covered in full in the next lesson) is often simpler to reason about and just as correct.
System.Collections.Concurrent. If only one thread ever touches a given collection instance, a plain collection is simpler and faster — the concurrency guarantee would be solving a problem you don't actually have.
Dictionary<TKey,TValue> and List<T> are not safe for concurrent mutation — they can corrupt their internal state or throw when multiple threads write (or write-while-reading) at the same time.System.Collections.Concurrent provides purpose-built, thread-safe alternatives: ConcurrentDictionary<TKey,TValue>, ConcurrentQueue<T>, ConcurrentStack<T>, ConcurrentBag<T>.ConcurrentDictionary achieves thread safety through fine-grained internal synchronization (segmented, not one big lock), which is what lets unrelated keys avoid unnecessarily blocking each other.GetOrAdd's valueFactory can be invoked more than once under contention for the same missing key — only one computed value is actually stored, but if the factory has side effects, wrap it in Lazy<T> or design around the possibility of redundant execution.lock (next lesson) when several related steps need to happen together as one atomic unit.You've seen why plain collections break under concurrent mutation, and the single most important gotcha in ConcurrentDictionary. Let's check both landed clearly.
1. Several worker threads write to a shared plain Dictionary<string, int> at the same time, with no locking. What's the most accurate description of the risk?
Correct: B
Why B is correct: Dictionary<TKey,TValue> provides no internal synchronization — it was designed and optimized for single-threaded use. Concurrent unsynchronized writes can corrupt its internal bucket/chain structure or produce exceptions like "Collection was modified" during enumeration.
Why A is incorrect: This is precisely the misconception this lesson corrects — Dictionary<TKey,TValue> has no built-in thread safety at all.
Why C is incorrect: There's no graceful rejection mechanism — the failure modes are corruption, exceptions, or silently wrong data, not a clean error.
Why D is incorrect: The code compiles and runs fine — the problem is a runtime correctness issue under concurrent access, not a compile-time restriction.
Reinforcement: "Not thread-safe" for a plain collection means real, concrete failure modes under concurrent mutation — not a theoretical warning.
2. What actually makes ConcurrentDictionary<TKey,TValue> scale better under concurrent load than a plain Dictionary<TKey,TValue> wrapped in one single lock?
Correct: B
Why B is correct: ConcurrentDictionary's advantage over "Dictionary plus one big lock" comes from partitioning its internal storage into segments, each with its own synchronization, so operations on unrelated keys in different segments can often proceed at the same time instead of queuing behind a single gate.
Why A is incorrect: ConcurrentDictionary is still fundamentally a hash table, building on the same hashing/bucket ideas as Dictionary<TKey,TValue>, just with added internal synchronization.
Why C is incorrect: ConcurrentDictionary is genuinely, deliberately synchronized — its thread safety is an engineered guarantee, not a matter of chance.
Why D is incorrect: There's no per-thread entry limit — any number of entries can be stored and accessed by any number of threads.
Reinforcement: Fine-grained, segmented synchronization — not the absence of synchronization — is what makes ConcurrentDictionary scale well under concurrent access.
3. Two threads call cache.GetOrAdd("btc-usd", key => FetchExpensiveRate(key)) for the same, currently-missing key at nearly the same instant. What is the documented, expected behavior?
Correct: B
Why B is correct: This is the documented GetOrAdd gotcha. Under contention for the same missing key, the valueFactory can be invoked by more than one racing thread, but ConcurrentDictionary only stores one winning result — every caller, including the "losing" thread(s), receives that same single stored value back.
Why A is incorrect: This is exactly the assumption the gotcha violates — "exactly once" is not guaranteed under contention, precisely because guaranteeing it would require holding a lock for the factory's entire duration.
Why C is incorrect: Neither thread throws because of the race itself — both complete normally and receive a valid, consistent result.
Why D is incorrect: The dictionary's data stays consistent — exactly one entry per key is stored, even though the factory may have been evaluated more than once behind the scenes.
Reinforcement: GetOrAdd guarantees consistent stored data, not that valueFactory runs exactly once — that distinction is the whole point of this gotcha.
4. A GetOrAdd valueFactory directly increments a shared "cache miss" counter and performs a paid, rate-limited API call. Why is this risky?
Correct: B
Why B is correct: This is the practical consequence of the GetOrAdd gotcha applied to real side effects — a factory with side effects (incrementing a counter, calling a paid/rate-limited API) can have those side effects happen more than once under contention, even though only one computed value is actually stored.
Why A is incorrect: This is precisely the false assumption that causes this exact bug in real code.
Why C is incorrect: GetOrAdd places no restriction on what the factory delegate does — it compiles and runs fine with side effects inside it; the risk is behavioral (possible redundant execution), not a runtime restriction that throws.
Why D is incorrect: The gotcha is specific to ConcurrentDictionary's GetOrAdd/AddOrUpdate methods and their factory delegates — it isn't a ConcurrentQueue<T> behavior at all.
Reinforcement: Wrapping expensive or side-effecting factory work in Lazy<T> (as shown in the real-world example) is the standard mitigation for exactly this scenario.
5. A method only ever runs on a single thread and never shares its local Dictionary<TKey,TValue> with any other thread. Should it be changed to ConcurrentDictionary<TKey,TValue>?
Correct: B
Why B is correct: Concurrent collections exist specifically to solve the problem of correctness under concurrent access — a problem that simply doesn't apply when only one thread ever touches the collection. In that case, a plain Dictionary is both simpler to reason about and faster, with no synchronization overhead being paid for a guarantee that isn't needed.
Why A is incorrect: Concurrent collections carry real overhead for the synchronization they provide — reaching for them unconditionally, even for genuinely single-threaded code, is unnecessary cost for no benefit.
Why C is incorrect: Dictionary<TKey,TValue> is not deprecated — it remains the standard, correct choice for single-threaded scenarios.
Why D is incorrect: Item count is unrelated to whether concurrent access safety is needed — the deciding factor is whether multiple threads actually touch the collection concurrently, not its size.
Reinforcement: Choose a concurrent collection based on whether multiple threads genuinely share it — not as a reflexive default.
You now know when a plain collection breaks under concurrency, and which purpose-built type to reach for instead. Next up: the actual synchronization primitives — lock, SemaphoreSlim, and Interlocked — for the cases a concurrent collection alone doesn't cover.
dotnetmadeeasy.com — Learn C# and .NET, the right way.