devinside.Marcin Borecki Ideas. Systems. Evidence.
← The notebook

Engineering

Lazy<T> on a Hot Path: Factories, Contention, Exceptions and Allocations

A controlled Lazy<T> laboratory separates factory execution, publication, cached exceptions and resource ownership before interpreting cold-path and ready-read measurements.

Explore the mechanism ↓
Editorial ink and watercolor illustration for One published value can hide two factories.

START WITH THE MECHANISM

Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.

THE FULL INVESTIGATION

Lazy<T> on a Hot Path: Factories, Contention, Exceptions and Allocations

Editorial context: 3 July 2025 · Prepared and updated: 12 November 2026
Dates and technology context

This is a retrospective editorial timeline. Topic context: 3 July 2025. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.

Date the 23 checks, forced PublicationOnly overlap, runtime10.0.15 source inspection and cold/ready measurements 2026-11-12. Earlier core may explain mode choice but cannot claim those runs happened then.

Two threads ask a lazy value for the same object. They both receive the same reference. It is tempting to conclude that the object's factory ran once.

That conclusion is not valid for every Lazy<T> mode. A single published value can be the result of several competing factory executions. If those factories perform external actions, acquire resources or mutate shared state, the work that lost the publication race still happened.

This is why I would choose a lazy-initialization mode from the factory's contract before looking at its nanosecond cost. Deferring construction, publishing a value, retrying a failed factory and owning the resulting object are different responsibilities. A small wrapper can coordinate some of them without making the others disappear.

The accompanying C# laboratory is executable code. Its accepted correctness run passed 23 named checks on .NET 10.0.15 with SDK 10.0.400. The checks cover successful initialization in three modes, factory-exception behavior, concurrent shared identity, two deliberately overlapping PublicationOnly factories, and explicit cleanup of the test objects. The code was reviewed without changing that accepted version or rerunning the same checks unnecessarily.

The timing experiment is narrower: it measures single-threaded successful cold construction and reads of a value that is already initialized. It does not measure contention, failed initialization, real resource cleanup or retained heap. Those limits are part of the result, not a footnote to a universal ranking.

Decide what the factory is allowed to do

Start with the factory, not the wrapper. Does calling it twice merely repeat a calculation, or does it register two subscriptions, open two resources, send two requests or increment a business counter? Can callers safely share its result? Is an initialization failure permanent for this instance, or should a later caller be allowed to try again?

Imagine a parser table constructed from immutable application metadata. If two threads build equivalent tables and only one is retained, the extra work may be wasteful but harmless. Now imagine a factory that both opens a resource and registers it with another component. Discarding one returned reference does not reverse that registration. The two factories have very different publication requirements despite returning objects of the same apparent shape.

There is also a distinction between deferring construction and reducing total work. If the value is never requested, deferral can avoid the factory altogether. If the value is always requested at startup, the wrapper may only move when the work occurs. If first access is on a latency-sensitive request, the moved cost may become less convenient rather than smaller.

Lazy initialization is useful for values that are expensive to create and may not be used, or when delaying creation is itself desirable. It is not an unconditional performance improvement over ordinary construction. Microsoft's lazy-initialization overview.

For this laboratory the factory is deliberately boring. It returns a small Box containing the integer seven. A separate correctness scenario assigns increasing identifiers so distinct factory products can be recognized. This simplicity makes execution count and reference identity visible without introducing a database, network dependency or unrelated serialization cost.

The test object implements IDisposable, but its Dispose method only flips a boolean marker. It holds no file handle, socket or native allocation. That choice lets the test observe whether disposal was invoked without implying that it measured a real operating-system resource leak.

Three modes describe different contracts

01 / See the idea

Choose a factory contract, not a speed setting

Three Lazy modes differ in initialization synchronization and whether competing factories can run.

The mode controls how initialization is coordinated, not whether the object returned by the factory is safe for arbitrary concurrent mutation. The None row assumes the caller supplies the required single-threaded access.

Interpretation and evidence
  • None requires controlled access.
  • ExecutionAndPublication coordinates factory execution.
  • PublicationOnly can produce competing candidates.
