.NET Performance Collections BenchmarkDotNet

Frozen Collections no .NET 8: coleções imutáveis de alta performance para dados de referência

Entenda como FrozenDictionary e FrozenSet do .NET 8 entregam lookups até 40% mais rápidos que Dictionary e ImmutableDictionary.

N
Neryx Digital Architects
11 de novembro de 2025
12 min de leitura
200 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

O .NET 8 introduziu FrozenDictionary<TKey, TValue> e FrozenSet<T> no namespace System.Collections.Frozen. Eles resolvem um problema real: quando você tem dados que são construídos uma vez (na inicialização da aplicação) e depois apenas lidos repetidamente em caminhos de alta frequência, o Dictionary padrão desperdiça capacidade de sincronização e hashing que nunca será usada. As Frozen Collections trocam flexibilidade de escrita por velocidade máxima de leitura.

O problema que elas resolvem

Considere cenários comuns em aplicações .NET de produção:

  • Tabelas de configuração carregadas na inicialização (países, estados, códigos de erro, permissões)
  • Lookup tables de domínio (tipos de produto, categorias, mapeamentos de status)
  • Dicionários de localização (strings de UI por idioma)
  • Caches de metadados via reflection (como mapeamento de propriedades)

Nesses casos, você quer apenas TryGetValue e Contains — nunca Add ou Remove. O Dictionary<TKey, TValue> funciona, mas carrega overhead de uma estrutura mutável: buckets com espaço para crescimento, versioning interno para detecção de modificações concorrentes, e hashing genérico.

O ImmutableDictionary resolve a imutabilidade, mas seu custo de lookup é maior que o Dictionary comum — ele usa uma árvore AVL internamente, o que penaliza leitura para garantir imutabilidade estrutural com custo O(log n).

As Frozen Collections ficam no meio-termo ideal: construção lenta (O(n) com otimização do perfect hashing), leitura ultra-rápida.

API básica

using System.Collections.Frozen;

// Construção — feita uma vez
var paises = new Dictionary<string, string>
{
    ["BR"] = "Brasil",
    ["US"] = "Estados Unidos",
    ["PT"] = "Portugal",
    // ... outros países
}.ToFrozenDictionary();

// Leitura — feita milhões de vezes
if (paises.TryGetValue("BR", out var nome))
    Console.WriteLine(nome); // Brasil

// FrozenSet
var codigosPermitidos = new HashSet<string> { "ADM", "GER", "SUP" }
    .ToFrozenSet();

bool isPermitido = codigosPermitidos.Contains("ADM"); // true

A API imita exatamente IDictionary<K,V> e ISet<T>. A transição de código existente é praticamente zero.

Como funciona internamente

Durante o ToFrozenDictionary(), o runtime analisa as chaves e escolhe a estratégia de hashing mais rápida para aquele conjunto específico:

  • SmallValueTypeComparableFrozenDictionary — para dicionários pequenos (< ~10 itens) com value types comparáveis
  • StringComparerFrozenDictionary — otimizado para chaves string, usa perfect hashing baseado em substrings das chaves
  • Int32FrozenDictionary — para chaves int, usa acesso direto por índice quando possível
  • DefaultFrozenDictionary — caso geral com hashing otimizado após análise do conjunto

A ideia central do perfect hashing para strings: o runtime inspeciona as chaves e identifica uma combinação de posição + comprimento que diferencia todas elas univocamente. Por exemplo, para as chaves {"BR", "US", "PT"}, basta comparar o primeiro caractere — não precisa fazer hash da string inteira. Esse atalho pode tornar o lookup 2–4× mais rápido que o hash genérico.

Benchmark real

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class FrozenBenchmark
{
    private Dictionary<string, int> _dict = null!;
    private ImmutableDictionary<string, int> _immutable = null!;
    private FrozenDictionary<string, int> _frozen = null!;
    private string[] _keys = null!;

    [Params(10, 100, 1000)]
    public int Size;

    [GlobalSetup]
    public void Setup()
    {
        var source = Enumerable.Range(0, Size)
            .ToDictionary(i => $"chave_{i:D4}", i => i);

        _dict      = new Dictionary<string, int>(source);
        _immutable = source.ToImmutableDictionary();
        _frozen    = source.ToFrozenDictionary();
        _keys      = source.Keys.ToArray();
    }

    [Benchmark(Baseline = true)]
    public int Dictionary_TryGetValue()
    {
        var sum = 0;
        foreach (var k in _keys)
            if (_dict.TryGetValue(k, out var v)) sum += v;
        return sum;
    }

    [Benchmark]
    public int ImmutableDictionary_TryGetValue()
    {
        var sum = 0;
        foreach (var k in _keys)
            if (_immutable.TryGetValue(k, out var v)) sum += v;
        return sum;
    }

    [Benchmark]
    public int FrozenDictionary_TryGetValue()
    {
        var sum = 0;
        foreach (var k in _keys)
            if (_frozen.TryGetValue(k, out var v)) sum += v;
        return sum;
    }
}

Resultados típicos (máquina x64, .NET 8 Release):

