Concurrent collections in C# protect supported collection operations, but they do not automatically make an application's entire workflow correct. A thread-safe lookup can be followed by a decision that is already outdated. A safely stored value can still refer to an object whose internals are mutable.
The useful starting point is the boundary of the guarantee. Which operation is protected? Which state must change together? Can a factory run more than once? Does enumeration represent one moment, or a traversal that permits concurrent changes?
This article explains those questions using ordinary locking and selected ConcurrentDictionary operations. It is not a general async guide, a producer-consumer pipeline tutorial, or a claim that concurrent collections are universally faster. Complete examples use C# 12-compatible syntax and stable .NET 10 APIs. They were reviewed against current documentation, not executed.
Concurrent Collections in C# Protect Specific Boundaries
A collection operation and an application workflow are different units. Adding a key can be one operation. Checking a quota, adding a key, recording a reservation, and sending a notification is a workflow.
Microsoft describes ConcurrentDictionary as a thread-safe keyed collection. Its supported operations coordinate access to the dictionary. That description does not make every sequence of calls into a transaction.
| Requirement | Relevant boundary | What remains outside it |
|---|---|---|
| Insert one key only if absent | TryAdd |
External effects and unrelated state |
| Replace a key's expected value | TryUpdate |
Other keys and a larger workflow |
| Compute a candidate value | Factory callback | Callback may repeat outside locks |
| Preserve several state changes together | Shared synchronization protocol | Nonparticipants and external systems |
| Traverse during dictionary writes | Concurrent enumerator | No moment-in-time snapshot |
| Return stable nested object state | Element ownership policy | Collection safety alone |
The table is a map of guarantees, not a ranking of implementation choices. A lock can be suitable for a small compound invariant. A concurrent dictionary can be suitable for independent keyed operations. The shape of the invariant determines which boundary needs protection.
Ordinary Collections Can Be Protected by One Shared Protocol
An ordinary dictionary can participate in a synchronized design when all relevant reads and writes use the same synchronization object. Microsoft's lock reference describes mutual exclusion for a given object. Locking only writers leaves readers outside the protocol.
The important word is “all.” A helper that returns the internal dictionary would allow callers to bypass the lock. A helper that reads count without the lock might observe state outside the intended invariant.
using System;
using System.Collections.Generic;
var registry = new ReservationRegistry();
Console.WriteLine(registry.TryReserve("R-1", 2));
Console.WriteLine(registry.TryReserve("R-2", 2));
Console.WriteLine(registry.TryReserve("R-3", 2));
Console.WriteLine(string.Join(", ", registry.GetIds()));
public sealed class ReservationRegistry
{
private readonly object _gate = new();
private readonly Dictionary<string, bool> _reservations = new(StringComparer.Ordinal);
public bool TryReserve(string id, int limit)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentOutOfRangeException.ThrowIfNegative(limit);
lock (_gate)
{
if (_reservations.ContainsKey(id) || _reservations.Count >= limit)
{
return false;
}
_reservations.Add(id, true);
return true;
}
}
public string[] GetIds()
{
lock (_gate)
{
var ids = new string[_reservations.Count];
_reservations.Keys.CopyTo(ids, 0);
return ids;
}
}
}
The quota check and insertion occur inside one shared lock. Snapshot acquisition uses that same lock. The returned array has independent outer storage, and strings make the element-state boundary straightforward.
The example deliberately does not promise an ordered report. Dictionary enumeration order is undefined, so a display requiring order must establish it separately.
This design has tradeoffs. The lock makes the compound invariant explicit and easy to locate, but participating operations serialize while holding it. A different workload might call for a different protocol. No throughput measurement is supplied here.
The class owns mutable synchronized state intentionally. The readonly fields prevent field reassignment, not mutation of the dictionary. That distinction is essential for understanding what the synchronization protects.
One Conditional Operation Can Replace Check-Then-Act
Consider a design that checks whether a key exists and then adds it. With multiple callers, another participant can insert the key between the check and the add.
For independent insert-if-absent behavior, TryAdd expresses the condition and insertion together at the dictionary boundary. The Boolean result tells the caller whether its insertion occurred.
using System;
using System.Collections.Concurrent;
var claims = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
var firstClaimed = claims.TryAdd("Job-1", "Worker-A");
var secondClaimed = claims.TryAdd("Job-1", "Worker-B");
Console.WriteLine($"First claim inserted: {firstClaimed}");
Console.WriteLine($"Second claim inserted: {secondClaimed}");
if (claims.TryGetValue("Job-1", out var owner))
{
Console.WriteLine($"Current owner: {owner}");
}
Only the insertion boundary is demonstrated. The dictionary does not establish that a worker remains alive, that a job completes, or that a claim can never later be removed.
If claim lifetime, retries, and external job state matter, those policies must be implemented around the collection. Treating a successful TryAdd as permanent ownership would extend the guarantee beyond the operation.
Likewise, ContainsKey followed by TryAdd is not needed merely to discover whether the insertion succeeded. TryAdd already provides that result. The extra check can become stale and does not strengthen the insertion guarantee.
The existing Dictionary variations guide covers the broader family of keyed collections. The concern here is the exact unit of conditional change.
Factories Are Not Exactly-Once Side-Effect Boundaries
GetOrAdd and AddOrUpdate accept callbacks that compute candidate values. Microsoft documents that delegates execute outside ConcurrentDictionary's internal locks.
For AddOrUpdate, the current remarks explicitly say the factories may execute multiple times. A candidate computed by one invocation is not necessarily the candidate eventually stored.
That makes irreversible side effects inside these factories risky. Charging a card, sending a notification, or appending a non-idempotent external record is not made exactly-once by placing the call inside a value factory.
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
var counts = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
Parallel.For(0, 100, _ =>
{
counts.AddOrUpdate("Processed", 1, (_, current) => checked(current + 1));
});
Console.WriteLine($"Stored count: {counts["Processed"]}");
The update callback only computes a new integer from the candidate current value. It does not emit an external effect that would be repeated when the callback repeats. The example's intended result follows from the operation's documented semantics, but no execution result is claimed.
The fixed addition value also avoids an addition callback in this overload. The update callback can still repeat, so its simplicity remains important.
An exception in the computation should not be silently converted into a successful update. Here checked arithmetic surfaces overflow rather than inventing a fallback count. A real application's failure policy belongs around its state operation.
Do not count callback invocations to infer how many updates occurred. Invocation count and committed dictionary changes are different quantities. That distinction is the central factory boundary.
Conditional Replacement Still Has a Limited Scope
TryUpdate compares the current value with an expected value and replaces it if the comparison succeeds. The stable .NET 10 implementation documents this conditional replacement boundary.
That can support a simple state transition. It does not make transitions across several independent keys atomic, and it does not combine the transition with an external database update.
using System;
using System.Collections.Concurrent;
var states = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
states.TryAdd("Job-1", "Pending");
var movedToRunning = states.TryUpdate("Job-1", "Running", "Pending");
var staleTransition = states.TryUpdate("Job-1", "Cancelled", "Pending");
Console.WriteLine($"Running transition: {movedToRunning}");
Console.WriteLine($"Stale transition: {staleTransition}");
Console.WriteLine($"Current state: {states["Job-1"]}");
The second transition expects a state that no longer matches in this example. A false result is part of the conditional API, not evidence of a corrupted dictionary.
An application still needs a policy for that false result. It might reread, reject the attempted transition, or report a conflict. Blindly retrying every transition can apply an action to a state for which it was never intended.
State strings keep this illustration small. A real state model should define valid transitions and identity deliberately. The dictionary is a storage boundary, not the definition of the whole state machine.
The Dictionary primer supplies background on key-value associations. Concurrency adds conditions around changing those associations, not a replacement for application semantics.
Safe Enumeration Is Not Always a Snapshot
Microsoft documents ConcurrentDictionary enumeration as safe during reads and writes but not a moment-in-time snapshot. Elements observed during traversal can reflect changes made after enumeration began.
That can be suitable for diagnostic inspection that tolerates a moving view. It can be unsuitable for a report that requires one consistent version of every entry.
foreach does not supply the missing guarantee. It uses the collection's enumerator. The foreach guide explains the syntax, while the concrete collection defines concurrent traversal semantics.
Do not generalize in either direction. Some concurrent collection APIs provide snapshot-oriented operations; others provide a traversal that permits change. The concrete ConcurrentDictionary.ToArray method returns a snapshot of key/value associations, not deeply immutable keys or values. “Concurrent” is not a universal snapshot promise.
Copying a concurrently changing arbitrary enumerable is not automatically a transaction either. Identify the acquisition operation and its documented behavior, then separate the outer membership guarantee from the mutability of referenced values.
For ordinary collections protected by a lock, acquiring a copy while holding the shared lock can establish an outer membership boundary among participants. It still does not freeze nested objects whose mutations occur outside that protocol.
Stored Objects Have Their Own Concurrency Rules
A concurrent dictionary can safely store a reference to a list. It does not turn operations on that list into concurrent-list operations.
For example, obtaining a List<string> through TryGetValue and calling Add on it changes the value object, not the dictionary's key-value association. The dictionary's internal coordination does not encompass that object's methods.
Microsoft's object reference explanation establishes why multiple callers can share the same instance. A thread-safe path to the reference is not a thread-safe implementation of every object reachable through it.
Stable immutable values can simplify this boundary when they fit the application. Replacing a complete value can be easier to reason about than mutating several fields through aliases. That is a semantic tradeoff, not proof of universal speed or lower memory use.
An IEnumerable return surface also does not remove the issue. Narrowing the visible collection API does not eliminate aliases or establish synchronized access to shared elements.
Compound Workflows Need Their Own Invariants
Suppose the application limits active jobs to ten. Reading Count, checking the limit, and then calling TryAdd are separate operations. Another caller can pass the same count check before either insertion completes.
Using a concurrent dictionary protects the individual calls but does not automatically preserve the quota. The relevant invariant includes the count condition and insertion together.
One shared lock around all participating quota operations is a possible approach. It trades independent concurrency for an explicit compound boundary. Other designs can use dedicated coordination, but the required invariant should be stated before choosing infrastructure.
External effects introduce another boundary. A local lock does not create a transaction with an email service or database. An operation can change local state and then fail before completing its external effect.
The iterator and collection traps provide broader context on misleading surface assumptions. Here the misleading assumption is that a safe container automatically supplies safe business behavior.
State the Invariant Before Choosing the Collection
An invariant is a condition the application intends to preserve. “A key has one current value” is different from “no more than two reservations exist” or “each completed reservation has one external receipt.”
The first can fit a keyed collection operation. The second includes several observations and a change that need coordination. The third crosses a local-state and external-system boundary. Calling all three thread safety makes it harder to identify what is actually proven.
Write the condition in ordinary language, then identify every operation that can affect it. A quota protocol needs to cover insertion, removal, and any reads used to decide whether capacity is available. A helper that bypasses that protocol can invalidate the guarantee even if most code uses the correct lock.
The choice between a lock and a concurrent collection should follow that analysis. A lock around a small complete invariant can be clearer than combining several individually safe calls and assuming they act as one. Independent keyed updates can be clearer with an operation that directly expresses the condition.
There is also a lifetime question. A successful conditional update proves something about that operation, not that the condition will remain true forever. Another valid update can follow immediately. Returning a status should not be described as a permanent reservation unless the larger protocol establishes one.
These boundaries are useful during code review. Ask whether callbacks have repeatable effects, whether mutable values escape, whether all participants use the same protocol, and whether a report needs snapshot semantics. Those questions expose assumptions that the container's type name alone cannot answer.
The examples here illustrate documented rules rather than demonstrate a tested application under contention. Production confidence requires validation of the actual invariant and failure paths, not only familiarity with a concurrent API.
An additional distinction is the observation's lifetime. A method can return a value that was valid when read while another thread immediately replaces the association. That does not make the original read unsafe; it means the caller must not interpret it as a guarantee about future state. A reservation, report, or external action needs a contract explaining how long its observation remains authoritative. Collection operation safety supplies one part of that explanation, not the whole lifetime policy.
Frequently Asked Questions
Does ConcurrentDictionary make every workflow atomic?
No. Supported operations protect their defined boundaries. Several calls, unrelated state, and external effects remain a larger workflow that requires its own consistency policy.
Can GetOrAdd factories run more than once?
Yes. Factory execution is outside the internal locking boundary, and competing callers can compute candidates. Do not assume callback invocation is an exactly-once place for irreversible external effects.
Can AddOrUpdate callbacks repeat?
Yes. Microsoft explicitly documents repeated factory execution. Prefer computations whose repeated evaluation does not duplicate external effects, and distinguish candidate computation from a stored update.
Is ConcurrentDictionary enumeration a snapshot?
No. Its enumerator permits concurrent reads and writes but does not represent one moment-in-time dictionary snapshot. Reports requiring a stronger consistency boundary need an appropriate acquisition policy.
Is a List stored inside ConcurrentDictionary thread-safe?
Not merely because it is stored there. Dictionary coordination protects the association, not all methods on the referenced value. The list and its aliases require their own ownership or synchronization policy.
Can an ordinary collection with a lock be appropriate?
Yes. One shared protocol can protect a compound invariant when every relevant reader and writer participates. The tradeoff is serialized work inside the protected region, not automatic superiority or inferiority to concurrent collections.
Understanding Concurrent Collections in C#
The key is to name the boundary: one insertion, one conditional replacement, one computation, one traversal, or one compound invariant. Each has different guarantees.
Concurrent collections are easier to use correctly when their documented operation safety remains distinct from element ownership and application workflow correctness.