documented contract and accepted 23-check fixture

The three modes in the fixture are None, ExecutionAndPublication and PublicationOnly. They are not three interchangeable speed settings for the same semantic promise.

None removes synchronization from lazy initialization. It is suitable only when the application ensures the necessary single-threaded access conditions. The fixture does not race this mode from multiple threads and then treat whichever result appears as a supported contract.

ExecutionAndPublication coordinates factory execution so callers share one published result. PublicationOnly permits competing initialization attempts while choosing one value for publication. With an explicit factory, the first two modes cache a thrown initialization exception; PublicationOnly does not. Constructor-based initialization without an explicit factory has different exception-caching details. LazyThreadSafetyMode documentation.

That distinction already answers many practical questions. If a factory cannot safely run twice, the successful PublicationOnly result having one identity is insufficient. If an explicit factory's failure should remain the result for the lifetime of this lazy instance, caching that failure may be desirable. If the underlying condition can recover, caching can make the instance remain unusable even after the environment changes.

None of these choices makes the returned object intrinsically safe for concurrent mutation. A thread-safe path to obtaining one reference does not add synchronization to that object's methods. If every caller receives the same mutable collection, its mutation contract still needs its own design.

I find it useful to write four questions beside a proposed factory: how many times may it execute, how many values may be published, what happens after a failure, and who eventually disposes the chosen value? A mode answers only part of that list. The remaining answers belong to the component using it.

Measure execution count separately from shared identity

The first correctness test is intentionally sequential. For each mode it checks that the value is initially absent, reads it twice, and verifies one factory execution and a stable reference:

int calls = 0;
var lazy = new Lazy<Box>(() =>
{
    calls++;
    return new Box(7);
}, mode);

Check(!lazy.IsValueCreated, mode + " initially absent");
var one = lazy.Value;
Check(ReferenceEquals(one, lazy.Value)
    && calls == 1
    && lazy.IsValueCreated,
    mode + " stable success");

This does not prove the same execution count under contention. It establishes a simpler contract: once this sequential initialization succeeds, the next read returns the published object rather than calling the factory again.

The separate concurrent ExecutionAndPublication test starts two tasks behind a gate. Its factory increments a counter with Interlocked.Increment, making the count itself safe to observe. The test checks both that the factory ran once and that the two task results refer to the same object.

The gate makes the callers eligible to begin together, but it does not turn this small test into a measurement of scheduling fairness or contention latency. One thread can still be scheduled first. The relevant accepted observation is that the two completed calls satisfy the mode's execution and identity contract.

Checking only the counter would be incomplete: one factory execution does not by itself prove that both callers obtained the same intended value. Checking only reference equality would also be incomplete, as the PublicationOnly test demonstrates next. Good tests keep these observations separate because the implementation is allowed to separate them.

This distinction generalizes beyond Lazy<T>. A cache can publish one entry after duplicate loads. A task memoizer can share a result after several requests started work. One visible outcome does not imply one attempt. When attempts have effects or costs, count them directly.

Force competing factories to overlap

02 / Watch the mechanism

Two factories can publish one identity

Two PublicationOnly factories both create candidates, meet at a barrier, then callers receive one shared published reference.
Enlarge diagram

Static view. Play to see how the mechanism unfolds.

The executed test forces both factories to create a Box before either returns. It observes two created objects and one shared published reference without assuming which candidate wins.

Interpretation and evidence
  • Count executions independently.
  • Do not infer one attempt from one result.
  • The winning candidate is not predetermined.
documented contract and accepted 23-check fixture

The PublicationOnly test places a two-participant barrier inside the factory. Both factories must create their objects and reach the barrier before either proceeds to return its candidate value.

using var rendezvous = new Barrier(2);
int calls = 0;
var created = new System.Collections.Concurrent.ConcurrentBag<Box>();

