Performance
Escopo: C#. Visão transversal: shared/platform/performance.md.
As técnicas deste guia valem para hot paths (caminhos quentes), os trechos que executam muitas vezes por segundo ou processam volume grande. Fora deles, escolha a versão mais legível. Meça antes de trocar código legível por código rápido, porque a intuição sobre onde está o gargalo erra com frequência, e BenchmarkDotNet responde em minutos. As duas ferramentas que mais aparecem aqui são Span<T>, que fatia texto e arrays sem alocar memória nova, e o StringBuilder, que monta texto sem criar uma string a cada volta do laço.
Conceitos fundamentais
| Conceito | O que é |
|---|---|
| hot path (caminho quente) | Trecho executado em volume ou frequência alta; única região onde otimizações pagam o custo |
| Span<T> (fatia de memória) | Tipo do .NET que representa janela sobre memória existente; sem alocação |
| ReadOnlySpan<char> (fatia somente leitura) | Janela somente leitura sobre uma string; substitui Substring em hot paths |
| GC (Garbage Collector · Coletor de Lixo) | Subsistema que libera memória; alocações em laço pressionam o GC e geram pausas |
| allocation (alocação) | Reserva de memória no heap gerenciado; new, boxing e concatenação alocam |
| boxing (encaixotamento) | Cópia de tipo de valor para o heap quando atribuído a object/interface; evite em laços |
| StringBuilder (construtor de strings) | Tipo que acumula strings sem realocar a cada concatenação |
| BenchmarkDotNet (biblioteca de benchmarking) | Ferramenta padrão para medir antes de decidir; números reais antes de mudar código |
Span<T>
Split, Substring e IndexOf criam objetos novos a cada chamada. Numa rota chamada mil vezes por segundo, esse lixo se acumula e o GC (Garbage Collector · Coletor de Lixo) precisa pausar a aplicação para limpá-lo. ReadOnlySpan<char> aponta para um pedaço da string que já existe na memória, sem copiar nada: mesma posição, janela diferente. A alocação só acontece no .ToString() final, quando o pedaço precisa virar string de verdade.
❌ Ruim: Split aloca array e strings intermediárias
public string ExtractProductCode(string sku)
{
var parts = sku.Split('-');
var code = parts[0];
return code;
}
✅ Bom: Span fatia sem alocar
public string ExtractProductCode(string sku)
{
var span = sku.AsSpan();
var separatorIndex = span.IndexOf('-');
var code = span[..separatorIndex].ToString();
return code;
}
Span<T> também serve para arrays. Quando o método recebe T[] e o laço roda em alta frequência, ReadOnlySpan<T> percorre os itens direto pelo índice, sem passar pelo enumerador que o foreach cria.
❌ Ruim: foreach sobre array em hot path
public decimal SumLineItemAmounts(OrderItem[] items)
{
var total = 0m;
foreach (var item in items)
{
total += item.Amount;
}
return total;
}
✅ Bom: ReadOnlySpan elimina a indireção do enumerador
public decimal SumLineItemAmounts(OrderItem[] items)
{
ReadOnlySpan<OrderItem> span = items;
var total = 0m;
for (var i = 0; i < span.Length; i++)
{
total += span[i].Amount;
}
return total;
}
StringBuilder
Em .NET, a string não muda depois de criada. Por isso summary += "..." dentro de um laço não acrescenta texto ao que existia: ele cria uma string nova, com o conteúdo antigo mais o novo, e joga a anterior fora. Cem itens produzem cem strings descartadas. StringBuilder mantém um buffer que cresce conforme necessário e produz a string uma única vez, no ToString().
❌ Ruim: nova string alocada por iteração
public string BuildOrderSummary(IEnumerable<OrderItem> items)
{
var summary = "";
foreach (var item in items)
{
summary += $"{item.ProductName}: {item.Quantity}x\n";
}
return summary;
}
✅ Bom: StringBuilder reutiliza o buffer
public string BuildOrderSummary(IEnumerable<OrderItem> items)
{
var builder = new StringBuilder();
foreach (var item in items)
{
builder.AppendLine($"{item.ProductName}: {item.Quantity}x");
}
var summary = builder.ToString();
return summary;
}
ValueTask
Todo Task<T> cria um objeto na memória, mesmo quando a resposta já estava pronta e nada precisou ser esperado. É o caso do método que consulta o cache e encontra o valor: ele aloca o objeto de uma operação assíncrona sem ter esperado por nada. ValueTask<T> evita essa alocação quando o resultado vem na hora, e por isso cabe em repositório, cache e validator, que são chamados o tempo todo e acertam o cache na maioria das vezes.
❌ Ruim: Task aloca mesmo quando o resultado está em cache
public async Task<Product?> FindProductAsync(Guid id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var cached))
return cached;
var product = await _repository.FindByIdAsync(id, ct);
return product;
}
✅ Bom: ValueTask sem alocação no caminho síncrono
public async ValueTask<Product?> FindProductAsync(Guid id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out var cached))
return cached;
var product = await _repository.FindByIdAsync(id, ct);
return product;
}
Deixe o Task onde o método quase sempre espera de verdade por uma resposta. Ali o ganho não aparece, e o ValueTask traz uma regra a mais para lembrar: ele só pode ser aguardado uma vez, e aguardá-lo duas vezes é um bug que o compilador não pega.
Guid v4 e Guid v7 como identificador
Guid.NewGuid() produz um UUID (Universally Unique Identifier · Identificador Universalmente Único) v4, que é aleatório. Como chave primária, isso significa que cada linha nova vai parar num ponto qualquer do índice, e o banco precisa abrir espaço no meio de páginas já cheias. Guid.CreateVersion7() (.NET 9+) começa pelo horário de criação, então os identificadores nascem em ordem crescente e cada linha nova entra no fim do índice. Veja o efeito disso no banco em sql/conventions/advanced/performance.md.
A versão fica escrita no próprio valor. O identificador tem cinco grupos separados por hífen, e o primeiro caractere do terceiro grupo é o número da versão: um v4 sempre mostra 4 nessa posição, e um v7 sempre mostra 7. Olhar para um identificador já diz qual dos dois você tem em mãos.
❌ Ruim: Guid.NewGuid() gera v4 aleatório e fragmenta o índice
public Order CreateOrder(CreateOrderRequest request)
{
// 3f2a9c71-8b4d-4e6f-a1c3-7d5e9b2f4a80
// ^ v4: aleatório, cada linha cai em um ponto qualquer do índice
var orderId = Guid.NewGuid();
var order = new Order(orderId, request.CustomerId, request.Total);
return order;
}
✅ Bom: Guid.CreateVersion7() nasce em ordem de tempo e entra no fim do índice
public Order CreateOrder(CreateOrderRequest request)
{
// 019842f0-6c1a-7b3e-9d4f-2a1b3c4d5e6f
// ^ v7: começa pelo horário, então cresce a cada chamada
var orderId = Guid.CreateVersion7();
var order = new Order(orderId, request.CustomerId, request.Total);
return order;
}
DoDocs v3.7.0 · Desenvolvido por @thiagocajadev · Baseado no trabalho de pmndrs/docs · Poimandres.