Lazy<T> no C#: modos de thread safety e exceções

O Lazy<T> é o tipo do .NET que adia a criação de um objeto até o momento em que ele realmente é usado. Além disso, ele define o que acontece quando várias threads pedem esse objeto ao mesmo tempo e quando a criação falha.
Esses dois comportamentos dependem do LazyThreadSafetyMode informado no construtor, e é aí que mora a confusão. Neste conteúdo você vai entender os três modos, como cada um trata exceções e quando vale trocar o Lazy<T> pelo LazyInitializer.
O que é o Lazy<T> no C#
O Lazy<T> existe desde o .NET Framework 4.0 e o .NET Core 1.0. Ele descreve como um objeto deve ser criado, mas só executa essa criação quando alguém acessa a propriedade Value.
Em outras palavras, o objeto passa por uma inicialização preguiçosa. A forma mais comum usa um método de fábrica, ou seja, uma função que devolve a instância de T.
// A fábrica ainda não roda nesta linha
Lazy<ReportGenerator> lazyGenerator = new(CreateGenerator);
// A primeira leitura de Value executa a fábrica e guarda o resultado
ReportGenerator generator = lazyGenerator.Value;
// Leituras seguintes devolvem a mesma instância, sem executar a fábrica
ReportGenerator sameGenerator = lazyGenerator.Value;
static ReportGenerator CreateGenerator()
{
// Aqui normalmente existe algo caro: arquivos, rede, cálculos
return new ReportGenerator();
}
public sealed class ReportGenerator;
Na prática, o Lazy<T> faz sentido quando T é caro de criar e talvez nem seja usado durante a execução. Assim, você paga o custo só se precisar do objeto.
Porém, o ponto mais delicado não é o adiamento em si. O que realmente importa é o comportamento quando várias threads leem Value antes da criação terminar.
Esse comportamento vem do enum LazyThreadSafetyMode, passado no construtor. Também existem sobrecargas com um bool isThreadSafe, mas elas apenas mapeiam para dois dos três modos.
As propriedades Value e IsValueCreated
Toda a API pública do tipo gira em torno de duas propriedades. A primeira é Value, que dispara a criação na primeira leitura e devolve sempre o mesmo resultado depois disso.
Já a segunda, IsValueCreated, indica se o valor já existe. Ela ajuda quando você precisa saber se a fábrica já rodou, por exemplo para liberar recursos apenas se o objeto chegou a nascer.
Lazy<DatabaseConnection> lazyConnection = new(() => new DatabaseConnection());
// Nada foi criado até aqui
Console.WriteLine(lazyConnection.IsValueCreated); // False
_ = lazyConnection.Value;
// Agora a instância existe
Console.WriteLine(lazyConnection.IsValueCreated); // True
// Só libera a conexão se ela foi criada de fato
if (lazyConnection.IsValueCreated)
lazyConnection.Value.Dispose();
public sealed class DatabaseConnection : IDisposable
{
public void Dispose() { }
}
Veremos mais adiante que essa propriedade também revela como cada modo trata falhas. Afinal, em um dos modos ela continua false mesmo depois de uma exceção.
Quanto às sobrecargas com isThreadSafe, a documentação mostra a equivalência direta. O valor true corresponde a ExecutionAndPublication, enquanto false corresponde a None.
Já o PublicationOnly não tem equivalente booleano. Portanto, para usar esse modo, você precisa informar o LazyThreadSafetyMode de forma explícita no construtor.
Os três modos de thread safety do Lazy<T>
O enum oferece três valores, e cada um resolve a concorrência de um jeito diferente. Antes de entrar nos detalhes, vale ver o resumo de cada opção.
None: nenhuma proteção, para uso em uma única thread.PublicationOnly: várias threads podem executar a fábrica, mas só um valor vence e fica publicado.ExecutionAndPublication: só uma thread executa a fábrica, enquanto as outras esperam o resultado.
Quando você não informa nada, o construtor que recebe só a fábrica usa ExecutionAndPublication. Por isso, o Lazy<T> padrão já nasce seguro para várias threads.
None: o Lazy<T> para uma única thread
O modo None simplesmente ignora a concorrência. Por isso, ele só deve aparecer quando você tem certeza de que apenas uma thread vai acessar a instância.
A documentação oficial descreve o comportamento com múltiplas threads como indefinido. Segundo Andrew Lock, na prática isso costuma terminar em InvalidOperationException.
Com essa limitação, o None serve basicamente para adiar uma função e guardar o resultado. Nesse cenário, um campo anulável com ??= entrega quase o mesmo efeito.
public sealed class InvoiceService
{
// Versão com Lazy<T> e modo None
private readonly Lazy<TaxTable> _taxTable =
new(LoadTaxTable, LazyThreadSafetyMode.None);
public decimal CalculateTax(decimal amount)
=> amount * _taxTable.Value.Rate;
private static TaxTable LoadTaxTable() => new(0.12m);
}
public sealed class InvoiceServiceWithField
{
// Versão equivalente com campo anulável
private TaxTable? _taxTable;
private TaxTable Table => _taxTable ??= LoadTaxTable();
public decimal CalculateTax(decimal amount)
=> amount * Table.Rate;
private static TaxTable LoadTaxTable() => new(0.12m);
}
public sealed record TaxTable(decimal Rate);
Como o código já é de uma thread só, o Lazy<T> aqui agrega pouco. Ainda assim, ele deixa a intenção mais explícita para quem lê a classe.
PublicationOnly: corrida pela publicação
O PublicationOnly já foi pensado para acesso concorrente. Quando várias threads leem Value antes de existir um valor, todas executam a fábrica de forma independente.
Em seguida, a primeira thread que terminar vence e publica o seu resultado em Value. As outras, quando terminam, descartam o próprio resultado e recebem o valor publicado.
Depois da publicação, qualquer nova leitura devolve o valor imediatamente, sem executar a fábrica. Portanto, todas as threads enxergam a mesma instância, mesmo que a fábrica tenha rodado mais de uma vez.
int factoryCalls = 0;
Lazy<Guid> lazyId = new(() =>
{
// Pode rodar em várias threads ao mesmo tempo
Interlocked.Increment(ref factoryCalls);
Thread.Sleep(50);
return Guid.NewGuid();
}, LazyThreadSafetyMode.PublicationOnly);
Guid[] results = await Task.WhenAll(
Enumerable.Range(0, 4).Select(_ => Task.Run(() => lazyId.Value)));
// Todos os resultados são iguais, mas a fábrica pode ter rodado até 4 vezes
Console.WriteLine($"Distinct values: {results.Distinct().Count()}");
Console.WriteLine($"Factory calls: {factoryCalls}");
Um detalhe importante vem da documentação: o Lazy<T> não descarta as instâncias perdedoras de forma especial. Assim, se T implementa IDisposable, esses objetos extras ficam para o coletor de lixo.
ExecutionAndPublication: execução única
O ExecutionAndPublication usa um lock para garantir que apenas uma thread execute a fábrica. A primeira thread que lê Value assume a criação.
Enquanto isso, as demais threads ficam bloqueadas, esperando o término. Quando a primeira thread conclui, ela publica o valor e o entrega a todas as outras.
Dessa forma, a fábrica roda exatamente uma vez e só um valor existe. Na experiência de Andrew Lock, esse costuma ser o comportamento esperado na maioria dos casos.
int factoryCalls = 0;
// Sem modo explícito, o padrão já é ExecutionAndPublication
Lazy<HttpClient> lazyClient = new(() =>
{
Interlocked.Increment(ref factoryCalls);
return new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
});
await Task.WhenAll(
Enumerable.Range(0, 4).Select(_ => Task.Run(() => lazyClient.Value)));
// Sempre imprime 1
Console.WriteLine($"Factory calls: {factoryCalls}");
Existe um alerta da Microsoft aqui. Se a própria fábrica usa locks internamente, a combinação com o lock do modo pode gerar deadlocks em situações raras.
Como escolher o modo do Lazy<T>
A grande vantagem do ExecutionAndPublication é executar a fábrica uma única vez. Se o objetivo é evitar uma inicialização cara duplicada, ou controlar o acesso a um recurso, esse é o modo certo.
Além disso, ele evita criar vários objetos para depois descartar quase todos. Então, quando T é grande, você economiza alocações.
Por outro lado, o PublicationOnly pode ser mais rápido em alguns cenários, porque dispensa a sincronização do lock. Se a fábrica é barata, rodar algumas cópias em paralelo às vezes custa menos que serializar as threads.
Segundo o artigo de Andrew Lock, o PublicationOnly tende a valer a pena quando estas condições aparecem juntas:
- Há muito acesso concorrente durante a inicialização.
- A fábrica executa rapidamente.
- Rodar a fábrica mais de uma vez não causa efeitos colaterais.
- Alocações duplicadas são baratas.
- A disputa pelo lock é significativa.
Fora desse conjunto, o padrão do Lazy<T> continua sendo a escolha mais segura. Ainda assim, existe outra diferença crítica entre os dois modos, e ela aparece quando a fábrica lança uma exceção.
Exceções no Lazy<T>: cache ou não
Quando a fábrica falha, o Lazy<T> pode seguir dois caminhos. No primeiro, ele guarda a exceção do mesmo jeito que guardaria um valor de sucesso.
No segundo, ele repassa a exceção para quem chamou, mas não a guarda. Com isso, uma chamada seguinte pode tentar de novo e ter sucesso.
O detalhe que confunde muita gente é que o modo de thread safety decide qual caminho vale. Ou seja, não existe hoje um parâmetro separado para controlar o cache de exceções.
| Modo | Exceção guardada com fábrica? |
|---|---|
None |
Sim |
PublicationOnly |
Não |
ExecutionAndPublication |
Sim |
Como mostra a tabela, o PublicationOnly é o único modo em que a exceção da fábrica nunca fica guardada. A Microsoft justifica o cache nos outros modos com consistência, porque duas threads sempre recebem o mesmo resultado, mesmo em caso de erro.
ExecutionAndPublication guarda a exceção
No ExecutionAndPublication, só uma thread executa a fábrica. Se ela lançar uma exceção, o Lazy<T> armazena essa falha como resultado definitivo.
Então todas as threads que estavam esperando recebem a mesma exceção. Da mesma forma, qualquer chamada futura a Value lança outra vez a mesma instância de Exception.
int attempts = 0;
Lazy<string> lazyConfig = new(() =>
{
attempts++;
throw new InvalidOperationException("Config server unavailable");
});
for (var i = 0; i < 3; i++)
{
try
{
_ = lazyConfig.Value;
}
catch (InvalidOperationException ex)
{
// A mesma exceção volta em todas as tentativas
Console.WriteLine($"Attempt {i + 1}: {ex.Message}");
}
}
// Imprime 1: a fábrica nunca roda de novo
Console.WriteLine($"Factory attempts: {attempts}");
A partir desse ponto, a instância fica quebrada para sempre. O modo None funciona da mesma maneira, apenas sem suportar acesso concorrente.
PublicationOnly permite nova tentativa
No PublicationOnly, várias threads podem estar executando a fábrica ao mesmo tempo. Se a fábrica falha em uma delas, apenas essa thread recebe a exceção.
A falha não se propaga para as outras threads e também não fica guardada. Além disso, a propriedade IsValueCreated continua false, e a próxima leitura de Value executa a fábrica de novo.
Enquanto isso, a primeira thread que concluir com sucesso publica o seu valor. As threads que ainda estavam rodando e terminam depois recebem esse valor publicado.
Por outro lado, uma thread que falhar continua vendo a sua exceção, mesmo depois da publicação. Em outras palavras, o Lazy<T> não engole erros.
Depois que existe um valor publicado, nenhuma leitura volta a lançar exceção, porque a fábrica não roda mais.
O caso do construtor sem parâmetros
A documentação traz uma exceção à regra que vale conhecer. Quando você cria o Lazy<T> sem fábrica, ele usa o construtor sem parâmetros de T.
Nesse caso, nenhum modo guarda exceções, nem mesmo None ou ExecutionAndPublication. Portanto, o cache de exceções só entra em jogo quando existe um valueFactory.
A Microsoft também observa outro ponto. Se a fábrica acessa recursivamente o Value da mesma instância, os modos None e ExecutionAndPublication lançam InvalidOperationException.
Como lidar com falhas transitórias
O cache de exceções faz sentido quando o Lazy<T> substitui algo que seria criado na inicialização da aplicação. Nesse contexto, uma falha costuma ser fatal de qualquer forma.
No entanto, falhas transitórias mudam o cenário, como um timeout de rede ou um serviço temporariamente fora. Com ExecutionAndPublication, um único erro deixaria a instância inutilizável até o processo reiniciar.
A recomendação da Microsoft é colocar a lógica de nova tentativa dentro da própria fábrica. Assim, a falha definitiva só acontece depois de esgotar as tentativas.
public sealed class SettingsProvider(ISettingsClient client)
{
private readonly Lazy<Settings> _settings = new(() => LoadWithRetry(client));
public Settings Current => _settings.Value;
private static Settings LoadWithRetry(ISettingsClient client)
{
const int maxAttempts = 3;
for (var attempt = 1; ; attempt++)
{
try
{
return client.Load();
}
catch (TimeoutException) when (attempt < maxAttempts)
{
// Espera um pouco antes de tentar de novo
Thread.Sleep(TimeSpan.FromMilliseconds(200 * attempt));
}
}
}
}
public interface ISettingsClient
{
Settings Load();
}
public sealed record Settings(string ConnectionString);
Outra saída é usar PublicationOnly quando a fábrica é segura para rodar mais de uma vez. Dessa maneira, cada nova leitura de Value ganha uma nova chance.
O que pode mudar no .NET 12
Andrew Lock chama de defeito da API o fato de o cache de exceções depender do modo de thread safety. Afinal, são duas decisões independentes presas em um único parâmetro.
Há cerca de oito anos existe uma proposta no repositório do runtime para resolver isso. A ideia inicial era um novo modo, parecido com ExecutionAndPublication, mas sem cache de exceções.
Depois, a proposta evoluiu para um parâmetro bool cacheExceptions, que cobriria todas as combinações. Segundo Lock, a proposta foi aprovada recentemente, mas ainda não tem implementação.
Por isso, a expectativa é que o .NET 12 permita escolher execução única sem guardar exceções. Até lá, o comportamento descrito neste artigo continua valendo no .NET 10, a versão LTS atual.
LazyInitializer como alternativa ao Lazy<T>
Existe outra forma de inicialização preguiçosa que nem exige alocar um objeto Lazy<T>. Trata-se da classe estática LazyInitializer e do método EnsureInitialized<T>().
Ele trabalha direto sobre um campo, passado por referência. Com isso, você escolhe um comportamento parecido com PublicationOnly ou com ExecutionAndPublication, conforme a sobrecarga.
public sealed class CatalogCache
{
private Catalog? _catalog;
// Parecido com PublicationOnly: as threads disputam a criação
public Catalog Get()
=> LazyInitializer.EnsureInitialized(ref _catalog, Catalog.Load);
}
public sealed class LockedCatalogCache
{
private Catalog? _catalog;
private bool _initialized;
private object? _syncLock;
// Parecido com ExecutionAndPublication: o lock garante uma única execução
public Catalog Get()
=> LazyInitializer.EnsureInitialized(
ref _catalog, ref _initialized, ref _syncLock, Catalog.Load);
}
public sealed class Catalog
{
public static Catalog Load() => new();
}
Na primeira versão, a documentação lembra que várias instâncias podem ser criadas, mas só uma fica no campo. Além disso, o método não chama Dispose nos objetos que sobraram.
Na segunda versão, o objeto de sincronização nasce automaticamente quando o campo _syncLock está nulo. Então você não precisa instanciar o lock manualmente.
Há uma diferença importante em relação ao Lazy<T>: nenhuma dessas formas guarda exceções. Se a fábrica falha, a próxima chamada simplesmente tenta de novo.
A escolha depende do objetivo. Se você precisa passar o valor preguiçoso adiante, o Lazy<T> é o caminho natural, enquanto o LazyInitializer evita uma alocação extra para um campo simples.
Boas práticas com Lazy<T>
Primeiro, lembre que a segurança do Lazy<T> cobre só a criação. A documentação avisa que o objeto criado não ganha proteção nenhuma, então seus métodos precisam ser seguros para várias threads.
Em seguida, prefira o padrão ExecutionAndPublication quando a fábrica tem efeitos colaterais ou custo alto. Deixe o PublicationOnly para fábricas baratas, idempotentes e muito disputadas.
Também evite o None em código que roda em serviços web ou tarefas em segundo plano. Em uma API ou em background services no .NET, várias threads acessam o mesmo objeto com frequência.
Por fim, decida conscientemente o que deve acontecer quando a fábrica falha. Se a falha pode ser temporária, coloque a nova tentativa na fábrica ou escolha um modo sem cache de exceções.
Para os detalhes completos de cada modo, a referência oficial é a página do enum LazyThreadSafetyMode no Microsoft Learn.
Conclusão
O Lazy<T> resolve bem a inicialização preguiçosa, mas o modo escolhido no construtor muda dois comportamentos ao mesmo tempo. Ele define quantas vezes a fábrica pode rodar e também se uma falha vira resultado permanente.
Com None, você tem desempenho sem proteção e exceções guardadas. Já o PublicationOnly deixa as threads competirem e não guarda exceções, enquanto o ExecutionAndPublication garante execução única e guarda a primeira falha.
Enquanto o parâmetro cacheExceptions não chega, conhecer essa ligação evita bugs difíceis de reproduzir. Assim, você usa o Lazy<T> sabendo exatamente o que esperar quando algo dá errado.
FAQ
Qual é o modo padrão do Lazy<T> quando passo só a fábrica?
O padrão é ExecutionAndPublication. Ele executa a fábrica uma única vez e entrega o mesmo valor para todas as threads.
O Lazy<T> torna o objeto criado thread-safe?
Não. Ele protege apenas a criação, e o objeto resultante precisa ter seus próprios mecanismos de sincronização.
Por que meu Lazy<T> lança sempre a mesma exceção?
Com None ou ExecutionAndPublication e uma fábrica, a primeira exceção fica guardada. Todas as leituras seguintes de Value lançam essa mesma exceção.
Como fazer o Lazy<T> tentar de novo depois de uma falha?
Use PublicationOnly, que não guarda exceções, ou coloque a lógica de nova tentativa dentro da própria fábrica.
Quando usar LazyInitializer no lugar de Lazy<T>?
Quando você só precisa inicializar um campo e quer evitar a alocação de um objeto extra. Ele também não guarda exceções.