DEV Community

Cristiano Rodrigues for Unhacked

Posted on

Closures no .NET: As Alocações Invisíveis que Estão Matando sua Performance

Você já parou para pensar no que realmente acontece quando usa uma lambda que captura uma variável externa? Aquele código aparentemente inocente pode estar gerando alocações no heap sem você perceber, criando pressão no Garbage Collector e degradando a performance da sua aplicação.

Neste artigo, vamos dissecar o mecanismo de closures no .NET, entender exatamente o que o compilador gera por trás dos panos, e aprender técnicas práticas para evitar essas alocações quando performance é crítica.

O Que são Closures?

Uma closure ocorre quando uma função (lambda, delegate, ou método local) "captura" variáveis do escopo externo. O termo vem do conceito de "fechar" sobre o ambiente léxico onde a função foi definida.

public void ExemploSimples()
{
    int multiplicador = 10; // variável do escopo externo

    Func<int, int> multiplicar = x => x * multiplicador; // closure!

    Console.WriteLine(multiplicar(5)); // 50
}
Enter fullscreen mode Exit fullscreen mode

Parece simples e elegante, certo? O problema é que essa elegância tem um custo oculto.

O que o compilador realmente gera

Quando você escreve uma closure, o compilador C# não faz mágica. Ele gera uma classe oculta para armazenar as variáveis capturadas. Vamos ver o que acontece com o exemplo anterior:

// O que você escreveu:
public void ExemploSimples()
{
    int multiplicador = 10;
    Func<int, int> multiplicar = x => x * multiplicador;
    Console.WriteLine(multiplicar(5));
}

// O que o compilador gera (simplificado):
public void ExemploSimples()
{
    var displayClass = new <>c__DisplayClass0_0(); // ALOCAÇÃO NO HEAP!
    displayClass.multiplicador = 10;

    Func<int, int> multiplicar = new Func<int, int>(displayClass.<ExemploSimples>b__0); // OUTRA ALOCAÇÃO!
    Console.WriteLine(multiplicar(5));
}

[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
    public int multiplicador;

    internal int <ExemploSimples>b__0(int x)
    {
        return x * this.multiplicador;
    }
}
Enter fullscreen mode Exit fullscreen mode

Percebeu? Duas alocações no heap onde você achava que não tinha nenhuma:

  1. Uma instância da classe DisplayClass para armazenar as variáveis capturadas
  2. Uma instância do delegate Func<int, int>

O impacto real: Benchmark

Vamos medir o impacto real dessas alocações:

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class ClosureBenchmarks
{
    private const int Iterations = 1000;

    [Benchmark(Baseline = true)]
    public int SemClosure()
    {
        int soma = 0;
        for (int i = 0; i < Iterations; i++)
        {
            soma += MultiplicarSemClosure(i, 10);
        }
        return soma;
    }

    [Benchmark]
    public int ComClosure()
    {
        int multiplicador = 10;
        int soma = 0;
        for (int i = 0; i < Iterations; i++)
        {
            // Closure criada a cada iteração? Não!
            // Mas se estivesse dentro do loop...
            soma += MultiplicarComClosure(i, x => x * multiplicador);
        }
        return soma;
    }

    [Benchmark]
    public int ComClosureDentroDoLoop()
    {
        int soma = 0;
        for (int i = 0; i < Iterations; i++)
        {
            int multiplicador = 10; // variável declarada dentro do loop
            Func<int, int> func = x => x * multiplicador; // closure recriada!
            soma += func(i);
        }
        return soma;
    }

    private static int MultiplicarSemClosure(int x, int multiplicador) => x * multiplicador;

    private static int MultiplicarComClosure(int x, Func<int, int> func) => func(x);
}
Enter fullscreen mode Exit fullscreen mode

Resultado típico:

Método Mean Allocated
SemClosure 1.2 μs 0 B
ComClosure 2.8 μs 64 B
ComClosureDentroDoLoop 45.3 μs 64,000 B

O cenário ComClosureDentroDoLoop aloca 64KB em apenas 1000 iterações! Em um hot path executado milhões de vezes, isso se traduz em pressão massiva no GC.

