Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close
    Cheat sheets: all cheat sheets

    ConcurrentDictionary: Thread Safety Is Not Workflow Atomicity

    How to reason about ConcurrentDictionary operations, delegates, duplicate work, and multi-step invariants in production and interviews.

    ConcurrentDictionary<TKey,TValue> lets multiple threads access and update the dictionary safely without corrupting its internal state. That guarantee does not automatically make a sequence of calls one atomic business operation.

    Operation versus workflow

    TryAdd, TryUpdate, and conditional removal are atomic for their documented dictionary operation. This sequence is not atomic (it is an illustrative fragment; cache, key, and LoadAsync belong to the application):

    C#
    if (!cache.ContainsKey(key))
    {
        cache[key] = await LoadAsync(key);
    }

    Two callers can both observe absence and both load. Replacing it with GetOrAdd makes insertion atomic, but the value factory can still execute more than once under contention; a competing value can win insertion, and callers must use the value returned by GetOrAdd. Therefore a factory with billing, email, or another irreversible side effect is unsafe.

    Control duplicate asynchronous work

    A common design stores a shared promise rather than the final value. This complete type uses an injected read-only loader; add it to a .NET 8 or later project with C# 12 or later and implicit usings enabled:

    C#
    using System.Collections.Concurrent;
    
    public sealed class ItemMemoizer(Func<string, Task<string>> loadAsync)
    {
        private readonly ConcurrentDictionary<string, Lazy<Task<string>>> _items = new();
    
        public Task<string> GetAsync(string key, CancellationToken waitToken = default)
        {
            var shared = _items.GetOrAdd(
                key,
                k => new Lazy<Task<string>>(
                    () => loadAsync(k),
                    LazyThreadSafetyMode.ExecutionAndPublication));
    
            // Cancel this caller's wait, not the work shared by other callers.
            return shared.Value.WaitAsync(waitToken);
        }
    }

    This is a memoization example: successful and failed tasks remain stored. It is not an automatically evicting in-flight registry. Restrict it to a bounded key set and a deliberate lifetime, or add a tested capacity/expiry policy before using it with arbitrary external keys.

    This can coalesce concurrent callers, but failure and cancellation policy must be explicit. If a failed task remains cached, every later caller receives the same failure. Often the owner removes the exact key/value pair after a failure so a future request can retry. Decide whether one caller’s cancellation cancels shared work or only that caller’s wait.

    Work through one key

    Assume an empty memoizer and a loader that pauses until a test-controlled gate opens.

    1. Callers A and B request "item-7". The dictionary may construct multiple lazy wrappers, but each caller uses the wrapper returned by GetOrAdd.
    2. Both evaluate that shared wrapper's Value. The loader runs once for that wrapper and returns one shared task.
    3. A cancels its waitToken while the loader remains paused. A's wait is canceled. B and the shared task still wait.
    4. Open the loader gate with "ready". B receives "ready"; a later caller receives the stored success without loading again.

    Now repeat with a fresh memoizer and fail the gate instead. Answer: both uncanceled waiters observe the failure; the next lookup observes that stored failure too. The class has no eviction code. Removing only by key during cleanup could remove a newer replacement; a retry policy needs a conditional removal that matches the entry being retired.

    Boundary question: does one shared load for "item-7" also cap work across 10,000 different keys? No. Sharing is per key. This example contains no global loader-concurrency limit or automatic capacity bound.

    Multi-key and external invariants

    No dictionary method atomically updates two keys or coordinates the dictionary with a database. If the invariant spans multiple entries, use a lock with a clearly defined scope, an immutable snapshot replaced atomically, partitioned ownership, or move the invariant into a transactional store.

    Interview checklist

    • Name the exact operation that must be atomic.
    • Separate duplicate computation from duplicate publication.
    • Keep irreversible side effects outside factories that may repeat.
    • Define failure eviction and cancellation ownership.
    • Test with forced interleavings, not only a high iteration count.
    • Measure contention before choosing coarse locks or elaborate lock-free structures.

    The strong answer is not “ConcurrentDictionary is thread-safe.” It is a precise statement of which state transition is protected and which wider workflow still needs coordination.

    Check the behavior

    Use a loader gated by TaskCompletionSource, request the same key twice, and assert that the loader runs once. Cancel one caller’s wait and verify that the second caller still gets the result when the gate opens. Then make the loader fail in a fresh run: both uncanceled callers should observe the failure, and a subsequent call still sees it until the owner applies an explicit eviction policy. This exercise distinguishes shared work, a caller’s waiting lifetime, and cache lifetime.