var lazy = new Lazy<Box>(() =>
{
    var box = new Box(Interlocked.Increment(ref calls));
    created.Add(box);
    if (!rendezvous.SignalAndWait(TimeSpan.FromSeconds(5)))
        throw new Exception("Rendezvous failed");
    return box;
}, LazyThreadSafetyMode.PublicationOnly);

var result = await Task.WhenAll(
    Task.Run(() => lazy.Value),
    Task.Run(() => lazy.Value))
    .WaitAsync(TimeSpan.FromSeconds(10));

A barrier coordinates participants at a phase boundary. Here its purpose is to force the interesting overlap, not to manufacture CPU load. If the second factory never arrives, the fixture fails through a finite timeout rather than hanging indefinitely. Barrier.SignalAndWait documentation.

The accepted run observed two factory calls and two created boxes, while both returned task results referenced the same published box. This is the counterexample to the opening assumption. The losing factory was real work, not a speculative expression removed from the program because its object was not published.

The test does not assume which identifier wins. Requiring a particular thread's value would turn an irrelevant scheduling choice into a brittle assertion. The semantic requirement is one shared published identity drawn from the valid candidates, not a guarantee that thread A always wins.

The candidate objects are retained in the concurrent bag for inspection. That retention is test instrumentation. It lets the fixture examine both candidates even though ordinary callers receive only the winner. It would be wrong to point at that bag later and claim that Lazy<T> itself retained every losing object.

This is also why a contention test and a contention benchmark are different artifacts. The barrier deliberately changes scheduling to expose a behavior. Timing that barrier would mostly tell us about the fixture's coordination. The included performance run does not do that; it stays single-threaded and successful.

Discarded does not mean disposed

After the competing factories complete, the test checks that neither Box has had its disposal marker set. It then explicitly disposes every box in the fixture's bag and verifies that all markers changed.

Check(created.All(x => !x.Disposed),
    "No implicit disposal observed for competing fixture objects");

foreach (var box in created)
    box.Dispose();

Check(created.All(x => x.Disposed),
    "Fixture disposes all created objects");

The narrow observation is important: no implicit Dispose call occurred for these candidates in the tested publication path. The fixture then completed its own cleanup. This is not a measurement of file descriptors, finalization latency or native memory.

For a factory returning a real disposable resource, the design must establish ownership before choosing a mode that can create losers. Returning one published resource does not tell the losing factory that it should close its resource, nor does it automatically undo side effects performed before return.

A factory cannot generally solve this by immediately disposing what it just created, because it does not yet know whether that object will be the published winner. Nor is checking a shared flag after return a clean solution: publication and consumption can race with ad hoc cleanup logic.

One useful approach is to make competing factory work pure or to limit it to harmless immutable candidates. Another is to choose a mode that does not allow duplicate execution for the relevant factory. If the domain truly needs speculative resource acquisition, it deserves an explicit ownership protocol rather than assuming a general lazy wrapper supplies one.

The published value also needs an eventual owner. A field containing Lazy<Resource> can hide the resource's creation, but it does not remove the component's responsibility for its lifetime. Whether the component should force initialization during disposal is a separate question; many designs should avoid creating a resource merely to dispose it.

For this reason I would review disposal and publication together. Optimizing the first read is a poor trade if the resulting ownership model is harder to prove correct.

A cached failure is a result of initialization

03 / A concrete example

A failed Value read can change the next read

Two reads of an always-throwing supplied factory cause one factory execution for None and ExecutionAndPublication, but two for PublicationOnly.

These are observed counts from two Value reads of an explicit factory that always throws. IsValueCreated remains false in all three cases, but false does not imply that another factory execution will occur.

Interpretation and evidence
  • The test uses an explicit factory.
  • Cached failure can prevent another attempt.
  • IsValueCreated does not encode retry policy.
documented contract and accepted 23-check fixture

The exception test gives every mode a factory that always throws. It requests the value twice, confirms that both reads throw, and then inspects how many times the factory actually ran.

int failures = 0;
var failure = new Lazy<Box>(() =>
{
    failures++;
    throw new InvalidOperationException("fixture");
}, mode);

