Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar

Span<T> e Memory<T>: Olhar Sem Copiar, Processar Sem Alocar

Span e Memory no C#: janelas sobre arrays e strings que recortam sem copiar, as regras de um ref struct e o motivo de cada uma, stackalloc com limite seguro, ArrayPool para buffers grandes e um importador de vendas que lê milhões de linhas alocando só o resultado.
Linguagem C#

• • 15 min de leitura

Cortar um pedaço de uma string cria outra string, e isso se repete a cada linha de um arquivo; um Span olha para o mesmo trecho sem criar nada.

"Memória no .NET" terminou com um diagnóstico: cada Substring, cada Split e cada intervalo como Valor[..5] cria um objeto novo no heap, que depois o coletor de lixo precisa recolher. Para ler um arquivo de um milhão de linhas de vendas, isso significa milhões de strings de vida curtíssima: exatamente o tipo de alocação que pressiona a geração 0 e provoca pausas. Desde o .NET Core 2.1, a plataforma oferece uma resposta elegante: tipos que representam uma janela sobre uma região de memória que já existe. Com eles, dá para cortar, comparar e converter pedaços de texto e de arrays sem copiar nada, sem alocar nada e sem abrir mão da verificação de limites. Grande parte do ganho de desempenho do .NET moderno vem daqui.

O problema, medido

Uma linha do registro de vendas tem o formato data;produto;quantidade. A forma óbvia de interpretá-la:

string linha = "2026-09-01;17;3";
string[] partes = linha.Split(';');          // aloca um array e três strings novas
int produtoId = int.Parse(partes[1]);
int quantidade = int.Parse(partes[2]);

Para uma linha, é irrelevante. Para um milhão de linhas, são um milhão de arrays e três milhões de strings, todos descartados logo em seguida. As strings "17" e "3" existem apenas para serem convertidas em números e jogadas fora. Os caracteres já estavam na linha original, e copiá-los foi desperdício puro.

Span<T>: uma janela sobre memória contígua

Um Span<T> representa uma sequência contígua de elementos que já estão em algum lugar da memória: num array, numa string, num bloco da pilha. Ele guarda só duas informações: onde a sequência começa e quantos elementos tem. Criar um span, ou recortá-lo, não copia nada:

int[] numeros = [10, 20, 30, 40, 50];

Span<int> todos = numeros;                 // conversão implícita: uma janela sobre o array inteiro
Span<int> meio = numeros.AsSpan(1, 3);     // janela sobre 20, 30, 40: nenhuma cópia

meio[0] = 99;
Console.WriteLine(numeros[1]);             // 99: a janela e o array são a MESMA memória

Span<int> fatia = meio[1..];               // recortar um span também não copia
Console.WriteLine(fatia.Length);           // 2

Compare com "Guardando Muitos Valores": lá, numeros[1..4] sobre um array criava um array novo. Sobre um span, o mesmo operador cria só uma nova janela. E o acesso continua verificado: meio[5] lança IndexOutOfRangeException, exatamente como num array. Para quem vem do C, um span é o par "ponteiro + tamanho" que se passa para funções como memcpy, só que com o tamanho verificado em cada acesso e sem nenhuma possibilidade de apontar para memória liberada.

Para strings, que são imutáveis, a versão certa é ReadOnlySpan<char>, que só permite leitura:

string linha = "2026-09-01;17;3";
ReadOnlySpan<char> texto = linha.AsSpan();

ReadOnlySpan<char> data = texto[..10];     // "2026-09-01", sem criar string
int separador = texto.IndexOf(';');        // 10
bool comecaCom = texto.StartsWith("2026"); // true

A biblioteca padrão foi reescrita para aceitar spans em quase todo lugar onde aceitava strings. int.Parse, decimal.TryParse, DateOnly.TryParseExact, Trim, IndexOf, SequenceEqual e dezenas de outros têm versões que recebem ReadOnlySpan<char>:

