.NET Performance Memória BenchmarkDotNet

Span, Memory e ArrayPool no .NET: zero-alloc em operações de alto volume

Aprenda a usar Span<T>, Memory<T>, ReadOnlySpan<char> e ArrayPool<T> para eliminar alocações desnecessárias no heap em parsers.

N
Neryx Digital Architects
12 de fevereiro de 2026
15 min de leitura
270 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

Toda vez que você chama string.Substring(), string.Split() ou cria um byte[] temporário para processar uma mensagem, está alocando memória no heap gerenciado. Em aplicações de baixo volume isso é irrelevante. Em APIs que processam milhares de requests por segundo, ou em parsers que percorrem megabytes de dados por minuto, essas alocações acumulam e forçam o GC a pausar seu processo em momentos críticos.

Span<T>, Memory<T> e ArrayPool<T> são as ferramentas do .NET para eliminar alocações desnecessárias — trabalhando diretamente sobre buffers existentes, sem copiar dados.

O problema: alocações ocultas

// Aparentemente simples — mas cria 3 objetos no heap por chamada
public static string ExtrairDominio(string email)
{
    var partes = email.Split('@');      // aloca string[]
    return partes[1].Split('.')[0];     // aloca mais string[]s e strings
}

// Em 10.000 req/s com emails médios de 30 chars:
// ~300KB de alocações por segundo apenas nessa função
// GC Gen0 coleta frequentemente → latência variável

Span<T>: uma janela sobre memória existente

Span<T> é um tipo por referência alocado na stack (ref struct) que representa uma fatia de memória contígua — pode ser um array, uma string, memória nativa ou stack-allocated. Não aloca nada no heap.

// Sem Span
string sub = texto.Substring(5, 10);  // aloca nova string

// Com Span — referencia a string original, sem cópia
ReadOnlySpan<char> sub = texto.AsSpan(5, 10); // zero alocação

Reescrevendo ExtrairDominio sem alocação

public static ReadOnlySpan<char> ExtrairDominio(ReadOnlySpan<char> email)
{
    int arroba = email.IndexOf('@');
    if (arroba < 0) return ReadOnlySpan<char>.Empty;

    var depois = email[(arroba + 1)..];

    int ponto = depois.IndexOf('.');
    return ponto < 0 ? depois : depois[..ponto];
}

// Uso:
var email = "danilo@neryx.com.br".AsSpan();
var dominio = ExtrairDominio(email); // "neryx" — zero alocações

Se você precisar de uma string real no final (por exemplo, para armazenar em banco), chame dominio.ToString() apenas uma vez — alocando apenas o resultado final, não os intermediários.

Operações de Span mais usadas

ReadOnlySpan<char> linha = "2026-07-25;PEDIDO;R$150,00".AsSpan();

// Fatiar
var data    = linha[..10];          // "2026-07-25"
var tipo    = linha[11..17];        // "PEDIDO"
var valor   = linha[18..];          // "R$150,00"

// Buscar
int idx = linha.IndexOf(';');
int lastIdx = linha.LastIndexOf(';');

// Comparar sem alocar
bool isDolar = valor.StartsWith("R$");
bool isIgual = data.SequenceEqual("2026-07-25".AsSpan());

// Parsear números diretamente do span
var valorSpan = valor[2..];  // "150,00"
// int.Parse, double.TryParse etc. aceitam ReadOnlySpan<char> nativamente
if (decimal.TryParse(valorSpan, out var preco)) { /* ... */ }

// Iterar
foreach (char c in linha) { /* ... */ }

// Split sem alocação (disponível via MemoryExtensions)
foreach (var parte in linha.Split(';'))
{
    // parte é Range — use linha[parte] para obter ReadOnlySpan
    Console.WriteLine(linha[parte].ToString());
}

Limitações do Span: por que existe Memory<T>

