Start Debugging

Lazy<T> vs LazyInitializer.EnsureInitialized vs Double-Checked Locking in C#

Verwenden Sie standardmäßig Lazy<T>: Es führt die Factory genau einmal aus, speichert Ausnahmen zwischen, und seine Lesezugriffe sind nach der Initialisierung zu billig, um messbar zu sein. Nutzen Sie LazyInitializer.EnsureInitialized, wenn Sie viele Felder oder Array-Elemente lazy befüllen und sich ein zusätzliches Objekt pro Wert nicht leisten können. Handgeschriebenes Double-Checked Locking lohnt sich unter .NET 10 fast nie.

Verwenden Sie standardmäßig Lazy<T>. Mit dem Standardmodus des Konstruktors (ExecutionAndPublication) garantiert es, dass die Factory selbst bei Konkurrenz genau einmal läuft, es speichert die Ausnahme der Factory zwischen, und sobald der Wert existiert, besteht ein Lesezugriff aus einer Null-Prüfung und einem Feldzugriff, zu billig, als dass BenchmarkDotNet ihn messen könnte. Greifen Sie zu LazyInitializer.EnsureInitialized, wenn Sie viele lazy erzeugte Werte haben (ein Feld pro Eigenschaft in einem häufig instanziierten Typ oder Elemente in einem Array) und das zusätzliche Lazy<T>-Objekt pro Wert genau die Kosten sind, die Sie vermeiden wollen. Handgeschriebenes Double-Checked Locking bietet Ihnen nichts, was LazyInitializer.EnsureInitialized(ref field, ref syncLock, factory) nicht ohnehin bietet. Schreiben Sie es also nur, wenn Sie ein Verhalten brauchen, das keine der beiden APIs liefert.

Alles Folgende wurde auf .NET 10.0.10 (SDK 10.0.302), C# 14, BenchmarkDotNet 0.15.8 auf einem Apple M4 (arm64) gemessen. Die APIs haben seit .NET Core 3.0 ihre Form nicht geändert, daher gilt die Empfehlung für .NET 8, 9, 10 und 11.

Der Vergleich auf einen Blick

Lazy<T> (Standard)LazyInitializer.EnsureInitialized(ref t, f)EnsureInitialized(ref t, ref sync, f)Double-Checked Locking
Factory läuft bei Konkurrenz höchstens einmalJaNein, jeder konkurrierende Thread kann sie ausführenJaJa, wenn korrekt geschrieben
Ausnahme aus der FactoryZwischengespeichert, wird immer wieder geworfenNicht zwischengespeichert, der nächste Aufruf versucht es erneutNicht zwischengespeichert, der nächste Aufruf versucht es erneutJe nach Ihrer Implementierung
Zusätzliche Allokation pro Lazy-WertLazy<T>-Objekt plus Lock-HelferKeineEin Lock-Objekt, nur bei Konkurrenz oder beim ersten AufrufEin Lock-Objekt (oder geteilt)
Heißer Lesezugriff nach Init (gemessen)unter 1 ns, nicht messbarunter 1 nsunter 1 nsunter 1 ns
Einmaliges Erzeugen + Init (gemessen)13,96 ns, 96 B6,19 ns, 48 B14,03 ns, 80 B8,96 ns, 72 B
Funktioniert mit WerttypenJaNein (where T : class)Nein, nutzen Sie die Überladung mit ref bool initializedJa, mit separatem Flag
Factory darf null zurückgebenJaNein, wirft InvalidOperationExceptionNein, wirft InvalidOperationExceptionJa, mit separatem Flag
Reentranz aus der FactoryWirft InvalidOperationExceptionStack OverflowStack Overflow (Monitor ist reentrant)Hängt vom Lock-Typ ab
Code, den Sie selbst pflegen1 Zeile1 Zeile2 Felder + 1 Aufrufca. 10 Zeilen

Was jede Option tatsächlich tut

Lazy<T>

Lazy<T> umhüllt eine Factory und einen Speicherplatz. Seine Eigenschaft Value besteht in .NET 10 aus einer Zeile:

// System.Private.CoreLib, release/10.0, src/System/Lazy.cs
public T Value => _state == null ? _value! : CreateValue();