Cenários Comuns de Closures Problemáticas

1. LINQ em Hot Paths

// PROBLEMÁTICO: closure em cada chamada
public IEnumerable<Produto> FiltrarPorCategoria(int categoriaId)
{
    return _produtos.Where(p => p.CategoriaId == categoriaId);
}

// MELHOR: evitar closure quando possível
public IEnumerable<Produto> FiltrarPorCategoria(int categoriaId)
{
    // Se performance for crítica, considere um loop tradicional
    foreach (var produto in _produtos)
    {
        if (produto.CategoriaId == categoriaId)
            yield return produto;
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Event Handlers com Captura

// PROBLEMÁTICO: cada subscription cria uma closure
public void Registrar(string userId)
{
    _eventBus.Subscribe<PedidoCriado>(evento => 
        ProcessarPedido(evento, userId)); // closure captura userId
}

// MELHOR: usar objeto de estado explícito
public void Registrar(string userId)
{
    var handler = new PedidoHandler(userId);
    _eventBus.Subscribe<PedidoCriado>(handler.Processar);
}
Enter fullscreen mode Exit fullscreen mode

3. Timers e Callbacks Assíncronos

// PROBLEMÁTICO: closure de longa duração
public void IniciarMonitoramento(string recurso)
{
    _timer = new Timer(_ => 
        VerificarStatus(recurso), // closure mantém referência
        null, 
        TimeSpan.Zero, 
        TimeSpan.FromSeconds(30));
}

// MELHOR: passar estado explicitamente
public void IniciarMonitoramento(string recurso)
{
    _timer = new Timer(
        state => VerificarStatus((string)state!),
        recurso, // estado passado explicitamente
        TimeSpan.Zero, 
        TimeSpan.FromSeconds(30));
}
Enter fullscreen mode Exit fullscreen mode

4. Parallel.For e closures

// PROBLEMÁTICO: closure compartilhada entre threads
public void ProcessarEmParalelo(int[] dados)
{
    int total = 0;
    Parallel.For(0, dados.Length, i =>
    {
        Interlocked.Add(ref total, dados[i]); // closure + contention
    });
}

// MELHOR: usar overload com estado local
public void ProcessarEmParalelo(int[] dados)
{
    int total = 0;
    Parallel.For(
        0, 
        dados.Length,
        () => 0, // inicializador de estado local
        (i, state, subtotal) => subtotal + dados[i], // sem closure
        subtotal => Interlocked.Add(ref total, subtotal) // agregação final
    );
}
Enter fullscreen mode Exit fullscreen mode

Técnicas para evitar Closures

1. Static Lambdas (C# 9+)

// Com static, o compilador IMPEDE a captura de variáveis
// Erro de compilação se tentar capturar algo
_lista.Where(static x => x.Ativo); // sem alocação de delegate

// Útil para lambdas que não precisam de estado externo
Func<int, int> dobrar = static x => x * 2;
Enter fullscreen mode Exit fullscreen mode

2. Passar estado explicitamente

Muitos métodos do .NET aceitam um parâmetro state justamente para evitar closures:

// Closure
Task.Run(() => ProcessarItem(item));

// Estado explícito
Task.Factory.StartNew(
    state => ProcessarItem((Item)state!),
    item);

// Closure com Timer
new Timer(_ => Executar(config));

// Estado explícito com Timer
new Timer(
    state => Executar((Config)state!),
    config,
    dueTime,
    period);
Enter fullscreen mode Exit fullscreen mode

3. Structs e ref struct para evitar Heap

// Usando Span<T> e stackalloc para evitar heap completamente
public int SomarPares(int[] numeros)
{
    Span<int> pares = stackalloc int[numeros.Length];
    int count = 0;

    foreach (var n in numeros)
    {
        if (n % 2 == 0)
            pares[count++] = n;
    }

    int soma = 0;
    foreach (var p in pares[..count])
        soma += p;

    return soma;
}
Enter fullscreen mode Exit fullscreen mode

4. Object Pooling para Delegates reutilizáveis

public class ProcessadorPool
{
    private readonly ObjectPool<Action<Item>> _pool;

    public ProcessadorPool()
    {
        _pool = new DefaultObjectPool<Action<Item>>(
            new DefaultPooledObjectPolicy<Action<Item>>());
    }

    public void Processar(Item item)
    {
        var action = _pool.Get();
        try
        {
            // usar action
        }
        finally
        {
            _pool.Return(action);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Cached Delegates

public class Processador
{
    // Delegate cacheado como campo estático
    private static readonly Func<Produto, bool> _filtroAtivo = 
        static p => p.Ativo;

    // Reutilizado em todas as chamadas, zero alocações adicionais
    public IEnumerable<Produto> ObterAtivos(IEnumerable<Produto> produtos)
    {
        return produtos.Where(_filtroAtivo);
    }
}
Enter fullscreen mode Exit fullscreen mode

Identificando Closures no seu código

1. JetBrains Rider / ReSharper

Configura para exibir hints de "Implicitly captured closure":

Settings → Editor → Inspection Severity → 
  "Implicitly captured closure" → Warning
Enter fullscreen mode Exit fullscreen mode

2. Visual Studio com Roslyn Analyzers

Instale o pacote Microsoft.CodeAnalysis.NetAnalyzers e habilite:

  • CA1859: Use concrete types when possible for improved performance
  • CA1822: Mark members as static

3. BenchmarkDotNet com MemoryDiagnoser

[MemoryDiagnoser]
[DisassemblyDiagnoser]
public class MeusBenchmarks
{
    // O DisassemblyDiagnoser mostra o IL/ASM gerado
}
Enter fullscreen mode Exit fullscreen mode

4. dotnet-counters para Monitoramento em Runtime

dotnet-counters monitor --process-id <PID> \
    --counters System.Runtime[gc-heap-size,alloc-rate]
Enter fullscreen mode Exit fullscreen mode

Quando Closures são aceitáveis

Nem toda closure é problemática. Use closures livremente quando:

  1. Código executado raramente: Setup de aplicação, configuração inicial
  2. Código não crítico para performance: UI handlers em aplicações desktop
  3. Clareza supera performance: Código de domínio onde legibilidade é prioridade
  4. Lambdas estáticas são suficientes: Sem captura de variáveis
// Perfeitamente aceitável em startup
services.AddSingleton(sp => 
{
    var config = sp.GetRequiredService<IConfiguration>();
    return new MinhaClasse(config["Setting"]);
});

// Perfeitamente aceitável em código de domínio
var pedidosPendentes = pedidos
    .Where(p => p.Status == StatusPedido.Pendente)
    .OrderBy(p => p.DataCriacao)
    .ToList();
Enter fullscreen mode Exit fullscreen mode

Resumo das Melhores Práticas

Cenário Recomendação
Hot paths (loops apertados, alta frequência) Evite closures completamente
LINQ em coleções grandes Considere loops tradicionais
Callbacks de longa duração Use estado explícito
Event handlers frequentes Use métodos de instância
Lambdas sem captura Use static keyword
Delegates reutilizáveis Cache como campo estático
Parallel processing Use overloads com estado local

Conclusão

Closures são uma ferramenta poderosa que torna o código C# mais expressivo e legível. No entanto, em cenários de alta performance, as alocações invisíveis que elas geram podem se tornar um gargalo significativo.

A chave não é eliminar todas as closures do seu código, mas sim:

  1. Entender o que o compilador gera
  2. Medir o impacto real com benchmarks
  3. Otimizar apenas onde necessário

Use ferramentas como BenchmarkDotNet e analyzers do Roslyn para identificar hot paths problemáticos, e aplique as técnicas apresentadas de forma cirúrgica onde o impacto é mensurável.

Lembre-se: otimização prematura é a raiz de todo mal, mas ignorância sobre o que seu código realmente faz é ainda pior.


Gostou do conteúdo? Compartilhe com outros devs .NET que precisam entender o que acontece por trás das lambdas!

Top comments (0)