int produtoId = int.Parse(texto.Slice(11, 2));   // 17: converte direto do trecho, sem string intermediária

As regras de um ref struct

Essa eficiência tem um preço, e o compilador faz questão de cobrá-lo. Span<T> é um ref struct: um tipo de valor que só pode viver na pilha. A razão é de segurança. Um span pode apontar para memória que só existe durante a execução de um método (como um bloco reservado na pilha, que veremos a seguir). Se ele pudesse ser guardado num objeto do heap, poderia sobreviver ao método e apontar para memória que já não existe, o clássico ponteiro solto do C. Por isso, um span:

  • não pode ser campo de uma classe, nem de uma struct comum;
  • não pode ser convertido para object nem para uma interface (não há boxing);
  • não pode ser capturado por uma lambda;
  • não pode continuar vivo depois de um await, recurso que veremos daqui a duas aulas.

Todas essas violações são erros de compilação. O compilador garante que um span nunca escape do contexto onde a memória que ele observa é válida. É o C# oferecendo o desempenho de ponteiros com a segurança de referências gerenciadas.

Memory<T>: quando a janela precisa ir para o heap

E quando você realmente precisa guardar uma janela num campo, ou atravessar um await? Para isso existe o Memory<T> (e ReadOnlyMemory<T>): um tipo comum, sem as restrições de ref struct, que representa a mesma ideia de janela, mas só sobre memória do heap, como arrays e strings. Ele pode ser guardado em qualquer lugar e, quando for a hora de processar, entrega um span:

public sealed class LoteDeLinhas(ReadOnlyMemory<char> conteudo)
{
    public int ContarLinhas()
    {
        ReadOnlySpan<char> texto = conteudo.Span;   // converte para span na hora de trabalhar
        return texto.Count('\n');
    }
}

A regra prática: use Span<T> e ReadOnlySpan<T> em parâmetros e variáveis locais de métodos síncronos, que é a grande maioria dos casos. Use Memory<T> quando o dado precisar ser guardado ou atravessar código assíncrono.

stackalloc: memória temporária sem heap

Às vezes você precisa de um buffer temporário pequeno, por exemplo para montar um código ou processar alguns bytes. Criá-lo com new byte[64] aloca no heap. Com stackalloc e um span, o buffer fica na pilha, e desaparece sozinho quando o método termina, sem nada para o GC recolher:

Span<char> buffer = stackalloc char[32];
bool ok = 59.90m.TryFormat(buffer, out int escritos, "F2", CultureInfo.InvariantCulture);
ReadOnlySpan<char> formatado = buffer[..escritos];   // "59.90"

Métodos TryFormat, presentes em todos os tipos numéricos e de data, escrevem direto num span em vez de criar uma string. A pilha, porém, é pequena, como vimos na aula anterior. Um stackalloc grande demais, ou com tamanho decidido por dados externos, pode estourá-la e derrubar o processo. A regra: use stackalloc apenas para tamanhos pequenos e limitados, e caia para o heap acima de um limite:

const int LimitePilha = 256;
Span<char> buffer = tamanho <= LimitePilha
    ? stackalloc char[LimitePilha]
    : new char[tamanho];

ArrayPool<T>: reaproveitar em vez de alocar

Para buffers grandes e temporários (ler um arquivo em blocos de 64 KB, por exemplo), nem a pilha nem um new a cada uso são boas escolhas. O ArrayPool<T> mantém arrays já alocados para serem emprestados e devolvidos:

using System.Buffers;

byte[] emprestado = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
    // usar o buffer... atenção: ele pode ser MAIOR que o pedido
}
finally
{
    ArrayPool<byte>.Shared.Return(emprestado);   // devolver sempre, inclusive em caso de erro
}

Três cuidados. O array devolvido por Rent pode ser maior que o tamanho pedido, então trabalhe com um span do tamanho que você realmente usou. Devolver é obrigatório, por isso o finally. E o array volta ao pool com o conteúdo antigo: se ele guardou dados sensíveis, use Return(emprestado, clearArray: true).