_state ist eine volatile LazyHelper-Referenz, die den Modus beschreibt und bei ExecutionAndPublication zugleich als Lock-Objekt dient. Der erste Aufruf geht in CreateValue, das lock (executionAndPublication) nimmt, erneut prüft, Ihre Factory ausführt, _value speichert und dann _state = null mit einem volatilen Schreibzugriff setzt. Jeder spätere Aufruf sieht _state == null und gibt das Feld zurück. Das ist Double-Checked Locking, einmal geschrieben von Leuten, die die Spezifikation des Speichermodells sorgfältiger gelesen haben als die meisten von uns.

Der Thread-Sicherheitsmodus wird über den Konstruktor gewählt:

// .NET 10, C# 14
var a = new Lazy<Config>(LoadConfig);                                          // ExecutionAndPublication
var b = new Lazy<Config>(LoadConfig, isThreadSafe: true);                      // ExecutionAndPublication
var c = new Lazy<Config>(LoadConfig, LazyThreadSafetyMode.PublicationOnly);    // threads race, first publish wins
var d = new Lazy<Config>(LoadConfig, LazyThreadSafetyMode.None);               // single-threaded only

LazyInitializer.EnsureInitialized

LazyInitializer ist eine statische Klasse, die über einen ref auf einem Feld arbeitet, das Ihnen gehört. Es gibt kein Wrapper-Objekt. Die Überladung mit zwei Argumenten lautet vollständig:

// System.Private.CoreLib, release/10.0, src/System/Threading/LazyInitializer.cs
public static T EnsureInitialized<T>(ref T? target, Func<T> valueFactory) where T : class =>
    Volatile.Read(ref target!) ?? EnsureInitializedCore(ref target, valueFactory);

private static T EnsureInitializedCore<T>(ref T? target, Func<T> valueFactory) where T : class
{
    T value = valueFactory() ?? throw new InvalidOperationException(SR.Lazy_StaticInit_InvalidOperation);
    Interlocked.CompareExchange(ref target, value, null!);
    return target;
}

Kein Lock. Jeder Thread, der null sieht, führt die Factory aus, und CompareExchange entscheidet, dessen Ergebnis gespeichert wird. Die Objekte der Verlierer werden einfach verworfen (und die Dokumentation weist darauf hin, dass die Methode “will not dispose of the objects that were not stored”). Das sind PublicationOnly-Semantik ohne den Wrapper.

Die Überladung mit drei Argumenten, EnsureInitialized(ref target, ref syncLock, valueFactory), fügt ein Lock hinzu. Sie erzeugt per CompareExchange lazy ein einfaches object in syncLock und führt dann lock (syncLock) { if (Volatile.Read(ref target) == null) Volatile.Write(ref target, valueFactory()); } aus. Das ist Double-Checked Locking im Lehrbuchstil, und die Factory läuft einmal.

Für Werttypen oder wenn null ein legitimes Ergebnis ist, gibt es Überladungen mit einem zusätzlichen Flag ref bool initialized. Sie nehmen immer den Lock-Pfad.

Handgeschriebenes Double-Checked Locking

Die korrekte moderne Variante ab .NET 9 mit dem Typ System.Threading.Lock:

// .NET 10, C# 14
public sealed class ReportCache
{
    private volatile Report? _report;
    private readonly Lock _lock = new();

    public Report Report
    {
        get
        {
            var r = _report;               // one volatile read on the fast path
            if (r is not null) return r;
            lock (_lock)
            {
                return _report ??= BuildReport();
            }
        }
    }

    private static Report BuildReport() => new();
}

public sealed class Report;

Das volatile leistet echte Arbeit. Die Spezifikation des .NET-Speichermodells (docs/design/specs/Memory-model.md in dotnet/runtime) besagt zwar, dass das Speichern einer Objektreferenz in gemeinsamen Speicher ein Release ist und datenabhängige Lesezugriffe darüber geordnet sind, sodass der klassische Fehler mit dem “halb konstruierten Objekt” auf CoreCLR nicht auftreten kann. Sie warnt aber auch ausdrücklich davor, die Ordnungs-Nebeneffekte der Referenzzuweisung für allgemeine Ordnungszwecke zu verwenden, und ein nicht-volatiles Feld erlaubt dem JIT, Lesezugriffe auf _report herauszuziehen oder zusammenzulegen. Markieren Sie es als volatile oder lesen Sie es mit Volatile.Read, und das Muster ist auf jeder Architektur korrekt, auf der .NET läuft.

