O que é Zero-Allocation em C#
Se você já trabalhou com C# em aplicações de alta performance, provavelmente já se deparou com o Garbage Collector (GC) pausing a aplicação nos momentos mais inconvenientes. O GC é responsável por liberar memória de objetos que não são mais usados, mas cada coleta tem um custo: pausas, latência é consumo de CPU.
Zero-allocation é a prática de escrever código que aloca o mínimo possível de objetos na heap gerenciada. Quando você evita criar objetos desnecessários, o GC tem menos trabalho a fazer. O resultado é uma aplicação mais rápida, com latência mais previsível é menor uso de memória.
Essa abordagem é especialmente relevante para APIs de alta requisição, jogos, sistemas de trading, processamento em tempo real é qualquer cenário onde cada milissegundo conta. Com as ferramentas modernas do .NET, é possível atingir esse objetivo sem abrir mão da produtividade do C#.
Como funciona o Garbage Collector no .NET
O GC do .NET organiza a heap em três gerações: Gen0, Gen1 é Gen2. Objetos recém-criados ficam na Gen0, que é coletada com frequência. Objetos que sobrevivem múltiplas coletas sobem para Gen1 é Gen2, que são coletadas com menor frequência, mas com maior custo quando ocorrem.
O problema clássico acontece quando você cria muitos objetos de vida curta, especialmente dentro de loops ou em caminhos críticos. Cada new na heap gerenciada pode eventualmente desencadear uma coleta de Gen0. Acumule pressão suficiente é você pode ver pausas de dezenas ou centenas de milissegundos.
Entender esse mecanismo é o primeiro passo. A partir daí, você começa a perceber onde no seu código os objetos são criados desnecessariamente é quais técnicas usar para evitá-los.
Principais recursos para zero-allocation em C#
O .NET moderno oferece um conjunto robusto de ferramentas para minimizar alocações:
- Span<T> é ReadOnlySpan<T>: tipos por valor que representam fatias contíguas de memória. Funcionam com arrays, stackalloc é buffers sem alocar na heap.
- stackalloc: aloca memória na stack em vez da heap. Perfeito para buffers temporários de tamanho fixo é pequeno.
- ArrayPool<T>: pool de arrays reutilizáveis. Em vez de criar é descartar arrays, você aluga é devolve.
- Structs: tipos por valor vivem na stack ou inline em outros objetos, sem pressão no GC.
- ValueTask é ValueTask<T>: alternativa a Task para métodos async que completam sincronicamente na maioria dos casos.
- Memory<T> é MemoryPool<T>: versão de Span que pode ser armazenada fora da stack, para cenários assíncronos.
Cada uma dessas ferramentas resolve um problema específico. O segredo é saber quando é onde aplicar cada uma.
Como começar: instalação é configuração
Não há instalação necessária, pois todos esses recursos fazem parte do .NET base (a partir do .NET Core 2.1+). Para medir o impacto das suas mudanças, instale o BenchmarkDotNet:
dotnet add package BenchmarkDotNet
Para análise de alocações em tempo real, use o dotMemory da JetBrains ou o próprio Visual Studio Diagnostic Tools. A extensão Heap Allocation Viewer para Rider é VS também é muito útil: ela mostra diretamente no editor quais linhas estão alocando objetos na heap.
Com essas ferramentas, você consegue medir o baseline antes de otimizar é confirmar que as mudanças realmente reduziram as alocações. Otimizar sem medir é adivinhar.
Exemplo prático: do código ingênuo ao zero-allocation
Cenário clássico: parsear um CSV linha a linha. A abordagem ingênua usa string.Split(), que aloca um array novo a cada chamada. Com Span<T>, você pode fatiar a string sem alocar:
Antes (aloca a cada linha):var parts = line.Split(',');
var name = parts[0];
var value = parts[1];
Depois (zero alocação com Span):ReadOnlySpan<char> span = line.AsSpan();
int idx = span.IndexOf(',');
var name = span.Slice(0, idx);
var value = span.Slice(idx + 1);
Para buffers temporários, troque new byte[1024] por stackalloc byte[1024] (quando o tamanho for pequeno é fixo) ou por ArrayPool<byte>.Shared.Rent(1024) (quando o tamanho for variável). Lembre-se de devolver o array ao pool com ArrayPool<byte>.Shared.Return(buffer) no bloco finally.
Comparação com outras abordagens
Outras linguagens resolvem esse problema de forma diferente. Em Rust, a ausência de GC é uma garantia da linguagem: você gerência memória manualmente com segurança em tempo de compilação. O custo é uma curva de aprendizado maior. Em Go, o GC é mais previsível é com pausas menores, mas você ainda tem um GC. Em Java, as técnicas são similares (off-heap, object pools), mas menos ergonômicas que as de C#.
A vantagem do C# moderno é que você pode escolher o nível de controle. Para 90% do código, escreva naturalmente. Para os 10% críticos, aplique as técnicas de zero-allocation. Você não precisa abrir mão da produtividade para ter performance.
Dentro do ecossistema .NET, frameworks como ASP.NET Core é gRPC já foram otimizados internamente com essas técnicas. Entendê-las te ajuda a usá-los melhor é a saber quando criar suas próprias otimizações.
Pontos positivos é limitações
Prós: redução significativa de latência em percentis altos (P99, P999), menor uso de memória, throughput maior em aplicações de alta carga. Quando bem aplicado, você pode eliminar pausas do GC completamente em caminhos críticos.
Contras: o código fica mais verboso é difícil de ler. Span<T> tem restrições (não pode ser armazenado em campos de classe, não funciona em métodos async nativamente). stackalloc com tamanhos grandes pode causar stack overflow. ArrayPool exige disciplina para devolver os arrays.
A maior armadilha é a premature optimization: aplicar essas técnicas em código que não é gargalo. Sempre meça primeiro com BenchmarkDotNet ou profiling real. Código complicado sem ganho mensurável é pior do que código simples com alocações moderadas.
Casos de uso reais
APIs de alta requisição: um endpoint que processa 10.000 requisições por segundo é aloca 1KB por requisição gera 10MB/s de pressão no GC. Com zero-allocation, você pode servir mais requisições com a mesma infraestrutura.
Parsing de dados binários: protocolos de rede, arquivos binários, buffers de mensagens. Span<T> é perfeito para fatiar é interpretar bytes sem cópias desnecessárias.
Game servers é simulações: loops de jogo rodam dezenas de vezes por segundo. Uma pausa do GC no meio de uma partida é perceptível pelo jogador. Zero-allocation elimina esse problema.
Pipelines de dados: transformação de grandes volumes de dados onde cada operação pequena pode acumular muita pressão no GC ao longo do tempo.
Dicas é boas práticas
Comece pelo Heap Allocation Viewer no seu IDE para identificar onde estão as alocações. Foque nos caminhos mais chamados primeiro, não nos mais feios visualmente. Uma alocação em código chamado 1 vez não importa; a mesma alocação em um loop de 100.000 iterações importa muito.
Use readonly struct para structs imutáveis é evite boxing involuntário (passar struct como object ou interface). Prefira ValueTask a Task em métodos async que geralmente completam de forma síncrona, como leituras de cache. Configure o GC no modo Server (gcServer: true no runtimeconfig.json) para aplicações web em produção.
Uma dica prática: use string.Create() em vez de concatenação ou StringBuilder quando precisar montar strings em caminhos críticos. Ele permite escrever diretamente no buffer sem alocações intermediárias.
Vale a pena?
Para a maioria das aplicações .NET de negócio, não é necessário. O GC do .NET moderno é excelente é o overhead de alocações normais raramente é o gargalo real. Se sua API responde em 50ms, economizar 2ms de GC não vai mudar a experiência do usuário.
Mas se você trabalha com sistemas onde latência P99 é P999 são críticos, onde o throughput máximo é um requisito funcional, ou onde cada alocação custa dinheiro em cloud, zero-allocation é uma habilidade essencial. O próximo passo prático é instalar o BenchmarkDotNet, rodar um benchmark de uma função crítica do seu sistema é ver quantas alocações por operação você tem. Os números vão te surpreender.
Comentários
Deixar um comentárioVocê precisa ter uma conta no CuritibaBlog para comentar.