Método Size Média Ratio Alocações
Dictionary 100 1.82 µs 1.00× 0 B
ImmutableDictionary 100 6.54 µs 3.59× 0 B
FrozenDictionary 100 1.09 µs 0.60× 0 B
Dictionary 1000 19.1 µs 1.00× 0 B
ImmutableDictionary 1000 77.3 µs 4.05× 0 B
FrozenDictionary 1000 11.2 µs 0.59× 0 B

O FrozenDictionary é consistentemente ~40% mais rápido que Dictionary e ~4× mais rápido que ImmutableDictionary para leituras em conjuntos de tamanho médio.

Casos de uso práticos

1. Lookup table de domínio registrada como Singleton

// Registro na DI
builder.Services.AddSingleton(sp =>
{
    var db = sp.GetRequiredService<AppDbContext>();

    // Carregado uma vez no startup, nunca mais modificado
    return db.Categorias
        .AsNoTracking()
        .ToList()
        .ToFrozenDictionary(c => c.Codigo, c => c.Descricao);
});

// Uso no handler (injeção de FrozenDictionary direto)
public class ProdutoHandler(FrozenDictionary<string, string> categorias)
{
    public string ResolverCategoria(string codigo)
        => categorias.TryGetValue(codigo, out var desc) ? desc : "Desconhecida";
}

2. Mapeamento de permissões por role

public static class PermissaoConfig
{
    public static readonly FrozenDictionary<string, FrozenSet<string>> PorRole =
        new Dictionary<string, string[]>
        {
            ["admin"]    = ["criar", "editar", "deletar", "visualizar"],
            ["gerente"]  = ["criar", "editar", "visualizar"],
            ["operador"] = ["visualizar"],
        }
        .ToFrozenDictionary(
            kvp => kvp.Key,
            kvp => kvp.Value.ToFrozenSet(StringComparer.OrdinalIgnoreCase)
        );

    public static bool Pode(string role, string acao)
        => PorRole.TryGetValue(role, out var permissoes) && permissoes.Contains(acao);
}

// No middleware de autorização — chamado em cada request
if (!PermissaoConfig.Pode(user.Role, "criar"))
    return Results.Forbid();

3. Tabela de lookup de códigos HTTP custom

public static class ErrorCatalog
{
    private static readonly FrozenDictionary<int, ErrorInfo> _catalog =
        LoadFromJson("errors.json").ToFrozenDictionary(e => e.Code);

    public static ErrorInfo? Get(int code)
        => _catalog.TryGetValue(code, out var info) ? info : null;
}

Quando NÃO usar

As Frozen Collections têm trade-offs claros:

  • Construção lenta: ToFrozenDictionary() é O(n) com constante maior que o Dictionary. Não use em hot paths de construção.
  • Sem updates: são completamente imutáveis. Se precisar atualizar durante a vida da aplicação, use ConcurrentDictionary ou o padrão de snapshot com Interlocked.Exchange.
  • Conjuntos muito pequenos (< 4 itens): o overhead de construção não compensa. Um simples array com busca linear é mais rápido.
  • Conjuntos muito grandes (> 100k itens) com chaves altamente aleatórias: o ganho de perfect hashing diminui e o custo de construção aumenta. Meça antes de adotar.

Compatibilidade com interfaces existentes

FrozenDictionary<K,V> implementa IReadOnlyDictionary<K,V> e IReadOnlyCollection<KeyValuePair<K,V>>, mas não IDictionary<K,V>. Se seu código usa IDictionary diretamente, você precisará ajustar as interfaces. A refatoração é mínima na maioria dos casos.

// Funciona
IReadOnlyDictionary<string, int> config = dados.ToFrozenDictionary();

// Não compila — FrozenDictionary não é IDictionary
IDictionary<string, int> config = dados.ToFrozenDictionary(); // erro

FrozenSet para verificações de existência

FrozenSet<T> segue a mesma lógica: construção lenta, Contains ultrarrápido. Útil para listas de IDs permitidos, palavras reservadas, extensões de arquivo válidas:

public static class ExtensoesBloqueadas
{
    private static readonly FrozenSet<string> _set =
        new[] { ".exe", ".bat", ".ps1", ".sh", ".cmd", ".vbs" }
        .ToFrozenSet(StringComparer.OrdinalIgnoreCase);

    public static bool EstaBloqueada(string extensao)
        => _set.Contains(extensao);
}

// No upload handler — chamado em cada arquivo enviado
if (ExtensoesBloqueadas.EstaBloqueada(Path.GetExtension(arquivo.FileName)))
    return Results.BadRequest("Extensão não permitida");

Conclusão

FrozenDictionary e FrozenSet são a escolha certa sempre que você tem dados que são carregados uma vez e lidos muito frequentemente. O ganho de 40% em lookups sobre o Dictionary — sem nenhuma alocação extra — pode fazer diferença real em APIs de alta frequência onde essas estruturas estão no caminho crítico de cada request.

O padrão de adoção é simples: construa seus dados normalmente com Dictionary/HashSet, chame .ToFrozenDictionary() / .ToFrozenSet() no final da inicialização, e registre como Singleton na DI. O restante do código não muda.

Precisa resolver isso na prática?

Performance em APIs de alta frequência muitas vezes é questão de escolher a estrutura de dados certa no momento certo. Se você tem gargalos de performance e quer um diagnóstico técnico, podemos ajudar.

Falar com um especialista →

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.