Das Verhalten bei Konkurrenz belegen

Die Dokumentation sagt “may run more than once”. Ich wollte eine Zahl. Diese Probe startet 16 Threads an einer Barrier, lässt alle gleichzeitig auf den Lazy-Wert zugreifen, nutzt eine Factory, die etwa 20.000 Iterationen lang wartet, um das Race-Fenster zu vergrößern, und wiederholt das 200-mal, wobei die schlechteste Runde festgehalten wird:

// .NET 10.0.10, C# 14, Apple M4, 10 cores
static int Race(Func<Func<object>, Func<object>> makeAccessor)
{
    int maxCalls = 0;
    for (int r = 0; r < 200; r++)
    {
        int calls = 0;
        Func<object> factory = () =>
        {
            Interlocked.Increment(ref calls);
            Thread.SpinWait(20_000);
            return new object();
        };
        var access = makeAccessor(factory);
        using var barrier = new Barrier(16);
        var results = new object[16];
        var threads = Enumerable.Range(0, 16)
            .Select(i => new Thread(() => { barrier.SignalAndWait(); results[i] = access(); }))
            .ToArray();
        foreach (var t in threads) t.Start();
        foreach (var t in threads) t.Join();
        if (results.Distinct().Count() != 1) throw new Exception("different instances returned");
        maxCalls = Math.Max(maxCalls, calls);
    }
    return maxCalls;
}

Ergebnisse, schlechteste Runde von 200:

OptionMax. Factory-Aufrufe in einer Runde
Lazy<T> Standard (ExecutionAndPublication)1
Lazy<T> PublicationOnly11
LazyInitializer.EnsureInitialized(ref t, f)12
LazyInitializer.EnsureInitialized(ref t, ref sync, f)1

Jede Option lieferte in jeder Runde dieselbe Instanz an alle 16 Threads, keine davon ist also “unsicher”. Der Unterschied ist verschwendete Arbeit. Wenn Ihre Factory einen Socket öffnet, eine 40 MB große Datei liest oder eine kostenpflichtige API aufruft, sind 12 parallele Ausführungen ein echter Fehler, auch wenn der veröffentlichte Wert korrekt ist.

Die Falle beim Zwischenspeichern von Ausnahmen

Das ist der Verhaltensunterschied, der in Produktion zuschlägt. Dieselbe Factory, die beim ersten Aufruf fehlschlägt und beim zweiten gelingt:

// .NET 10.0.10, C# 14
int attempts = 0;
Func<string> flaky = () => ++attempts == 1 ? throw new InvalidOperationException("transient") : "ok";

var lazy = new Lazy<string>(flaky);
// lazy.Value -> InvalidOperationException: transient
// lazy.Value -> InvalidOperationException: transient   (factory not called again)

string? field = null;
// LazyInitializer.EnsureInitialized(ref field, flaky) -> InvalidOperationException: transient
// LazyInitializer.EnsureInitialized(ref field, flaky) -> "ok"

Die Probe bestätigte es: Das Standard-Lazy<T> rief die Factory einmal auf und warf danach bei jedem Zugriff dieselbe Ausnahme. PublicationOnly und LazyInitializer riefen sie zweimal auf und erholten sich.

Die Ausnahmetabelle von MS Learn nennt die Regel: Jeder Lazy<T>-Konstruktor, der eine valueFactory entgegennimmt, speichert Ausnahmen zwischen, außer der Modus ist PublicationOnly. Der parameterlose Konstruktor (der Activator.CreateInstance verwendet) tut das nicht. Ein Lazy<HttpClient>, dessen Factory einen beim Start fehlenden Konfigurationswert liest, wirft also für die gesamte Lebensdauer des Prozesses, selbst wenn die Konfiguration später auftaucht. Wenn der Fehler vorübergehend sein kann, verwenden Sie entweder PublicationOnly, verwenden Sie LazyInitializer, oder ersetzen Sie die Lazy<T>-Instanz in einem catch.

Dieselbe Falle ist bei Lazy<Task<T>> doppelt tückisch: Die Factory wirft nicht, sondern liefert einen fehlgeschlagenen Task, und dieser fehlgeschlagene Task wird in jedem Modus als erfolgreicher Wert zwischengespeichert. Wenn Sie asynchrone Arbeit zwischenspeichern, prüfen Sie IsFaulted und tauschen Sie die Instanz aus, oder nutzen Sie einen echten Cache.

