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 →