Juntando tudo: lendo vendas sem alocar

A Livraria precisa importar o registro de vendas, com milhões de linhas no formato data;produto;quantidade. Um método no padrão Try, operando só sobre spans, interpreta cada linha criando apenas o objeto Venda final:

using System.Diagnostics.CodeAnalysis;
using System.Globalization;

static bool TentarLerVenda(ReadOnlySpan<char> linha, [NotNullWhen(true)] out Venda? venda)
{
    venda = null;

    Span<Range> partes = stackalloc Range[4];   // um a mais que o esperado, para detectar excesso
    int quantidadeDePartes = linha.Split(partes, ';');
    if (quantidadeDePartes != 3)
        return false;

    ReadOnlySpan<char> textoData = linha[partes[0]].Trim();
    ReadOnlySpan<char> textoProduto = linha[partes[1]].Trim();
    ReadOnlySpan<char> textoQuantidade = linha[partes[2]].Trim();

    if (!DateOnly.TryParseExact(textoData, "yyyy-MM-dd", CultureInfo.InvariantCulture,
                                DateTimeStyles.None, out DateOnly data))
        return false;
    if (!int.TryParse(textoProduto, NumberStyles.Integer, CultureInfo.InvariantCulture, out int produtoId))
        return false;
    if (!int.TryParse(textoQuantidade, NumberStyles.Integer, CultureInfo.InvariantCulture, out int quantidade)
        || quantidade <= 0)
        return false;

    venda = new Venda(produtoId, quantidade, data);
    return true;
}

Leia com atenção o truque do Split. A versão para spans não cria strings: ela preenche um span de Range, os intervalos da aula de coleções, reservado na pilha, com as posições de cada parte, e devolve quantas encontrou. Reservamos quatro posições para uma linha que deveria ter três partes. Se a linha tiver separadores a mais, a quarta posição recebe o restante, a contagem dá 4, e a linha é rejeitada. Com exatamente três posições, uma linha como 2026-09-01;17;3;lixo teria o 3;lixo inteiro na terceira parte, e o erro passaria despercebido até o TryParse falhar por outro motivo. Depois, linha[partes[1]] recorta o span usando o intervalo, e as conversões leem direto desses recortes.

O uso, contando as linhas válidas e inválidas de um conteúdo grande:

string conteudo = """
    2026-09-01;17;3
    2026-09-01;18;5
    data-invalida;19;1
    2026-09-02;17;2;extra
    2026-09-02;20;4
    """;

int validas = 0, invalidas = 0, unidades = 0;
foreach (ReadOnlySpan<char> linha in conteudo.AsSpan().EnumerateLines())
{
    if (linha.IsWhiteSpace()) continue;
    if (TentarLerVenda(linha, out Venda? venda))
    {
        validas++;
        unidades += venda.Quantidade;
    }
    else
    {
        invalidas++;
    }
}

Console.WriteLine($"Válidas: {validas}, inválidas: {invalidas}, unidades: {unidades}");
// Válidas: 3, inválidas: 2, unidades: 12

EnumerateLines() percorre as linhas de um span, entregando cada uma como outro span, sem nenhuma string intermediária. A linha com data inválida e a linha com uma parte extra são rejeitadas. As três válidas somam 3 + 5 + 4 = 12 unidades. Meça com o GC.GetAllocatedBytesForCurrentThread da aula anterior e compare com a versão baseada em Split de strings: para milhões de linhas, a diferença vai de centenas de megabytes a praticamente só os objetos Venda.

Uma nota de equilíbrio: código com spans é mais verboso e exige mais atenção que a versão com Split. Para ler um arquivo de configuração com vinte linhas, a versão simples é a certa. Spans valem o esforço em caminhos que processam volumes grandes ou executam com muita frequência: importações, protocolos de rede, parsers, laços de jogos. E, muitas vezes, você se beneficia deles sem escrever nenhum: a própria biblioteca do .NET os usa por dentro.