Der Benchmark

Zwei Fragen: Was kostet ein Lesezugriff, sobald der Wert existiert, und was kostet jede Option pro Instanz, wenn Sie den Besitzer erzeugen und den Wert einmal anfassen. Methodik: BenchmarkDotNet 0.15.8, Standard-Job, [MemoryDiagnoser], .NET 10.0.10 arm64 RyuJIT, Apple M4, macOS. Jede Holder-Klasse stellt eine Eigenschaft Value bereit; Expensive ist eine winzige Klasse, sodass die Messung die Lazy-Mechanik erfasst und nicht die Factory. Der vollständige Quelltext sind die Klassen HotPath und ColdPath unten.

// .NET 10.0.10, C# 14, BenchmarkDotNet 0.15.8
public sealed class LazyHolder
{
    private readonly Lazy<Expensive> _value = new(() => new Expensive());
    public Expensive Value => _value.Value;
}

public sealed class LazyInitHolder
{
    private Expensive? _value;
    public Expensive Value => LazyInitializer.EnsureInitialized(ref _value, static () => new Expensive());
}

public sealed class LazyInitLockHolder
{
    private Expensive? _value;
    private object? _sync;
    public Expensive Value => LazyInitializer.EnsureInitialized(ref _value, ref _sync, static () => new Expensive());
}

[MemoryDiagnoser]
public class ColdPath
{
    [Benchmark(Baseline = true)] public int Plain() => new PlainHolder().Value.Id;
    [Benchmark] public int Lazy_ExecutionAndPublication() => new LazyHolder().Value.Id;
    [Benchmark] public int LazyInitializer_Factory() => new LazyInitHolder().Value.Id;
    [Benchmark] public int LazyInitializer_SyncLock() => new LazyInitLockHolder().Value.Id;
    [Benchmark] public int DoubleChecked_Lock() => new DoubleCheckedHolder().Value.Id;
    [Benchmark] public int Lazy_NeverTouched() => new LazyHolder() is null ? 0 : 1;
}

Heißer Pfad (Wert bereits erzeugt, ein Lesezugriff):

Methode (.NET 10.0.10, arm64)MittelwertAllocated
Einfaches Feld (??=, nicht Thread-sicher)0,000 ns0 B
Lazy<T> ExecutionAndPublication0,000 ns0 B
Lazy<T> PublicationOnly0,000 ns0 B
LazyInitializer.EnsureInitialized(ref t, f)0,495 ns0 B
LazyInitializer.EnsureInitialized(ref t, ref sync, f)0,289 ns0 B
Double-Checked Locking (volatile + Lock)0,314 ns0 B

Kalter Pfad (Besitzer allokieren, Wert einmal lesen):

Methode (.NET 10.0.10, arm64)MittelwertVerhältnisAllocated
Einfaches Feld (??=, nicht Thread-sicher)1,53 ns1,0024 B
LazyInitializer.EnsureInitialized(ref t, f)6,19 ns4,0448 B
Double-Checked Locking (volatile + Lock)8,96 ns5,8572 B
Lazy<T> PublicationOnly9,68 ns6,3264 B
Lazy<T> ExecutionAndPublication13,96 ns9,1296 B
LazyInitializer.EnsureInitialized(ref t, ref sync, f)14,03 ns9,1680 B
Lazy<T> erzeugt, Value nie gelesen6,17 ns4,0372 B

Der heiße Pfad ist ein Unentschieden. Jede Option liegt unterhalb der Auflösung von BenchmarkDotNet, sobald der Wert existiert: Der Tier-1-JIT inlined Lazy<T>.Value zu einer Null-Prüfung auf _state und einem Feldzugriff, und BenchmarkDotNet weist es als nicht unterscheidbar von einer leeren Methode aus. Die Werte unter einer Nanosekunde in den anderen Zeilen liegen im Rauschen. Wenn Ihnen jemand rät, Lazy<T> “wegen der Lese-Performance” durch handgeschriebenen Code zu ersetzen, zeigt die Messung, dass es nichts zu gewinnen gibt.