for (int i = 0; i < 2; i++)
{
    bool caught = false;
    try { _ = failure.Value; }
    catch (InvalidOperationException) { caught = true; }
    Check(caught, mode + " exception " + i);
}

Check(failures ==
    (mode == LazyThreadSafetyMode.PublicationOnly ? 2 : 1),
    mode + " exception factory count");

In the recorded run, the explicit factory ran once for None and ExecutionAndPublication, and twice for PublicationOnly. All three instances reported IsValueCreated == false after their failures.

That last observation is easy to misuse. False does not mean the instance is equivalent to a fresh, never-accessed lazy value. With a cached exception, another Value read can throw without executing the factory again. The property describes whether a value was created, not whether another attempt is permitted. Lazy.IsValueCreated documentation.

Imagine a factory that loads a required immutable application setting. If the setting is invalid, caching the failure may make the component's invalid state clear and stable. Now imagine a factory that reaches a temporarily unavailable external service. Caching the first outage for the entire lifetime of a singleton can be a surprising recovery policy.

PublicationOnly avoids that particular cached-failure behavior, but it is not a complete retry policy. It provides no application-specific backoff, attempt bound, circuit state or rule for coordinating callers during an outage. Multiple readers can create more factory attempts, which may be precisely the wrong response to an overloaded dependency.

The lesson is not to pick the non-caching mode whenever failures seem temporary. It is to decide whether Lazy<T> is the right place to encode recovery at all. A refreshable cache or explicit initialization state machine may express the intended behavior more honestly.

Keep constructor and factory overloads distinct

All executed initialization tests in this laboratory supply a Func<Box>. That matters. The documentation distinguishes explicit factories from lazy instances that invoke the parameterless constructor of T without a supplied factory. A rule summarized as “this mode caches exceptions” can become inaccurate when that distinction is omitted.

Even a factory whose body is only new T() is still an explicit factory for this purpose. The constructor overload chosen by the caller affects the lazy wrapper's exception policy; visual similarity of the eventual object-creation expression does not erase that difference.

This article therefore reports the tested claim narrowly: explicit throwing factories were invoked once or twice according to the observed mode. It does not pretend that the laboratory executed every constructor overload. The documented overload distinction is useful context, not an additional measured result.

There is another boundary when T itself is a task. Publishing a Task<TValue> and completing that task are different events. A lazy wrapper can successfully publish a task that later fails asynchronously. The failure behavior of that task is not automatically the same mechanism as a synchronous exception thrown before the factory returns a value.

The current fixture does not test Lazy<Task<T>>, async initialization or cancellation. Bringing those into a production design requires naming both lifetimes and deciding whether callers share one attempt or create new attempts after failure. It would be misleading to inherit the synchronous factory table as a complete async recovery policy.

Restricting the claim is not avoiding an important subject. It protects a small, useful experiment from becoming an untested tutorial about several different abstractions.

Read the runtime source without inventing a heap result

04 / See the idea

A reference path is not a heap measurement

A Lazy object can reference its factory and captured state before initialization; a separate owner can keep the state alive even after that factory reference is cleared.

The source shows the stored factory reference being cleared on initialization paths. That removes one possible reference path, but an independent owner or the published value can still keep related state reachable.

Interpretation and evidence
  • Source reveals reference relationships.
  • Clearing one edge does not prove collection.
  • Retained heap was not measured here.
source-supported conceptual reference graph, not measured retention

The exact .NET release source helps connect the observed behavior to a mechanism. The checked implementation stores a factory reference and a state helper. The execution-and-publication path uses synchronization around initialization; the publication-only path can call the factory before competing to install a value. The factory-based paths also clear the stored factory reference as part of initialization. Lazy implementation at the .NET 10 release tag.

These details explain why factory execution and publication can differ. They do not establish the lifetime of every object a real closure references. Another part of the application can still retain the same state, and a published object can itself reference that state.

Consider an illustrative factory that captures a configuration object. Before initialization, a reachable lazy wrapper can keep the delegate and captured state reachable. Once the stored delegate reference is cleared, one path may disappear, but that is not proof that the configuration was collected. Proving collection would require examining all relevant roots or a carefully designed lifetime test.

