CLR & Concurrency · Lesson 17

ConcurrentDictionary factories may run more than once

Atomic publication does not mean the value factory executes exactly once.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

The runtime contract

ConcurrentDictionary makes particular collection operations safe under concurrency, but GetOrAdd does not promise its value factory executes exactly once. A factory may run outside internal dictionary locks and several contenders can compute candidates before one value is retained. The portable companion shows only the sequential existing-key path and must not be read as concurrent construction evidence. The desktop project deliberately coordinates two factories so both enter before either can publish. Their candidate identities may differ, but both callers receive the retained value. This distinguishes atomic association publication from the ownership and side effects of speculative value creation.

Trace the portable program

The first portable GetOrAdd inserts two for key a and increments the factory counter once. The second call finds that existing association, returns two, and does not run its factory returning ninety-nine. Stdout is first:2, second:2, and factory-calls:1. In the external two-worker test, a barrier inside the callback forces two candidate constructions. Both returned values agree with the stored association, the dictionary has one entry, and factory-calls is two. The winner's numeric candidate is deliberately not asserted because the scheduler need not choose the same winning worker on every run.

Execute the actual desktop lab

The desktop factory increments a candidate counter before meeting another factory at a barrier. Because the dictionary is initially empty and neither callback can finish first, both callbacks genuinely run. After publication, both callers receive the same retained association and the dictionary contains one entry. The candidate value that wins is intentionally not printed as a fixed expected number. All synchronization workers are bounded and joined. This is stronger than launching two tasks and assuming the scheduler must overlap them, and it cleanly demonstrates why association atomicity does not imply one execution of arbitrary user factory code.

Pitfalls and boundary limits

Factories that allocate disposable objects or perform external effects need an ownership policy for candidates that lose publication. A dictionary cannot automatically undo an effect or dispose every rejected candidate safely. A Lazy wrapper can sometimes defer expensive work behind the retained value, but its mode, failure caching, removal, and retry behavior still need a deliberate contract. Removing a key later also changes lifetime and possible construction history. Do not describe a concurrent map as a permanent exactly-once registry merely because one GetOrAdd call returned one value. Its documented operation boundary is narrower than the lifecycle of the resource associated with a key.

Practice one: isolate behavior

First use a different key in the second portable call and predict that its factory runs, producing a second entry and another factory call. Hint: existing association lookup and absent-key construction are separate paths. Then restore the same key and change the second factory's candidate to a visibly different number, confirming that the already stored two is returned. Keep expected counts and values independent. In the desktop experiment, retain the callback barrier if your claim requires proving two factory invocations; simply starting workers together is not sufficient scheduling evidence.

Practice two: design and verify

For another exercise, design a cache of expensive resource holders where losing candidates have explicit cleanup and consumers cannot use a disposed winner. Specify removal and failure behavior before choosing Lazy or another wrapper. Hint: putting an irreversible network call inside the raw value factory is not made safe by the dictionary's thread safety. Test both a reused association and genuinely competing candidate creation with controlled gates. If asynchronous work is shared, define who owns cancellation and whether failures remain cached rather than assuming every caller can cancel or recreate the same operation independently.

Review the evidence

Review concurrent-map use by separating lookup, candidate computation, publication, returned value, resource lifetime, and later removal. The sequential companion demonstrates a cached-key path only. The desktop project proves controlled multiple factory calls and one retained association, without predicting a particular winning worker. These results do not establish exactly-once external work, waiter fairness, or a universal contention rate. A safe design treats callbacks as repeatable speculation unless an additional owner or protocol provides a stronger guarantee, and it tests that stronger guarantee through the actual resource and lifecycle boundary rather than through a map's name.

External .NET 10 SDK / desktop runtime. OS-thread experiments run only here; the .NET 9 browser companion has the narrower scope stated in the guide.

ConcurrentDictionary factories may run more than once — desktop runtime lab

Set up the SDK project

  1. Save both files at their exact paths in a fresh owned directory.
  2. Use a .NET 10 SDK, then run dotnet run --project RuntimeLab.csproj. This project intentionally uses C# 13 syntax.
  3. Workers and waits are bounded; a timeout or a violated invariant fails the process. No benchmark or NativeAOT publication is implied by an ordinary run.

Download complete SDK project ↗

Project files

Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var values = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
using var factories = new Barrier(2);
int calls = 0;
int[] results = new int[2];
int Factory(string key) { int candidate = Interlocked.Increment(ref calls); if (!factories.SignalAndWait(TimeSpan.FromSeconds(5))) throw new TimeoutException(); return candidate; }
OwnedThreads.Run(() => results[0] = values.GetOrAdd("a", Factory), () => results[1] = values.GetOrAdd("a", Factory));
Console.WriteLine($"shared:{results[0] == results[1] && results[0] == values["a"]}");
Console.WriteLine($"entries:{values.Count}");
Console.WriteLine($"factory-calls:{calls}");

public static class OwnedThreads
{
    public static void Run(params Action[] actions)
    {
        var errors = new ConcurrentQueue<Exception>();
        var threads = actions.Select(action => new Thread(() => { try { action(); } catch (Exception error) { errors.Enqueue(error); } }) { IsBackground = true }).ToArray();
        foreach (var thread in threads) thread.Start();
        foreach (var thread in threads) if (!thread.Join(TimeSpan.FromSeconds(8))) throw new TimeoutException("Owned worker did not finish");
        if (!errors.IsEmpty) throw new AggregateException(errors);
    }
}
RuntimeLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>13.0</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <PublishAot>false</PublishAot>
  </PropertyGroup>
</Project>

Expected result

Exact behavioral stdout on the stated desktop lab: shared:True entries:1 factory-calls:2 Variable diagnostics, where emitted, go to stderr and must not be treated as portable timing or allocation guarantees.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Reflection;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;

var values = new ConcurrentDictionary<string, int>(StringComparer.Ordinal);
int calls = 0;
int first = values.GetOrAdd("a", _ => { calls++; return 2; });
int second = values.GetOrAdd("a", _ => { calls++; return 99; });
Console.WriteLine($"first:{first}");
Console.WriteLine($"second:{second}");
Console.WriteLine($"factory-calls:{calls}");
Open C# practice

C# practice output

first:2
second:2
factory-calls:1
Try this
The sequential companion is not evidence of concurrent exactly-once construction. The desktop callback barrier forces both factories to enter before publication.

Further reading

Interview questions for this lesson