Start Debugging

Lazy<T> vs LazyInitializer.EnsureInitialized vs double-checked locking en C#

Usa Lazy<T> por defecto: ejecuta la factory una sola vez, almacena en caché las excepciones y sus lecturas son demasiado baratas para medirlas tras la inicialización. Usa LazyInitializer.EnsureInitialized cuando inicializas de forma diferida muchos campos o posiciones de un arreglo y no puedes pagar un objeto extra por valor. El double-checked locking escrito a mano casi nunca vale la pena en .NET 10.

Usa Lazy<T> por defecto. Con el modo por defecto del constructor (ExecutionAndPublication), garantiza que la factory se ejecute exactamente una vez incluso bajo contención, almacena en caché la excepción de la factory y, una vez que el valor existe, una lectura es una comprobación de null más la carga de un campo, demasiado barata para que BenchmarkDotNet la mida. Recurre a LazyInitializer.EnsureInitialized cuando tengas muchos valores creados de forma diferida (un campo por propiedad en un tipo muy usado, o posiciones de un arreglo) y el objeto Lazy<T> extra por valor sea el costo que intentas evitar. El double-checked locking escrito a mano no te da nada que LazyInitializer.EnsureInitialized(ref field, ref syncLock, factory) no te dé ya, así que escríbelo solo cuando necesites un comportamiento que ninguna de las dos APIs ofrece.

Todo lo que sigue se midió en .NET 10.0.10 (SDK 10.0.302), C# 14, BenchmarkDotNet 0.15.8, en un Apple M4 (arm64). La forma de las APIs no ha cambiado desde .NET Core 3.0, así que el consejo vale para .NET 8, 9, 10 y 11.

La comparación de un vistazo

Lazy<T> (por defecto)LazyInitializer.EnsureInitialized(ref t, f)EnsureInitialized(ref t, ref sync, f)Double-checked locking
La factory se ejecuta como máximo una vez bajo contenciónSíNo, cada hilo en la carrera puede ejecutarlaSíSí, si está bien escrito
Excepción de la factoryEn caché, se relanza siempreSin caché, la siguiente llamada reintentaSin caché, la siguiente llamada reintentaLo que escribas
Asignación extra por valor diferidoObjeto Lazy<T> más un auxiliar de bloqueoNingunaUn objeto de bloqueo, solo si hay contención o en la primera llamadaUn objeto de bloqueo (o compartido)
Lectura frecuente tras inicializar (medida)menos de 1 ns, no mediblemenos de 1 nsmenos de 1 nsmenos de 1 ns
Creación + inicialización única (medida)13.96 ns, 96 B6.19 ns, 48 B14.03 ns, 80 B8.96 ns, 72 B
Funciona con tipos de valorSíNo (where T : class)No, usa la sobrecarga con ref bool initializedSí, con una bandera aparte
La factory puede devolver nullSíNo, lanza InvalidOperationExceptionNo, lanza InvalidOperationExceptionSí, con una bandera aparte
Reentrada desde la factoryLanza InvalidOperationExceptionDesbordamiento de pilaDesbordamiento de pila (Monitor es reentrante)Depende del tipo de bloqueo
Líneas de código que te tocan mantener112 campos + 1 llamada~10

Qué hace realmente cada opción

Lazy<T>

Lazy<T> envuelve una factory y un espacio de almacenamiento. Su propiedad Value en .NET 10 es una sola línea:

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

_state es una referencia LazyHelper volátil que describe el modo y, para ExecutionAndPublication, hace también de objeto de bloqueo. La primera llamada entra en CreateValue, que toma lock (executionAndPublication), vuelve a comprobar, ejecuta tu factory, guarda _value y luego pone _state = null con una escritura volátil. Cada llamada posterior ve _state == null y devuelve el campo. Eso es double-checked locking, escrito una sola vez, por gente que ha leído la especificación del modelo de memoria con más cuidado que la mayoría de nosotros.

El modo de seguridad entre hilos se elige con el constructor:

// .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 es una clase estática que opera sobre un campo tuyo, a través de un ref. No hay objeto envoltorio. La sobrecarga de dos argumentos es, completa:

// 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;
}

Sin bloqueo. Cada hilo que ve null ejecuta la factory, y CompareExchange decide qué resultado se guarda. Los objetos de los perdedores se descartan sin más (y la documentación señala que el método “will not dispose of the objects that were not stored”). Es la semántica de PublicationOnly sin el envoltorio.