The performance fixture avoids that extra question by using a small non-capturing factory for its measured paths. Its correctness factories capture counters and a bag because the tests need those observations; those are not the factories being timed. Mixing the two would charge test instrumentation to production initialization and confuse the result.

Similarly, the source's use of a helper object is a reason to investigate allocation, not a license to guess a byte count from a code listing. Object layout, runtime version, generic type, delegate reuse and JIT decisions can affect the observed total. The actual allocation measurement follows below.

This is the boundary between useful source reading and an invented memory profile. Source can show references and control flow. A measured allocation counter can show bytes allocated in a selected interval. Neither alone measures retained heap in an arbitrary application.

Separate cold creation from ready reads

05 / See the idea

Cold construction and ready reads are distinct

All six mode-and-state timing cases show medians and observed ranges, including the slow ready None sample.

Cold includes constructing a new Lazy and Box; ready starts with an initialized value. The displayed range preserves the unusually slow ready None sample, and none of these timings measures concurrent initialization.

Interpretation and evidence
  • Compare the same measured boundary.
  • Keep outliers visible.
  • Do not infer contention from single-thread timing.
measured; benchmark-results.json

The benchmark has two states. In ready, the lazy instance is created and initialized before the measured loop. Every iteration then reads the integer from the already-published box. In cold, each measured iteration constructs a new lazy wrapper, accesses its value and constructs a new box through the factory.

The central measured expression is the same as the laboratory source:

sum += state == "ready"
    ? lazy.Value.Value
    : new Lazy<Box>(() => new Box(7), mode).Value.Value;

Each sample performs 100,000 iterations after 1,000 warm-up iterations for the chosen state. The result sum must equal seven times the iteration count. Six samples are recorded for every mode and state, and the mode order reverses on alternating samples.

The cold measurement is not the isolated cost of the first Value access on an existing wrapper. It includes constructing the wrapper and box. The ready measurement is not a prediction of request latency; it is a tight loop reading a tiny already-initialized value.

Neither state introduces concurrent callers. It would therefore be incorrect to use this timing table to claim that one mode scales better under contention. The correctness suite establishes that certain concurrent behaviors occurred; the benchmark measures successful single-threaded paths. They answer different questions.

The non-capturing lambda can be reused by compiler-generated machinery. The fixture does not deliberately create a unique captured closure per iteration, and it does not inspect generated IL to attribute every allocation. A user with a capturing factory, a larger result or real resource acquisition has a different workload.

Allocation is measured using the current-thread managed-allocation counter around the synchronous loop. It is not a retained-memory or native-resource measurement. The counter reports allocations associated with the current thread over the selected interval; collection survival is a separate question. GC.GetAllocatedBytesForCurrentThread documentation.

The Box objects used in this benchmark have no external resource. Their disposal marker is not invoked in the timed cold loop because disposal cost is not part of that benchmark. This is safe for this deliberately resource-free fixture and must not be copied as a lifetime policy for a real disposable result.

The integrator recorded the local measurement on 12 November 2026 at 13:56 UTC, with exit code zero. It used the unchanged accepted laboratory on .NET 10.0.15 in the serialized task-benchmark lane. The following table includes all three modes and both states. Times are nanoseconds per loop operation; allocation is managed bytes per operation on the measured thread.

State Mode Median ns/op Observed ns/op range Allocated B/op range
ready None 2.397 1.521–183.353 0–0.00128
ready ExecutionAndPublication 1.496 1.496–1.795 0–0
ready PublicationOnly 1.577 1.496–1.754 0–0
cold None 33.541 33.284–54.043 64–64.0006
cold ExecutionAndPublication 50.018 47.830–59.029 96–96
cold PublicationOnly 35.459 34.779–35.816 64–64

The cold results show 64 bytes per operation for most samples of None and PublicationOnly, and 96 for ExecutionAndPublication. These totals belong to this particular wrapper-plus-Box construction path. The source's additional synchronization state is consistent with a mechanism worth investigating, but this experiment does not use an allocation-stack profile or object-layout analysis to attribute every byte independently.