Até aqui, o conteúdo das vendas veio de uma string escrita no próprio programa. Na vida real, ele vem de um arquivo gravado por outro sistema, ou gravado pela própria Livraria no dia anterior. E os dados que o programa cria (o catálogo, os pedidos, os registros) desaparecem assim que ele termina. Para que as informações sobrevivam ao programa, é preciso gravá-las em disco, escolhendo formatos, codificações e cuidados para que um erro no meio da gravação não destrua o que já existia.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Escreva um método ContarSeparadores(ReadOnlySpan<char> texto, char separador) que conte as ocorrências de um caractere sem alocar nada, usando um laço. Depois compare com a chamada pronta texto.Count(separador).

Ver resposta

✓ Resposta:

static int ContarSeparadores(ReadOnlySpan<char> texto, char separador)
{
    int total = 0;
    foreach (char c in texto)
        if (c == separador) total++;
    return total;
}

Console.WriteLine(ContarSeparadores("2026-09-01;17;3", ';'));   // 2
Console.WriteLine("2026-09-01;17;3".AsSpan().Count(';'));        // 2

O foreach funciona sobre spans com a mesma sintaxe de arrays, e sem alocar nenhum enumerador no heap. Uma string literal passada a um parâmetro ReadOnlySpan<char> é convertida implicitamente, sem cópia. A versão pronta, MemoryExtensions.Count, dá o mesmo resultado e costuma ser mais rápida, porque usa instruções vetorizadas do processador, que comparam vários caracteres de uma só vez. A lição da aula de LINQ se repete: saiba escrever o laço, mas prefira a operação pronta da biblioteca quando ela existir.

Exercício 2

Preveja o conteúdo do array depois do código abaixo e explique:

int[] a = [1, 2, 3, 4, 5];
Span<int> s = a.AsSpan(1, 3);
s[0] = 20;
s[^1] = 40;
Span<int> t = s[1..2];
t[0] = 30;
Console.WriteLine(string.Join(",", a));
Ver resposta

✓ Resposta: Imprime 1,20,30,40,5. s é uma janela sobre as posições 1, 2 e 3 do array (os valores 2, 3 e 4). s[0] = 20 altera a posição 1 do array. s[^1] é o último elemento da janela, que corresponde à posição 3 do array, e recebe 40. t = s[1..2] é uma janela de um elemento sobre a segunda posição de s, que é a posição 2 do array, e t[0] = 30 a altera. Nenhuma das três janelas copiou nada: todas escrevem na mesma memória do array. É a diferença fundamental em relação ao recorte de arrays com .., que criava arrays novos e independentes.

Exercício 3

Explique por que cada trecho abaixo não compila, e o que poderia dar errado se fosse permitido.

class Cache { private Span<byte> _dados; }

Span<int> numeros = stackalloc int[4];
Func<int> primeiro = () => numeros[0];
Ver resposta

✓ Resposta: No primeiro, uma classe vive no heap e pode sobreviver a qualquer método. Um span guardado nela poderia apontar para memória da pilha de um método que já retornou. O próximo acesso leria ou escreveria numa região reaproveitada por outra chamada, corrompendo dados em silêncio: exatamente o ponteiro solto do C. Por isso Span<T>, sendo um ref struct, não pode ser campo de classe. A alternativa é Memory<T>, que só aceita memória do heap. No segundo, uma lambda que captura uma variável a transforma num campo de um objeto gerado pelo compilador (a closure de "Delegates, Lambdas e Eventos"), que vive no heap e pode ser chamado muito depois de o método terminar. numeros aponta para um bloco da pilha que deixaria de existir, e o compilador recusa a captura pelo mesmo motivo. As duas restrições têm a mesma raiz: garantir, na compilação, que nenhum span sobreviva à memória que ele observa.

Exercício 4

