Todo desenvolvedor .NET conhece o Singleton. É o primeiro padrão que a maioria aprende, o mais fácil de desenhar num quadro branco e, por uma ironia cruel, o mais fácil de implementar errado. A versão que aparece em tutoriais de dez linhas funciona perfeitamente na sua máquina, com uma thread só, e falha silenciosamente no primeiro pico de tráfego em produção.
A pergunta que separa quem conhece o padrão de quem o domina é esta: o que acontece quando duas requisições chegam no mesmo milissegundo, antes de a instância existir? A resposta ingênua cria dois objetos que deveriam ser um. O Google resolveu variações desse problema de inicialização única em escala, o C# ganhou ferramentas específicas para ele ao longo de várias versões, e o Lazy<T> existe justamente porque escrever a versão thread-safe à mão é mais sutil do que parece.
Vamos implementar o Singleton do jeito certo em .NET 10, medir o custo de cada abordagem com dados reais, e terminar com a parte que os tutoriais escondem: quando o Singleton é a ferramenta errada e está sabotando o seu projeto.
Este é o primeiro artigo da série sobre Design Patterns em C# e .NET 10. Começamos pelos padrões criacionais, e nenhum é mais emblemático que o Singleton.
O que o Singleton resolve
O Singleton garante que uma classe tenha uma única instância durante toda a vida da aplicação e oferece um ponto de acesso global a ela. Dois problemas em um: unicidade e acesso.
A unicidade importa quando o objeto representa algo que é logicamente único no sistema ou é caro demais para duplicar. Um catálogo de configuração carregado de disco, um pool de conexões, um cache em memória, um cliente HTTP com sua própria fila de conexões. Criar dois desses é desperdício no melhor caso e bug no pior, quando dois caches divergem e passam a responder valores diferentes para a mesma chave.
O acesso global é a parte controversa, e voltaremos a ela na seção de limitações. Por ora, guarde a intuição: o Singleton troca a flexibilidade de passar uma dependência explicitamente pela conveniência de acessá-la de qualquer lugar.
Por que a solução óbvia falha
A primeira implementação que quase todo mundo escreve é esta:
// ATENÇÃO: esta versão NÃO é thread-safe.
// Ela está aqui para mostrar onde o problema mora.
public sealed class ConfiguracaoIngenua
{
private static ConfiguracaoIngenua? _instancia;
public string AmbienteAtual { get; }
private ConfiguracaoIngenua()
{
// Simula uma carga cara: arquivo, banco, chamada externa
AmbienteAtual = "Production";
}
public static ConfiguracaoIngenua Instancia
{
get
{
// Duas threads podem passar por este if ao mesmo tempo
if (_instancia is null)
{
_instancia = new ConfiguracaoIngenua();
}
return _instancia;
}
}
}
Repare no que acontece quando duas threads chegam juntas, antes de a instância existir:
Duas instâncias foram criadas. A thread A recebeu a instância A, a thread B recebeu a B, e a partir de t5 todo o resto do sistema recebe a B. Se essa classe carrega um arquivo de dez megabytes, você pagou a carga duas vezes. Se ela abre um pool de conexões, você tem dois pools. O padrão que existe para garantir unicidade acabou de produzir duplicidade.
O construtor privado é a única parte que a versão ingênua acerta: ele impede que código externo faça new ConfiguracaoIngenua(), forçando todo mundo a passar pela propriedade Instancia. Sem ele, não há Singleton, há apenas uma variável estática que qualquer um pode ignorar.
Anatomia do padrão
A estrutura do Singleton é a mais enxuta de todos os padrões do catálogo:
Três elementos, e só:
Construtor privado: bloqueia a criação externa. Ninguém consegue escrever new, então a classe controla sozinha quantas instâncias existem.
Campo estático: guarda a referência para a única instância. Estático porque precisa sobreviver a qualquer chamada individual e ser compartilhado por todos.
Ponto de acesso estático: a propriedade ou método que devolve a instância, criando-a na primeira vez se necessário.
A dificuldade toda mora no ponto de acesso, especificamente em como criar a instância exatamente uma vez quando várias threads chegam juntas. Vamos percorrer as soluções em ordem de qualidade.
Implementação: da trava ao Lazy
Trava em toda leitura
A correção mais direta é serializar o acesso com uma trava. No .NET 9 ganhamos o tipo Lock, dedicado a esse propósito, mais rápido que travar num object genérico:
// Versão thread-safe com o tipo Lock, introduzido no .NET 9
public sealed class ConfiguracaoComLock
{
private static ConfiguracaoComLock? _instancia;
private static readonly Lock _trava = new();
public string AmbienteAtual { get; }
private ConfiguracaoComLock()
{
AmbienteAtual = "Production";
}
public static ConfiguracaoComLock Instancia
{
get
{
// Toda leitura paga o custo da trava, mesmo depois de criada
lock (_trava)
{
_instancia ??= new ConfiguracaoComLock();
return _instancia;
}
}
}
}
Correto, mas caro. O comentário aponta o problema: a instância é criada uma vez, mas a trava é adquirida em toda leitura, para sempre. Depois que o objeto existe, o lock não protege mais nada, só cobra pedágio. Numa propriedade acessada milhões de vezes por segundo, isso pesa, como o benchmark vai mostrar.
Double-checked locking
A técnica clássica para pagar a trava só enquanto ela importa é a dupla checagem:
// Double-checked locking: só entra na trava se ainda não existe instância
public sealed class ConfiguracaoDoubleChecked
{
private static volatile ConfiguracaoDoubleChecked? _instancia;
private static readonly Lock _trava = new();
public string AmbienteAtual { get; }
private ConfiguracaoDoubleChecked()
{
AmbienteAtual = "Production";
}
public static ConfiguracaoDoubleChecked Instancia
{
get
{
// Primeira checagem: sem trava, caminho rápido
if (_instancia is null)
{
lock (_trava)
{
// Segunda checagem: outra thread pode ter criado
// enquanto esta esperava a trava
_instancia ??= new ConfiguracaoDoubleChecked();
}
}
return _instancia;
}
}
}
A ideia: a primeira checagem, fora da trava, é o caminho rápido para o caso comum em que a instância já existe. Só quando ela ainda é null pagamos a trava, e aí a segunda checagem cobre o caso de duas threads terem passado juntas pela primeira.
Nota: o
volatileno campo não é decoração. Sem ele, o modelo de memória permite que uma thread veja a referência já atribuída antes de o construtor ter terminado de rodar, entregando um objeto meio construído. Ovolatilegarante que a escrita da referência só fique visível depois que tudo dentro do construtor completou. Errar isso produz o pior tipo de bug: o que aparece uma vez a cada milhão de execuções, só em produção, e nunca no debugger.
O double-checked locking funciona, mas é um código que você precisa acertar com cuidado. A boa notícia é que, em C#, você quase nunca deveria escrevê-lo.
Inicialização estática
O runtime do .NET já garante que campos estáticos são inicializados uma única vez, de forma thread-safe, sem você escrever trava nenhuma:
// Inicialização estática: o runtime garante a criação única
public sealed class ConfiguracaoEstatica
{
private static readonly ConfiguracaoEstatica _instancia = new();
public string AmbienteAtual { get; }
// O construtor estático explícito impede a marcação beforefieldinit,
// garantindo que a instância só nasce no primeiro acesso ao tipo
static ConfiguracaoEstatica() { }
private ConfiguracaoEstatica()
{
AmbienteAtual = "Production";
}
public static ConfiguracaoEstatica Instancia => _instancia;
}
O construtor estático vazio parece inútil, mas tem função. Sem ele, o compilador marca o tipo com o atributo beforefieldinit, que dá ao runtime liberdade de inicializar os estáticos mais cedo do que você espera. Com o construtor estático explícito, a garantia é firme: a instância nasce no primeiro acesso ao tipo, não antes. É elegante e correto, mas tem uma limitação: a inicialização não é preguiçosa de verdade se a classe tiver outros membros estáticos, porque tocar qualquer um deles dispara a criação da instância.
Lazy<T>: a forma idiomática
Para inicialização preguiçosa e thread-safe sem escrever uma linha de sincronização, o C# oferece Lazy<T> desde o .NET Framework 4.0. É a resposta que a linguagem quer que você use:
// Lazy<T>: a forma idiomática em C# moderno
public sealed class ConfiguracaoLazy
{
private static readonly Lazy<ConfiguracaoLazy> _instancia =
new(() => new ConfiguracaoLazy());
public string AmbienteAtual { get; }
private ConfiguracaoLazy()
{
AmbienteAtual = "Production";
}
public static ConfiguracaoLazy Instancia => _instancia.Value;
}
O Lazy<T> encapsula toda a complexidade que percorremos. Por padrão ele usa o modo LazyThreadSafetyMode.ExecutionAndPublication, que garante que a fábrica roda uma única vez mesmo sob concorrência e que nenhuma thread vê um objeto meio construído. Você ganha o double-checked locking correto, com a barreira de memória certa, sem escrever nem depurar nada disso. A instância só é criada no primeiro acesso a .Value.
Testar a garantia de unicidade sob concorrência é uma linha:
// 100 threads acessando ao mesmo tempo, deve resultar em 1 instância
var instancias = new ConfiguracaoLazy[100];
Parallel.For(0, 100, i => instancias[i] = ConfiguracaoLazy.Instancia);
Console.WriteLine(instancias.Distinct().Count()); // 1
Cem threads, uma instância. É o que o Lazy<T> promete e cumpre.
Performance: por que o benchmark ingênuo mente
Todas as versões corretas produzem uma única instância. A pergunta natural é qual delas custa menos a cada acesso, depois que a instância existe. O benchmark ingênuo para responder isso é o que quase todo mundo escreve, e é o que vai te enganar. Vamos escrevê-lo, rodá-lo, e entender por que o resultado não quer dizer o que parece.
O harness mede o custo de um acesso com a instância já criada e o JIT aquecido:
[MemoryDiagnoser]
public class SingletonBenchmark
{
[Benchmark(Baseline = true)]
public object LockSempre() => ConfiguracaoComLock.Instancia;
[Benchmark]
public object DoubleChecked() => ConfiguracaoDoubleChecked.Instancia;
[Benchmark]
public object Lazy() => ConfiguracaoLazy.Instancia;
[Benchmark]
public object StaticReadonly() => ConfiguracaoEstatica.Instancia;
}
Rodando na minha máquina, o resultado é este:
Olhe para o Lazy<T> marcando 0,00 ns. Nenhuma operação real custa zero. Esse valor não significa que o Lazy<T> é infinitamente rápido, significa que não sobrou nada para medir. E ele não está sozinho: o double-checked e o estático também caíram abaixo do piso confiável do BenchmarkDotNet. Resultado sub-nanossegundo quase sempre quer dizer a mesma coisa, o JIT otimizou o trabalho para fora do loop.
A razão é a invariância. Depois que a instância existe, esses três métodos apenas devolvem uma referência que já está pronta. O BenchmarkDotNet chama cada um milhares de vezes num loop, e o JIT percebe que a chamada devolve exatamente a mesma referência em toda iteração. Sendo invariante, ele calcula uma vez e reaproveita, e o custo por chamada desaba para perto de zero. Não há o que medir, porque uma leitura de campo cacheada é essencialmente de graça. O [MemoryDiagnoser] confirma o outro lado da moeda: nenhuma das quatro aloca no acesso, porque todas só devolvem uma referência que já existe.
Repare que só o lock sobreviveu com um número honesto, e sobreviveu justamente porque tem um lock. A trava é uma barreira que o JIT não pode eliminar nem mover para fora do loop, então cada iteração de fato adquire e libera a trava. Ou seja, os 2,88 ns não medem o acesso ao Singleton, medem o custo do lock em toda leitura, que é o único trabalho real que restou na tabela.
A leitura correta, então, é a mais simples possível. Na via quente, com a instância já criada, as três estratégias corretas são de graça, porque se reduzem a uma leitura de campo. A única que paga um custo recorrente é a ingênua, com lock em toda leitura. Não existe a mais rápida das três, existe as três livres e a quarta cobrando pedágio.
Isso muda o motivo pelo qual você escolhe o Lazy<T>. Não é por um nanossegundo que ele não te dá. É porque é o código correto mais simples de escrever, thread-safe sem uma linha de sincronização à mão. O argumento de performance, quando você mede direito, não separa as boas opções entre si, ele só condena a ingênua.
E onde as estratégias divergem de verdade é sob concorrência, não em acesso sequencial. O benchmark que importa dispara várias threads lendo a instância ao mesmo tempo, e passa cada leitura por um delegate. Esse delegate é o que faz o teste medir alguma coisa: como o JIT não enxerga através dele, não consegue provar que a leitura é invariante e não a move para fora do loop, ao contrário do benchmark ingênuo. Cada uma das milhões de leituras executa de fato. O tempo medido é o do lote inteiro rodando em paralelo, por isso a escala agora é de milissegundos, não de nanossegundos. Rodando com 1, 2, 4 e 8 threads, a diferença salta:
Leia a primeira linha e depois as outras três. O lock em toda leitura desmorona conforme as threads aumentam: de 4,2 ms com uma thread para 717 ms com oito, quase duzentas vezes mais lento, apesar de o trabalho por thread ser o mesmo. As três estratégias sem trava mal se mexem, porque as leituras acontecem em paralelo, sem ninguém esperando ninguém.
A coluna de Lock Contentions do BenchmarkDotNet mostra a causa exata: zero com uma thread, 3 com duas, 607 com quatro, e 11.107 com oito. Cada contenção é uma thread que chegou na trava, encontrou ela ocupada, e foi estacionada pelo sistema operacional até a vez chegar. Quanto mais threads, mais elas brigam pela mesma trava, e a vazão colapsa. O lock não cobra um pedágio fixo, ele cobra um pedágio que piora quanto mais movimento passa, exatamente o oposto do que você quer num sistema de alto tráfego.
Nota: este benchmark é o pior caso de propósito. O loop faz só uma coisa, pegar a instância, um milhão de vezes sem pausa, então as threads batem na trava sem parar e a serialização aparece no seu extremo. Em produção não é assim. Cada thread pega a instância e depois faz trabalho, uma consulta, uma serialização, um cálculo, e esse trabalho espalha as chegadas na trava ao longo do tempo. A direção do resultado se mantém, o lock serializa e degrada com a concorrência, mas a magnitude dos 717 ms é um teto, não o número que você verá no seu sistema. Leia a tabela como ranking e tendência, não como custo absoluto.
Vale uma observação na via de uma thread só, onde não há contenção. O double-checked (1,9 ms) é mensuravelmente mais lento que o Lazy (0,65 ms) e o estático (0,50 ms), e a razão é o volatile. Ele obriga uma leitura com barreira de memória a cada acesso, enquanto o Lazy e o estático, depois de criados, são leituras de campo comuns. Não é uma diferença que pese na prática, mas explica por que a técnica clássica que você escreveria à mão perde para as duas que a linguagem te dá de graça.
Escolha o Lazy<T>. Ele fica a um passo do piso, escala plano sob qualquer concorrência, e você não escreve nem uma linha de sincronização. O lock em toda leitura não é só mais lento, é uma bomba-relógio que só aparece quando o tráfego chega.
Exemplo da vida real
Imagine uma API de emissão de notas fiscais que precisa calcular o ICMS de cada item. As alíquotas por unidade federativa moram num arquivo JSON, carregado do disco na inicialização. Ler e desserializar esse arquivo a cada requisição seria absurdo: os dados mudam raramente e cabem folgados na memória. É um caso de Singleton de manual.
// Catálogo de alíquotas de ICMS por UF, carregado uma única vez
public sealed class CatalogoAliquotas
{
private static readonly Lazy<CatalogoAliquotas> _instancia =
new(() => new CatalogoAliquotas("aliquotas.json"));
private readonly IReadOnlyDictionary<string, decimal> _aliquotas;
public static CatalogoAliquotas Instancia => _instancia.Value;
private CatalogoAliquotas(string caminhoArquivo)
{
// Carga cara: leitura de disco e desserialização,
// paga uma única vez na vida do processo
var json = File.ReadAllText(caminhoArquivo);
var dados = JsonSerializer.Deserialize<Dictionary<string, decimal>>(json)
?? throw new InvalidOperationException(
$"Arquivo {caminhoArquivo} vazio ou inválido");
_aliquotas = dados;
}
public decimal ObterAliquota(string uf) =>
_aliquotas.TryGetValue(uf, out var aliquota)
? aliquota
: throw new KeyNotFoundException($"UF desconhecida: {uf}");
}
Repare em três decisões de produção. A validação no construtor transforma um arquivo corrompido em falha explícita na inicialização, não em NullReferenceException três horas depois. O dicionário é exposto como IReadOnlyDictionary, porque um Singleton compartilhado por todas as threads jamais pode ser mutável sem sincronização, e a imutabilidade elimina o problema pela raiz. E ObterAliquota falha alto para uma UF desconhecida, em vez de devolver zero e gerar uma nota fiscal com imposto errado.
Integração com ASP.NET Core: o Singleton que você deveria usar
Na maioria dos casos, você não deveria escrever o padrão Singleton à mão. O container de injeção de dependência do ASP.NET Core já implementa o ciclo de vida singleton para você, e melhor.
var builder = WebApplication.CreateBuilder(args);
// O container é o singleton: uma instância para toda a vida da aplicação
builder.Services.AddSingleton<CatalogoAliquotas>(_ =>
CatalogoAliquotas.CarregarDe("aliquotas.json"));
var app = builder.Build();
app.MapGet("/aliquota/{uf}", (string uf, CatalogoAliquotas catalogo) =>
Results.Ok(new { uf, aliquota = catalogo.ObterAliquota(uf) }));
app.Run();
E a classe fica mais limpa, porque ela não é mais responsável pelo próprio ciclo de vida:
// A classe não precisa mais de campo estático nem construtor privado:
// o ciclo de vida é responsabilidade do container
public sealed class CatalogoAliquotas
{
private readonly IReadOnlyDictionary<string, decimal> _aliquotas;
private CatalogoAliquotas(IReadOnlyDictionary<string, decimal> aliquotas) =>
_aliquotas = aliquotas;
public static CatalogoAliquotas CarregarDe(string caminhoArquivo)
{
var json = File.ReadAllText(caminhoArquivo);
var dados = JsonSerializer.Deserialize<Dictionary<string, decimal>>(json)
?? throw new InvalidOperationException(
$"Arquivo {caminhoArquivo} vazio ou inválido");
return new CatalogoAliquotas(dados);
}
public decimal ObterAliquota(string uf) =>
_aliquotas.TryGetValue(uf, out var aliquota)
? aliquota
: throw new KeyNotFoundException($"UF desconhecida: {uf}");
}
O AddSingleton garante uma única instância para toda a aplicação, com a mesma segurança de thread do Lazy<T>, mas com uma vantagem decisiva: o CatalogoAliquotas chega ao endpoint como parâmetro, injetado, e não como uma chamada a uma propriedade estática global. A diferença parece cosmética e não é, como a próxima seção explica.
Quando não usar: o lado escuro do padrão
O Singleton é o padrão mais criticado do catálogo GoF, e a crítica é justa. O problema não é a unicidade, é o acoplamento que o acesso global estático arrasta junto.
Testabilidade comprometida. Uma classe que chama CatalogoAliquotas.Instancia lá dentro tem uma dependência invisível e impossível de substituir. No teste, você não consegue injetar um catálogo falso com alíquotas controladas, porque a dependência não passa por lugar nenhum que você possa interceptar. Ela está soldada ao código estático. Compare com a versão via DI: ali o catálogo é um parâmetro, e no teste você passa a implementação que quiser.
Estado global disfarçado. Um Singleton é uma variável global com um paletó. Todo o problema que aprendemos a temer em variáveis globais continua ali: qualquer parte do sistema pode depender dele, mudanças no seu estado afetam todo mundo de forma difícil de rastrear, e a ordem de inicialização vira um campo minado quando um Singleton depende de outro.
Acoplamento oculto. As dependências de um método deveriam estar visíveis na sua assinatura. Quando um método usa três Singletons por dentro, a assinatura mente: ela diz que o método não precisa de nada, quando na verdade precisa de três coisas. Isso torna o código mais difícil de entender e de refatorar.
Nota: o Singleton também é traiçoeiro em cenários de concorrência quando carrega estado mutável. Se dois requests escrevem no mesmo Singleton sem sincronização, você tem uma race condition clássica. Se sincroniza, cria um gargalo onde todas as threads se enfileiram. O Singleton seguro é o imutável, e imutabilidade é uma disciplina, não um padrão que se ativa sozinho.
Some tudo e chega a uma regra prática. Você quase nunca precisa do padrão Singleton escrito à mão. Precisa é do ciclo de vida singleton, e esse o container de DI entrega sem nenhum dos malefícios acima:
Use o AddSingleton do container quando: você está numa aplicação ASP.NET Core ou em qualquer host com DI, o que hoje é o caso normal. Você ganha a instância única, a segurança de thread e a testabilidade, tudo de graça.
Use o Singleton clássico com Lazy<T> quando: você está numa biblioteca sem acesso a um container, ou num utilitário estático genuíno sem dependências, ou num ponto de entrada onde a DI ainda não subiu. São casos reais, mas são a minoria.
Não use nenhum dos dois quando: o objeto tem estado mutável por request, precisa de tempo de vida diferente em contextos diferentes, ou você está alcançando o global só para evitar passar um parâmetro. Esse último caso é o mais comum e o mais danoso: o Singleton vira desculpa para não pensar no fluxo de dependências.
Conclusão
O Singleton é uma solução elegante para um problema real: garantir uma instância única e cara de criar. A implementação correta em C# moderno não é difícil, porque o Lazy<T> embrulha toda a sutileza de thread safety que a versão ingênua erra, entregando a melhor performance com o código mais simples.
O que é difícil no Singleton não é fazê-lo funcionar, é decidir se você deveria usá-lo. Os detalhes que importam ficam quase todos na fronteira: o volatile no double-checked locking, a imutabilidade obrigatória do estado compartilhado, o acoplamento estático que envenena os testes. Acertar a mecânica é a parte fácil, resistir ao uso indevido é a parte difícil.
O trade-off, no fim, é entre conveniência e desenho. O acesso global estático é conveniente hoje e caro amanhã, quando você tenta testar, refatorar ou evoluir. Em .NET moderno, o container de DI resolve a unicidade sem cobrar esse preço, e é por isso que o AddSingleton deveria ser o seu reflexo antes de escrever private static instância.
Na próxima vez que você for criar um campo estático para guardar a única instância de algo, pare um segundo e pergunte se um AddSingleton não resolveria melhor. Na maioria das vezes, o melhor Singleton é aquele que você não escreve.
No próximo artigo da série, vamos ao Factory Method, o padrão que resolve o problema oposto: quando você precisa criar objetos mas não quer decidir agora qual classe concreta instanciar.







Top comments (0)