La sobrecarga de tres argumentos, EnsureInitialized(ref target, ref syncLock, valueFactory), añade un bloqueo. Crea de forma diferida un object simple en syncLock con CompareExchange y luego hace lock (syncLock) { if (Volatile.Read(ref target) == null) Volatile.Write(ref target, valueFactory()); }. Eso es double-checked locking de manual, y ejecuta la factory una sola vez.

Para tipos de valor, o cuando null es un resultado legítimo, hay sobrecargas con una bandera extra ref bool initialized. Siempre toman el camino con bloqueo.

Double-checked locking escrito a mano

La versión moderna correcta en .NET 9 y posteriores, usando el tipo 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;

El volatile hace un trabajo real. La especificación del modelo de memoria de .NET (docs/design/specs/Memory-model.md en dotnet/runtime) sí dice que guardar una referencia a un objeto en memoria compartida es un release y que las lecturas con dependencia de datos a través de ella quedan ordenadas, así que el clásico bug del “objeto a medio construir” no puede ocurrir en CoreCLR. Pero también advierte explícitamente que los efectos de ordenación de la asignación de referencias no deben usarse para ordenar en general, y un campo no volátil permite que el JIT eleve o fusione las lecturas de _report. Márcalo volatile, o léelo con Volatile.Read, y el patrón es correcto en todas las arquitecturas en las que corre .NET.

Demostrar el comportamiento bajo contención

La documentación dice “may run more than once”. Yo quería un número. Esta prueba inicia 16 hilos sobre un Barrier, hace que todos accedan al valor diferido a la vez, usa una factory que gira unas 20 000 iteraciones para ampliar la ventana de la carrera, y se repite 200 veces, registrando la peor ronda:

// .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;
}

Resultados, la peor ronda de 200:

OpciónMáximo de llamadas a la factory en una ronda
Lazy<T> por defecto (ExecutionAndPublication)1
Lazy<T> PublicationOnly11
LazyInitializer.EnsureInitialized(ref t, f)12
LazyInitializer.EnsureInitialized(ref t, ref sync, f)1

Todas las opciones devolvieron la misma instancia a los 16 hilos en cada ronda, así que ninguna es “insegura”. La diferencia es trabajo desperdiciado. Si tu factory abre un socket, lee un archivo de 40 MB o llama a una API de pago, 12 ejecuciones en paralelo son un bug real aunque el valor publicado sea correcto.

La trampa del almacenamiento en caché de excepciones

Esta es la diferencia de comportamiento que muerde en producción. La misma factory, falla en la primera llamada y funciona en la segunda:

// .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"

La prueba lo confirmó: el Lazy<T> por defecto llamó a la factory una vez y lanzó la misma excepción en cada acceso posterior. PublicationOnly y LazyInitializer la llamaron dos veces y se recuperaron.

La tabla de excepciones de MS Learn expone la regla: cualquier constructor de Lazy<T> que reciba un valueFactory almacena las excepciones en caché, salvo que el modo sea PublicationOnly. El constructor sin parámetros (que usa Activator.CreateInstance) no las almacena. Así, un Lazy<HttpClient> cuya factory lee un valor de configuración que falta al arrancar lanzará durante toda la vida del proceso, incluso después de que la configuración aparezca. Si el fallo puede ser transitorio, usa PublicationOnly, usa LazyInitializer o reemplaza la instancia de Lazy<T> en un catch.

La misma trampa es el doble de traicionera con Lazy<Task<T>>: la factory no lanza, devuelve un Task en estado de fallo, y esa tarea fallida se almacena en caché como un valor exitoso en todos los modos. Si almacenas trabajo asíncrono en caché, comprueba IsFaulted y cambia la instancia, o usa una caché de verdad.

El benchmark

Dos preguntas: cuánto cuesta una lectura una vez que el valor existe, y cuánto cuesta cada opción por instancia cuando creas el propietario y tocas el valor una vez. Metodología: BenchmarkDotNet 0.15.8, job por defecto, [MemoryDiagnoser], .NET 10.0.10 arm64 RyuJIT, Apple M4, macOS. Cada clase contenedora expone una propiedad Value; Expensive es una clase diminuta para que lo medido sea la maquinaria diferida y no la factory. El código completo son las clases HotPath y ColdPath de abajo.

// .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;
}