Der kalte Pfad ist der Ort, an dem sich die Optionen unterscheiden, und das liegt vor allem an Allokationen. Die Lazy<T>-Zeilen erzählen die Geschichte: Ein Lazy<T> kostet 72 Byte, bevor es jemand liest (das Lazy<T>-Objekt selbst plus der LazyHelper, den ExecutionAndPublication im Konstruktor allokiert, um darauf zu sperren), und etwa 14 ns, um es einmal zu erzeugen und zu initialisieren. PublicationOnly verzichtet auf den Helper und ist daher 8 Byte und 4 ns günstiger. Die lock-freie LazyInitializer-Überladung ist mit 6,2 ns die günstigste Thread-sichere Option, weil sie außer dem Wert nichts allokiert. Die Überladung mit ref syncLock bezahlt für das Lock-Objekt, das sie beim ersten Aufruf erzeugt, daher liegen ihre einmaligen Kosten neben denen von Lazy<T>.

Ein Vorbehalt zur Spalte Allocated: Die Escape-Analyse von .NET 10 allokiert Holder-Objekte, die nachweislich nicht entweichen, auf dem Stack, weshalb die einfache Baseline nur 24 Byte zeigt. Lesen Sie die Spalte relativ zur Baseline, nicht als absolute Objektgrößen.

In absoluten Zahlen spielen 8 ns und 48 Byte pro Lazy-Wert nur eine Rolle, wenn Sie Millionen von Besitzern erzeugen. Ein Dienst mit einem Lazy<T>-Feld, das einmal beim Start konstruiert wird, bekommt davon nie etwas zu spüren.

Wann Sie Lazy<T> wählen

Wann Sie LazyInitializer.EnsureInitialized wählen

Verwenden Sie die Überladung mit ref syncLock, wenn die Factory einmal laufen muss. Übergeben Sie ein Feld vom Typ object?, das mit null startet: Es wird erst beim ersten Aufruf allokiert, der den langsamen Pfad erreicht.

Wann handgeschriebenes Double-Checked Locking noch gerechtfertigt ist

Selten. Zwei Fälle, in denen ich es tatsächlich gebraucht habe:

Wenn Sie es schreiben, verwenden Sie ab .NET 9 System.Threading.Lock, halten Sie das Feld volatile, lesen Sie es auf dem schnellen Pfad einmal in eine lokale Variable und sperren Sie niemals auf this.

Der Stolperstein, der für Sie entscheidet

Für statische Lazy-Werte brauchen Sie wahrscheinlich keine der drei Optionen. Ein static readonly-Feld, das vom Typinitialisierer gesetzt wird, ist lazy (die Runtime führt ihn vor dem ersten Zugriff auf das Feld aus), Thread-sicher und auf dem heißen Pfad kostenlos, weil der Tier-1-JIT static readonly-Felder einer bereits initialisierten Klasse als Konstanten behandelt:

// .NET 10, C# 14
public static class Pricing
{
    public static readonly FrozenDictionary<string, decimal> Table = LoadTable();
    private static FrozenDictionary<string, decimal> LoadTable() => /* ... */ FrozenDictionary<string, decimal>.Empty;
}

Der Haken: Wenn LoadTable wirft, ist der Typ für die Lebensdauer des Prozesses defekt, und jeder Zugriff wirft TypeInitializationException. Das ist dasselbe Zwischenspeicherverhalten wie bei Lazy<T>, nur schwerer zu beheben. Verwenden Sie ein statisches Lazy<T>, wenn Sie die Wiederholungssemantik von PublicationOnly brauchen oder wenn der Typ weitere statische Member hat, die auch bei fehlgeschlagenem Laden nutzbar bleiben sollen.

Die Empfehlung noch einmal

Beginnen Sie mit Lazy<T> und dem Standardmodus. Es führt die Factory einmal aus, auf dem heißen Pfad ist es eine einzelne Null-Prüfung, und sein einziger echter Nachteil (zwischengespeicherte Ausnahmen) lässt sich mit PublicationOnly abwählen. Wechseln Sie zu LazyInitializer.EnsureInitialized, wenn das Profiling zeigt, dass die Wrapper-Allokationen ins Gewicht fallen, was in der Praxis viele Instanzen mit jeweils mehreren Lazy-Feldern bedeutet. Verwenden Sie die Überladung mit ref syncLock, wenn die Factory einmal laufen muss. Schreiben Sie Double-Checked Locking nur dann selbst, wenn Sie den Wert zurücksetzen oder mehr als eine Sache atomar initialisieren müssen.

Verwandte Artikel

Quellen

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Zurück