Span<T> é um ref struct — existe apenas na stack. Isso significa que ele não pode:

  • Ser armazenado em campos de classe
  • Ser usado em métodos async
  • Cruzar fronteiras de await
  • Ser boxed para object

Memory<T> resolve isso: é um tipo normal (não ref struct) que encapsula uma referência ao buffer original. Você pode armazená-lo em campos, passá-lo por await e convertê-lo para Span quando precisar operar sobre ele:

public class ProcessadorAssync
{
    private readonly Memory<byte> _buffer; // pode ser campo

    public ProcessadorAssync(byte[] dados)
    {
        _buffer = dados.AsMemory();
    }

    public async Task ProcessarAsync(CancellationToken ct)
    {
        // Pode cruzar await
        await Task.Delay(10, ct);

        // Converte para Span dentro do método síncrono
        Span<byte> span = _buffer.Span;
        span.Fill(0); // limpa o buffer
    }
}

// Fatiamento de Memory
Memory<byte> cabecalho = _buffer[..4];
Memory<byte> corpo     = _buffer[4..];

stackalloc: alocação na stack para buffers pequenos

Para buffers temporários de tamanho pequeno e conhecido, stackalloc aloca diretamente na stack — sem GC, sem heap:

public static string CapitalizarPalavra(ReadOnlySpan<char> palavra)
{
    if (palavra.IsEmpty) return string.Empty;

    // Buffer temporário na stack — sem alocação no heap
    Span<char> resultado = stackalloc char[palavra.Length];
    palavra.CopyTo(resultado);
    resultado[0] = char.ToUpper(resultado[0]);

    return new string(resultado); // apenas uma alocação — o resultado final
}

Regra de ouro: use stackalloc apenas para buffers menores que ~1KB. Buffers maiores devem ir para o ArrayPool.

ArrayPool<T>: reutilizando buffers do heap

Quando o buffer precisa ser maior que o razoável para stack ou quando precisa sobreviver além do método, ArrayPool<T>.Shared empresta arrays de um pool gerenciado — você usa e devolve, evitando novas alocações:

public static async Task<int> LerEmChunksAsync(
    Stream stream, CancellationToken ct)
{
    // Empresta um buffer do pool — zero alocação
    byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
    int totalLido = 0;

    try
    {
        int bytesLidos;
        while ((bytesLidos = await stream.ReadAsync(buffer, ct)) > 0)
        {
            // Span sobre a parte preenchida do buffer
            var dados = buffer.AsSpan(0, bytesLidos);
            ProcessarChunk(dados);
            totalLido += bytesLidos;
        }
    }
    finally
    {
        // OBRIGATÓRIO: devolver ao pool, mesmo em exceção
        ArrayPool<byte>.Shared.Return(buffer, clearArray: false);
    }

    return totalLido;
}

O clearArray: false é mais rápido — use true apenas quando o buffer pode conter dados sensíveis (senhas, tokens).

Cuidado: o buffer emprestado pode ser maior que o pedido

// Rent(100) pode retornar um array de 128 bytes
byte[] buffer = ArrayPool<byte>.Shared.Rent(100);
Console.WriteLine(buffer.Length); // pode ser 128, 256, etc.

// SEMPRE use a fatia com o tamanho real pedido
var fatia = buffer.AsSpan(0, 100); // não buffer.AsSpan()

Benchmark: a diferença em números

[MemoryDiagnoser]
public class SpanBenchmark
{
    private const string CsvLinha = "2026-07-25;PEDIDO-12345;cliente@neryx.com.br;R$1.250,00";

    [Benchmark(Baseline = true)]
    public string[] ParseComSplit()
        => CsvLinha.Split(';');

    [Benchmark]
    public int ParseComSpan()
    {
        // Conta campos sem alocar — retorna contagem como prova de trabalho
        var span = CsvLinha.AsSpan();
        int count = 0;
        foreach (var _ in span.Split(';')) count++;
        return count;
    }