Camino frecuente (el valor ya está creado, una lectura):

Método (.NET 10.0.10, arm64)MediaAsignado
Campo simple (??=, no seguro entre hilos)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

Camino frío (asignar el propietario, leer el valor una vez):

Método (.NET 10.0.10, arm64)MediaRatioAsignado
Campo simple (??=, no seguro entre hilos)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> creado, Value nunca leído6.17 ns4.0372 B

El camino frecuente es un empate. Todas las opciones quedan por debajo de la resolución de BenchmarkDotNet una vez que el valor existe: el JIT de nivel 1 incorpora Lazy<T>.Value hasta dejarlo en una comprobación de null sobre _state y la carga de un campo, y BenchmarkDotNet lo reporta como indistinguible de un método vacío. Los números por debajo del nanosegundo de las demás filas están dentro del ruido. Si alguien te dice que reemplaces Lazy<T> por código a mano “por rendimiento de lectura”, la medición dice que no hay nada que ganar.

El camino frío es donde las opciones difieren, y es sobre todo asignación de memoria. Las filas de Lazy<T> cuentan la historia: un Lazy<T> cuesta 72 bytes antes de que nadie lo lea (el propio objeto Lazy<T> más el LazyHelper que ExecutionAndPublication asigna en el constructor para bloquear sobre él), y unos 14 ns para crearlo e inicializarlo una vez. PublicationOnly se salta el auxiliar, así que es 8 bytes y 4 ns más barato. La sobrecarga de LazyInitializer sin bloqueo es la opción segura entre hilos más barata, con 6.2 ns, porque no asigna nada aparte del valor. La sobrecarga con ref syncLock paga el objeto de bloqueo que crea en la primera llamada, así que su costo único queda al lado del de Lazy<T>.

Una salvedad sobre la columna Allocated: el análisis de escape de .NET 10 asigna en la pila los objetos contenedores que demostrablemente no escapan, y por eso la línea base simple muestra solo 24 bytes. Lee la columna en relación con la línea base, no como tamaños absolutos de objetos.

En términos absolutos, 8 ns y 48 bytes por valor diferido solo importan cuando creas millones de propietarios. Un servicio con un campo Lazy<T> que se construye una vez al arrancar nunca lo notará.

Cuándo elegir Lazy<T>

Cuándo elegir LazyInitializer.EnsureInitialized

Usa la sobrecarga con ref syncLock cuando la factory deba ejecutarse una sola vez. Pasa un campo de tipo object? que empiece en null: solo se asigna en la primera llamada que llegue al camino lento.

Cuándo sigue estando justificado el double-checked locking escrito a mano

Rara vez. Dos casos que sí he necesitado:

Si lo escribes, usa System.Threading.Lock en .NET 9+, mantén el campo volatile, léelo una sola vez en una variable local en el camino rápido y nunca bloquees sobre this.

La salvedad que decide por ti

Para valores diferidos estáticos, probablemente no necesites ninguna de las tres. Un campo static readonly inicializado por el inicializador de tipo es diferido (el runtime lo ejecuta antes del primer acceso al campo), seguro entre hilos y gratis en el camino frecuente, porque el JIT de nivel 1 trata los campos static readonly de una clase ya inicializada como constantes:

// .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;
}

El problema: si LoadTable lanza una excepción, el tipo queda roto durante toda la vida del proceso y cada acceso lanza TypeInitializationException. Es el mismo comportamiento de caché que Lazy<T>, solo que más difícil de recuperar. Usa un Lazy<T> estático cuando necesites la semántica de reintento de PublicationOnly o cuando el tipo tenga otros miembros estáticos que quieras que sigan siendo utilizables aunque la carga falle.

La recomendación, otra vez

Empieza con Lazy<T> y el modo por defecto. Ejecuta la factory una vez, es una sola comprobación de null en el camino frecuente y su única desventaja real (las excepciones en caché) se puede evitar con PublicationOnly. Cambia a LazyInitializer.EnsureInitialized cuando el perfilado muestre que las asignaciones de los envoltorios importan, lo que en la práctica significa muchas instancias con varios campos diferidos cada una. Usa la sobrecarga con ref syncLock si la factory debe ejecutarse una sola vez. Escribe el double-checked locking tú mismo solo cuando necesites reiniciar el valor o inicializar más de una cosa de forma atómica.

Relacionado

Fuentes

Comments

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

< Volver