The ready path usually reported zero managed allocation. One None sample reported 0.00128 bytes per operation, corresponding to 128 bytes over the entire measured loop, while one cold sample had a similarly tiny additional batch allocation. Those small amortized values should remain in the report rather than be rounded into a stronger universal zero-allocation claim.

The ready None timing range is also a warning against treating a tiny median as a stable end-to-end cost: one sample reached 183.353 ns/op, while the fastest was 1.521. The benchmark retained that sample. Warm-up iterations alone do not prove that compilation state and host conditions have stabilized. We did not run a separate investigation that attributes the outlier to a specific cause.

The other ready medians were approximately 1.5 ns/op, and the cold medians ranged from about 33.5 to 50.0 ns/op. These are observations of a tight loop with its branch, property reads and accumulation still included. No empty-loop baseline was subtracted. They do not justify claiming that an HTTP request will become measurably faster by changing its lazy mode.

Most importantly, the cold rows compare modes with different correctness contracts. A lower construction time cannot make duplicate factory side effects acceptable, and a zero-allocation ready read cannot make unsynchronized first access safe. The small timing advantage of one row is relevant only after the required initialization and failure behavior has been established.

No contention timing, exception-path timing or retained-heap result was collected. The concurrency evidence remains the separate controlled correctness test. A future performance question about many simultaneous first callers would require a different harness and an explicit factory workload, rather than relabeling these single-thread results.

Choose a mode by the failure you cannot accept

If duplicate factory execution would perform an invalid external action, no small timing advantage can make publication-only initialization correct for that factory. If all access is not controlled to satisfy the requirements of None, a faster single-threaded sample does not supply missing synchronization.

If initialization failure must be recoverable, define how recovery works. Replacing a lazy instance can create a fresh attempt, but replacement needs its own ownership and concurrency rules. An explicit retrying component may be clearer than relying on repeated Value reads to drive an accidental retry policy.

If construction is cheap and the value is always used, compare the design with eager initialization. The included benchmark does not provide that comparison, so it cannot justify adding Lazy<T> to a property merely because ready reads are inexpensive. The wrapper may still be useful for dependency ordering or avoiding unnecessary work, but that is a separate argument.

If the value is expensive and rarely used, deferral can be valuable even when the first read carries synchronization overhead. In that case the application-level question is how often construction is avoided and where the remaining first-use latency appears. The nanosecond loop is only one part of that decision.

For each proposed use I would keep a compact contract beside the code: permitted concurrent readers, whether duplicate factory attempts are harmless, the meaning of initialization failure, and the owner of any created resource. These facts make the mode choice reviewable before a profile becomes relevant.

Reproduce the evidence at its actual boundary

The local project pins SDK 10.0.400 and runtime 10.0.15. It uses the runtime's APIs directly and has no third-party package or external service dependency. Its accepted correctness evidence came from the root task's recorded run and was reused after the unchanged source was reviewed.

From the laboratory directory, the correctness command is:

dotnet run -c Release -- check

The compiled measurement command is:

dotnet bin/Release/net10.0/LazyLab.dll measure

The measurement command writes JSON to standard output. Run it in an isolated task-measurement window and preserve that output with the actual execution environment. It is a custom stopwatch harness, not BenchmarkDotNet, and six within-process samples do not establish a universal performance distribution.

The strongest result of this exercise is the separation of contracts. One published value does not necessarily mean one factory call. No published value does not necessarily mean the factory may retry. A discarded candidate does not imply a disposal call. A cheap ready read does not establish a cheap first access or a safe ownership model.

Once those distinctions are explicit, the performance numbers become useful. They can help quantify a valid design choice instead of tempting us to choose an invalid contract because its smallest row looks attractive.

Source code and execution evidence

Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.

THE AUTHOR

Marcin Borecki

Developer, entrepreneur and business angel. Writing about AI, software, data and the evidence behind technical decisions.

About the author →