Escreva um método Inverter(string texto) que devolva a string invertida usando um buffer de char: stackalloc para textos de até 256 caracteres e ArrayPool<char> acima disso.

Ver resposta

✓ Resposta:

using System.Buffers;

static string Inverter(string texto)
{
    ArgumentNullException.ThrowIfNull(texto);
    const int LimitePilha = 256;

    char[]? alugado = null;
    Span<char> buffer = texto.Length <= LimitePilha
        ? stackalloc char[LimitePilha]
        : (alugado = ArrayPool<char>.Shared.Rent(texto.Length));
    try
    {
        Span<char> destino = buffer[..texto.Length];
        texto.AsSpan().CopyTo(destino);
        destino.Reverse();
        return new string(destino);
    }
    finally
    {
        if (alugado is not null)
            ArrayPool<char>.Shared.Return(alugado);
    }
}

Os detalhes importam. O stackalloc tem tamanho constante (256), e não o tamanho do texto, para que nenhuma entrada consiga estourar a pilha. O array alugado pode ser maior que o pedido, por isso recortamos buffer[..texto.Length] antes de usar. Reverse inverte o span no lugar, sem cópia. A única alocação inevitável é a string final, new string(destino). O finally devolve o array ao pool mesmo se algo falhar. Um aviso honesto: inverter char a char quebra caracteres representados por dois char (alguns emojis e símbolos raros) e letras com acento combinado. Para texto de verdade, a inversão correta opera sobre "elementos de texto", com StringInfo.

Exercício 5

Interprete a linha "Machado de Assis;59.90" extraindo o autor e o preço de duas formas: com Split e com spans. Meça as alocações de cada forma repetindo a operação 100 mil vezes.

Ver resposta

✓ Resposta:

using System.Globalization;

const string Linha = "Machado de Assis;59.90";
const int N = 100_000;

long antes = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < N; i++)
{
    string[] partes = Linha.Split(';');
    decimal preco = decimal.Parse(partes[1], CultureInfo.InvariantCulture);
}
long comSplit = GC.GetAllocatedBytesForCurrentThread() - antes;

antes = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < N; i++)
{
    ReadOnlySpan<char> texto = Linha;
    int pos = texto.IndexOf(';');
    ReadOnlySpan<char> autor = texto[..pos];
    decimal preco = decimal.Parse(texto[(pos + 1)..], CultureInfo.InvariantCulture);
}
long comSpan = GC.GetAllocatedBytesForCurrentThread() - antes;

Console.WriteLine($"Split: {comSplit / 1024} KB | Span: {comSpan / 1024} KB");

A versão com Split aloca, a cada iteração, um array e duas strings. Em 100 mil repetições, isso soma vários megabytes: no .NET 10, medido, 12.500 KB contra 0 KB da versão com spans. A versão com spans aloca zero bytes no laço: o autor é só uma janela sobre a linha original, e o preço é convertido direto do recorte. Se o autor precisasse ser guardado (num objeto Produto, por exemplo), aí sim seria necessário um autor.ToString(), e essa seria a única alocação. Esse é o padrão geral: com spans, você adia a criação de strings até o momento em que um valor precisa realmente existir por conta própria, e muitos valores intermediários nunca chegam a existir.

Comentários

Mais em Linguagem C#

Interfaces: Contratos Que Qualquer Tipo Pode Assinar
Interfaces: Contratos Que Qualquer Tipo Pode Assinar

Interfaces em C#: contratos que tipos sem parentesco assinam, várias…

C# nos jogos: Unity, Godot, MonoGame e o game loop
C# nos jogos: Unity, Godot, MonoGame e o game loop

Como o C# chegou aos jogos pela Unity, pelo XNA e MonoGame e pelo Godot, a…

Dapper: SQL Seu, Sem a Cerimônia
Dapper: SQL Seu, Sem a Cerimônia

Como o Dapper elimina a cerimônia do ADO.NET sem esconder o SQL: QueryAsync…