    [Benchmark]
    public string ExtrairEmailTradicional()
    {
        var partes = CsvLinha.Split(';');
        return partes[2]; // "cliente@neryx.com.br"
    }

    [Benchmark]
    public ReadOnlySpan<char> ExtrairEmailSpan()
    {
        var span = CsvLinha.AsSpan();
        int i1 = span.IndexOf(';');
        var resto = span[(i1 + 1)..];
        int i2 = resto.IndexOf(';');
        var resto2 = resto[(i2 + 1)..];
        int i3 = resto2.IndexOf(';');
        return resto2[..i3]; // "cliente@neryx.com.br"
    }
}

Resultados típicos (.NET 8, x64):

Método Média Alocações
ParseComSplit 185 ns 184 B
ParseComSpan 32 ns 0 B
ExtrairEmailTradicional 210 ns 232 B
ExtrairEmailSpan 18 ns 0 B

~6× mais rápido, zero alocações. Em 100k req/s, a diferença entre alocações é ~22MB/s a menos de pressão no GC.

Padrões práticos em aplicações reais

Parser de CSV sem alocação

public static IEnumerable<Pedido> ParsearCsv(ReadOnlySpan<char> conteudo)
{
    foreach (var linhaRange in conteudo.Split('\n'))
    {
        var linha = conteudo[linhaRange].Trim();
        if (linha.IsEmpty) continue;

        var campos = linha.Split(';');
        var it = campos.GetEnumerator();

        it.MoveNext(); var id    = linha[it.Current];
        it.MoveNext(); var valor = linha[it.Current];
        it.MoveNext(); var data  = linha[it.Current];

        yield return new Pedido(
            int.Parse(id),
            decimal.Parse(valor),
            DateOnly.Parse(data)
        );
    }
}

Deserialização de protocolo binário

public static Mensagem Desserializar(ReadOnlySpan<byte> payload)
{
    // Lê header sem copiar
    var tipo    = payload[0];
    var versao  = payload[1];
    var tamanho = BinaryPrimitives.ReadUInt16LittleEndian(payload[2..4]);
    var corpo   = payload[4..(4 + tamanho)];

    return new Mensagem(tipo, versao, corpo.ToArray()); // ToArray apenas no final
}

Buffer pool em middleware

public class CompressionMiddleware(RequestDelegate next)
{
    public async Task InvokeAsync(HttpContext ctx)
    {
        var buffer = ArrayPool<byte>.Shared.Rent(8192);
        try
        {
            var memory = buffer.AsMemory(0, 8192);
            // usa memory como buffer temporário para compressão
            await next(ctx);
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }
}

Guia rápido de escolha

Cenário Ferramenta
Parsear string sem criar substrings ReadOnlySpan<char>
Manipular array sem cópia em método síncrono Span<T>
Buffer que precisa cruzar await Memory<T>
Buffer temporário < 1KB em método síncrono stackalloc + Span<T>
Buffer temporário > 1KB ou em async ArrayPool<T>.Shared.Rent()
Ler dados de Stream em chunks ArrayPool + Memory

Conclusão

Span<T>, Memory<T> e ArrayPool<T> compõem o toolkit de low-allocation programming do .NET. Eles não exigem mudanças arquiteturais — você os aplica cirurgicamente nos hot paths identificados pelo profiler ou pelo [MemoryDiagnoser] do BenchmarkDotNet.

A estratégia prática: meça primeiro (BenchmarkDotNet + dotMemory/PerfView), identifique as funções com mais alocações no hot path, e aplique o padrão correto em cada caso. Não tente reescrever a aplicação inteira — o Pareto vale aqui: 20% das funções causam 80% das alocações problemáticas.

Precisa resolver isso na prática?

Se você tem APIs com latência variável causada por GC, podemos fazer um diagnóstico de performance — profiling, identificação de hot paths e refatoração orientada a dados.

Falar com um especialista →

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.