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

ConceitoO 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.