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
}
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;
}
}
Percebeu? Duas alocações no heap onde você achava que não tinha nenhuma:
- Uma instância da classe
DisplayClasspara armazenar as variáveis capturadas - 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);
}
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;
}
}
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);
}
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));
}
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
);
}
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;
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);
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;
}
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);
}
}
}
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);
}
}
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
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
}
4. dotnet-counters para Monitoramento em Runtime
dotnet-counters monitor --process-id <PID> \
--counters System.Runtime[gc-heap-size,alloc-rate]
Quando Closures são aceitáveis
Nem toda closure é problemática. Use closures livremente quando:
- Código executado raramente: Setup de aplicação, configuração inicial
- Código não crítico para performance: UI handlers em aplicações desktop
- Clareza supera performance: Código de domínio onde legibilidade é prioridade
- 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();
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:
- Entender o que o compilador gera
- Medir o impacto real com benchmarks
- 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)