PipeReader no .NET 10: como desserializar JSON com menos GC

O PipeReader no .NET 10 permite que o System.Text.Json desserialize JSON direto de um pipeline, sem converter os dados para Stream antes. Na prática, isso reduz cópias intermediárias e alivia o garbage collector em APIs que recebem payloads grandes.
O que é o PipeReader no .NET 10
Imagine uma API ASP.NET Core recebendo um JSON com um milhão de objetos. Nesse cenário, o serializador precisa montar um grafo de objetos enorme e, ao mesmo tempo, gerenciar buffers temporários de leitura.
Esse trabalho pressiona a memória e obriga o garbage collector a entrar em ação com frequência. Por isso, qualquer redução de alocações intermediárias faz diferença em endpoints que lidam com volumes altos.
O PipeReader é a ponta de leitura de um pipeline da biblioteca System.IO.Pipelines. Em vez de você alocar um array de bytes e controlar índices manualmente, ele entrega blocos de memória que vêm de um pool e voltam para ele quando você termina.
A biblioteca nasceu para simplificar código de I/O de alta performance, como servidores TCP e parsers de protocolo. Além disso, o Kestrel, servidor web do ASP.NET Core, trabalha com pipelines internamente há várias versões.
A novidade do PipeReader no .NET 10 está na integração com o JsonSerializer. Agora, os métodos DeserializeAsync e DeserializeAsyncEnumerable aceitam um PipeReader diretamente, complementando o suporte a PipeWriter que já existia na serialização.
Antes dessa versão, quem tinha um PipeReader em mãos precisava chamar AsStream() para obter um Stream. Só então os dados chegavam ao serializador, passando por uma camada extra de adaptação.
// Antes do .NET 10: era preciso adaptar o PipeReader para Stream
await using var stream = pipeReader.AsStream();
var person = await JsonSerializer.DeserializeAsync<Person>(stream);
// A partir do .NET 10: o PipeReader vai direto para o serializador
var person = await JsonSerializer.DeserializeAsync<Person>(pipeReader);
await pipeReader.CompleteAsync();
Segundo a documentação oficial das novidades das bibliotecas do .NET 10, eliminar essa conversão pode trazer ganhos de eficiência. Afinal, você passa a trabalhar com o tipo que já tem em mãos, sem adaptadores no caminho.
Stream e PipeReader: qual a diferença
Um Stream trabalha com um buffer fornecido pelo chamador. Ou seja, você entrega um array, o Stream preenche esse array e você decide o que fazer com os bytes lidos.
O PipeReader, por outro lado, assume a responsabilidade pelo gerenciamento de memória. Ele devolve um ReadOnlySequence<byte> com os dados disponíveis e você informa, com AdvanceTo, quanto consumiu e quanto apenas examinou.
Essa diferença parece sutil, mas muda bastante coisa em cargas grandes. Como o pipeline controla os buffers e os devolve ao pool, o código consumidor não precisa redimensionar arrays nem copiar blocos de um lugar para outro.
Além disso, o Utf8JsonReader, parser de baixo nível do System.Text.Json, já sabe ler sequências fragmentadas. Por isso, a integração com o PipeReader no .NET 10 aproveita uma capacidade que o leitor de JSON já tinha.
O que significa zero-copy na prática
Zero-copy é a técnica de processar dados sem criar cópias redundantes deles na memória. Quanto menos cópias, menor o uso de CPU, menor o consumo de RAM e menor a quantidade de objetos temporários para o GC limpar.
Na prática, nenhuma desserialização fica totalmente livre de alocações. Afinal, o serializador ainda precisa criar cada objeto, cada string e cada lista do resultado final.
O ganho está nas alocações intermediárias, aquelas que existem só para transportar bytes de um ponto a outro. Quando o serializador lê direto do pipeline, ele evita parte desse trabalho e reduz a pressão sobre o garbage collector.
Esse ponto conversa com outro tema clássico de performance em C#. Se você quer entender por que objetos temporários custam caro, vale ler o artigo sobre boxing e unboxing no C#, que mostra o impacto dessas alocações na memória.
Também convém ajustar as expectativas desde já. O termo zero-copy aparece com frequência em artigos sobre o PipeReader no .NET 10, mas o benefício real depende da origem dos dados e do tamanho do payload.
Como o ASP.NET Core usa o PipeReader no .NET 10
Uma boa notícia é que boa parte das aplicações já se beneficia da mudança sem alterar nenhuma linha. Segundo a documentação do ASP.NET Core, o framework passou a usar as sobrecargas baseadas em PipeReader em três áreas:
- Minimal APIs, no binding de parâmetros e na leitura do corpo da requisição.
- MVC, nos input formatters e no model binding.
- Os métodos de extensão de
HttpRequestJsonExtensions, comoReadFromJsonAsync.
Isso significa que um endpoint comum, como o do exemplo abaixo, já recebe o corpo da requisição através do PipeReader no .NET 10. Você não escreve nada diferente, e mesmo assim o binding usa o caminho novo.
app.MapPost("/telemetry", (TelemetryRequest request) =>
{
// O binding já usa o PipeReader internamente no .NET 10
return Results.Ok(new { request.DeviceId, Count = request.Readings.Count });
});
Quando você precisa de controle manual, pode ler o corpo diretamente de HttpContext.Request.BodyReader. Essa propriedade expõe o PipeReader da requisição, sem nenhum Stream no meio.
app.MapPost("/telemetry/raw", async (HttpContext context) =>
{
// BodyReader é o PipeReader do corpo da requisição
var request = await JsonSerializer.DeserializeAsync<TelemetryRequest>(
context.Request.BodyReader,
cancellationToken: context.RequestAborted);
return request is null
? Results.BadRequest()
: Results.Ok(new { request.DeviceId, Count = request.Readings.Count });
});
Repare que, nesse caso, você não precisa chamar CompleteAsync. O ciclo de vida do BodyReader pertence ao servidor, que cuida de liberar os recursos ao final da requisição.
Se você está começando com esse estilo de endpoint, a visão geral das Minimal APIs no Microsoft Learn explica o binding de parâmetros com detalhes. Lá também aparece a seção específica sobre a desserialização com PipeReader no .NET 10.
Preparando o projeto de benchmark
Para enxergar a diferença com números, vamos reproduzir o experimento publicado no blog da elmah.io. A ideia é simples: gerar um arquivo JSON grande e desserializá-lo de duas formas, comparando tempo, memória alocada e coletas do GC.
Primeiro, crie um projeto Web API com o template padrão e o framework net10.0:
dotnet new webapi -n ZeroCopyJsonDemo -f net10.0
cd ZeroCopyJsonDemo
O projeto usa Minimal APIs, então todo o código de teste fica concentrado no Program.cs. Dessa forma, você consegue focar apenas na comparação entre as duas abordagens.
Criando os modelos
O cenário simula um dispositivo IoT enviando leituras de telemetria. Cada leitura tem data, temperatura e umidade.
namespace ZeroCopyJsonDemo.Models;
public class TelemetryReading
{
public DateTime Timestamp { get; set; }
public double Temperature { get; set; }
public double Humidity { get; set; }
}
public class TelemetryRequest
{
public string DeviceId { get; set; } = string.Empty;
public List<TelemetryReading> Readings { get; set; } = [];
}
Repare que TelemetryRequest guarda uma lista de leituras. Com um milhão de itens, essa lista sozinha já representa um grafo de objetos considerável para o serializador montar.
Gerando um JSON grande
Em seguida, adicione um endpoint que cria o arquivo de teste. Ele gera um milhão de leituras, serializa tudo em UTF-8 e grava o resultado em disco.
app.MapGet("/create-json", () =>
{
var payload = new TelemetryRequest
{
DeviceId = "device-001",
Readings = Enumerable.Range(1, 1_000_000)
.Select(i => new TelemetryReading
{
Timestamp = DateTime.UtcNow.AddSeconds(i),
Temperature = 20 + i % 10,
Humidity = 40 + i % 20
})
.ToList()
};
// Serializa direto para bytes UTF-8, sem passar por string
var json = JsonSerializer.SerializeToUtf8Bytes(payload);
File.WriteAllBytes("large.json", json);
return Results.Ok(new { SizeMb = Math.Round(json.Length / 1024d / 1024d, 2) });
});
No experimento original, o arquivo gerado ficou com cerca de 72 MB. Não é um volume extremo para produção, mas já basta para evidenciar diferenças no comportamento do GC.
Além disso, note o uso de SerializeToUtf8Bytes em vez de serializar para string. Essa escolha evita a conversão para UTF-16 e combina com a filosofia de trabalhar diretamente com bytes.
Medindo a desserialização com Stream
Agora vamos escrever um método auxiliar de medição. O artigo original repete o mesmo bloco duas vezes, então aqui ele ganha uma versão reutilizável que recebe a operação a medir.
static async Task<BenchmarkResult> MeasureAsync(Func<Task> action)
{
// Força uma coleta completa para que as medições partam do mesmo estado
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var allocatedBefore = GC.GetTotalAllocatedBytes(precise: true);
var gen0Before = GC.CollectionCount(0);
var gen1Before = GC.CollectionCount(1);
var gen2Before = GC.CollectionCount(2);
var stopwatch = Stopwatch.StartNew();
await action();
stopwatch.Stop();
var allocatedAfter = GC.GetTotalAllocatedBytes(precise: true);
return new BenchmarkResult(
ExecutionTimeMs: stopwatch.Elapsed.TotalMilliseconds,
AllocatedMemoryMb: Math.Round((allocatedAfter - allocatedBefore) / 1024d / 1024d, 2),
Gen0Collections: GC.CollectionCount(0) - gen0Before,
Gen1Collections: GC.CollectionCount(1) - gen1Before,
Gen2Collections: GC.CollectionCount(2) - gen2Before);
}
O método força uma coleta antes de começar, registra os contadores do GC e mede o tempo com um Stopwatch. No fim, ele calcula a diferença de cada contador e converte os bytes alocados para megabytes.
O resultado vai para um record simples. Como o Program.cs usa instruções de nível superior, declare esse tipo no fim do arquivo.
public record BenchmarkResult(
double ExecutionTimeMs,
double AllocatedMemoryMb,
int Gen0Collections,
int Gen1Collections,
int Gen2Collections);
Com o auxiliar pronto, a abordagem tradicional fica bem curta. Ela abre o arquivo como Stream e entrega esse Stream ao serializador.
static async Task DeserializeWithStreamAsync()
{
await using var stream = File.OpenRead("large.json");
_ = await JsonSerializer.DeserializeAsync<TelemetryRequest>(stream);
}
Esse é o código que a maioria das aplicações escreveu durante anos. Ele funciona bem, mas o serializador precisa gerenciar os próprios buffers enquanto lê o arquivo.
Medindo a desserialização com PipeReader no .NET 10
A versão com pipeline muda apenas a forma de entregar os dados. Em vez de passar o Stream diretamente, você cria um PipeReader em volta dele com PipeReader.Create.
static async Task DeserializeWithPipeReaderAsync()
{
await using var stream = File.OpenRead("large.json");
var reader = PipeReader.Create(stream);
try
{
_ = await JsonSerializer.DeserializeAsync<TelemetryRequest>(reader);
}
finally
{
// Sempre complete o PipeReader para devolver os buffers ao pool
await reader.CompleteAsync();
}
}
O ponto mais importante desse trecho é a chamada a CompleteAsync. Segundo a documentação do System.IO.Pipelines, esquecer de completar o leitor pode causar vazamento de memória, porque os buffers não voltam ao pool.
Também vale conhecer as opções de StreamPipeReaderOptions. Por padrão, o leitor aluga buffers de 4096 bytes, pede um novo bloco quando restam menos de 1024 bytes e fecha o Stream ao ser completado.
Caso você precise manter o Stream aberto depois da leitura, informe leaveOpen: true na criação. Assim, o PipeReader no .NET 10 libera os buffers, mas deixa o arquivo sob o seu controle.
Desserializando listas com PipeReader no .NET 10
Quando o JSON é um array na raiz, existe uma opção ainda mais interessante. O método DeserializeAsyncEnumerable também ganhou sobrecargas que recebem um PipeReader.
await using var stream = File.OpenRead("readings.json");
var reader = PipeReader.Create(stream);
try
{
await foreach (var reading in JsonSerializer.DeserializeAsyncEnumerable<TelemetryReading>(reader))
{
// Cada item chega aqui assim que é lido, sem montar a lista inteira
if (reading is not null)
ProcessReading(reading);
}
}
finally
{
await reader.CompleteAsync();
}
Nesse modelo, a aplicação nunca mantém o milhão de leituras em memória ao mesmo tempo. Em outras palavras, você combina o pipeline com o streaming de itens e reduz drasticamente o pico de memória.
Existe ainda uma sobrecarga com o parâmetro topLevelValues. Quando ele vale true, o serializador trata o conteúdo como uma sequência de valores JSON separados por espaço, formato comum em logs e em JSON Lines.
Resultados do benchmark com PipeReader no .NET 10
Agora falta o endpoint que dispara as duas medições e devolve os números do Stream e do PipeReader no .NET 10 lado a lado.
app.MapGet("/benchmark", async () =>
{
var streamResult = await MeasureAsync(DeserializeWithStreamAsync);
var pipeReaderResult = await MeasureAsync(DeserializeWithPipeReaderAsync);
return Results.Ok(new { Stream = streamResult, PipeReader = pipeReaderResult });
});
Execute a aplicação em modo Release, porque o modo Debug desativa otimizações do JIT e distorce os números.
dotnet run -c Release
Primeiro, chame /create-json uma única vez para gerar o arquivo. Depois, chame /benchmark de cinco a dez vezes e descarte a primeira execução, que inclui o custo de aquecimento do JIT.
No experimento da elmah.io, a versão com pipeline disparou menos coletas do GC durante a desserialização. Como consequência, ela terminou cerca de 21% mais rápido que a versão com Stream.
Curiosamente, a memória total alocada não caiu na mesma proporção. O autor observou que a abordagem com PipeReader no .NET 10 aloca um pouco mais de memória bruta, mas evita a rotatividade no Large Object Heap e as pausas do GC.
Esse detalhe importa porque objetos com 85.000 bytes ou mais vão para o Large Object Heap. Como essa área só passa por limpeza nas coletas de geração 2, buffers grandes e temporários costumam custar caro em APIs com tráfego intenso.
Ainda assim, trate esses números como um indicativo e não como verdade absoluta. Um endpoint de benchmark manual sofre influência do hardware, do sistema operacional e de outras cargas na máquina.
Para medições sérias, prefira o BenchmarkDotNet, que cuida de aquecimento, iterações e estatísticas. Além disso, meça com payloads parecidos com os da sua aplicação real, porque um JSON de 2 KB se comporta de forma muito diferente de um arquivo de 72 MB.
Cuidados com conversores personalizados
Para a maioria das aplicações, a troca de Stream por PipeReader acontece sem efeitos colaterais. No entanto, existe um cuidado importante para quem escreveu um JsonConverter próprio.
Com um pipeline, o valor de um token pode chegar dividido em mais de um segmento de memória. Nesse caso, a propriedade Utf8JsonReader.HasValueSequence vale true e os bytes ficam em ValueSequence, não em ValueSpan.
Um conversor que lê apenas ValueSpan pode perder dados ou lançar ArgumentOutOfRangeException. Por isso, a correção mais simples consiste em verificar HasValueSequence antes de acessar os bytes.
public sealed class DecimalAsStringConverter : JsonConverter<decimal>
{
public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Com PipeReader, o valor pode chegar dividido em mais de um segmento
ReadOnlySpan<byte> span = reader.HasValueSequence
? reader.ValueSequence.ToArray()
: reader.ValueSpan;
if (Utf8Parser.TryParse(span, out decimal value, out _))
return value;
throw new JsonException("Invalid decimal value.");
}
public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
Essa abordagem aloca um array apenas quando o valor chega fragmentado. Para cenários mais exigentes, você pode criar um caminho separado que processa o ReadOnlySequence<byte> sem cópia.
Se não houver tempo para corrigir os conversores agora, existe uma saída temporária. Você pode desligar o uso do PipeReader no ASP.NET Core com um switch de AppContext.
// Solução temporária: volta para a leitura baseada em Stream
AppContext.SetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", true);
A própria Microsoft recomenda usar esse switch apenas como paliativo. Afinal, ele pode desaparecer no .NET 11, então o caminho certo continua sendo ajustar os conversores para suportar HasValueSequence.
Quando usar o PipeReader no .NET 10
O cenário ideal reúne payloads grandes e endpoints com muitas requisições por segundo. Nesses casos, cada coleta de GC evitada se traduz em latência menor e em respostas mais estáveis.
Também faz sentido adotar o PipeReader no .NET 10 quando a origem dos dados já é um pipeline. Sockets, protocolos customizados, o BodyReader do ASP.NET Core e bibliotecas de transporte costumam expor PipeReader nativamente.
Por outro lado, alguns cenários não ganham quase nada com a mudança. Se o JSON é pequeno ou se você já tem o conteúdo inteiro em um byte[], a sobrecarga de Deserialize que recebe um ReadOnlySpan<byte> continua sendo a opção mais direta.
Lembre também que, em Minimal APIs e MVC, o framework já faz a troca por você. Portanto, escrever código manual com BodyReader só compensa quando você precisa de algo que o binding padrão não oferece, como streaming de itens.
Em resumo, use estas perguntas como guia antes de mexer no código:
- O payload costuma ter vários megabytes?
- O endpoint recebe tráfego alto e sensível a latência?
- A origem dos dados já expõe um
PipeReader? - O JSON é um array grande que pode virar streaming de itens?
Quanto mais respostas positivas, maior a chance de o PipeReader trazer um ganho mensurável. Mesmo assim, meça antes e depois, porque performance sem medição vira apenas suposição.
Conclusão
O PipeReader no .NET 10 fecha uma lacuna antiga entre o System.IO.Pipelines e o System.Text.Json. Agora o serializador consome bytes direto do pipeline, sem adaptadores e com menos alocações intermediárias.
No experimento analisado, essa mudança reduziu a pressão sobre o GC e deixou a desserialização de um JSON de 72 MB cerca de 21% mais rápida. Além disso, o ASP.NET Core já aplica a melhoria em Minimal APIs e MVC sem exigir nenhuma alteração.
Na prática, o seu trabalho se resume a três pontos: revisar conversores personalizados, completar sempre os leitores que você mesmo cria e medir com cargas realistas. Com isso, você aproveita o melhor do PipeReader no .NET 10 sem surpresas em produção.
O código completo do experimento original está disponível no repositório da elmah.io, listado nas fontes ao final do artigo.
FAQ
Preciso mudar meu código para usar o PipeReader no ASP.NET Core?
Na maioria dos casos, não. Minimal APIs, MVC e ReadFromJsonAsync já usam as sobrecargas com PipeReader a partir do .NET 10.
O PipeReader sempre deixa a desserialização mais rápida?
Não necessariamente. O ganho aparece com payloads grandes e tráfego alto, enquanto JSONs pequenos tendem a mostrar diferenças mínimas.
Por que preciso chamar CompleteAsync no PipeReader?
Porque essa chamada devolve os buffers ao pool e libera o recurso de origem. Sem ela, a aplicação pode vazar memória.
Meu conversor personalizado começou a falhar no .NET 10, o que fazer?
Verifique HasValueSequence e leia de ValueSequence quando o valor vier fragmentado. Como paliativo, use o switch Microsoft.AspNetCore.UseStreamBasedJsonParsing.
O PipeReader funciona com source generators do System.Text.Json?
Sim. Existem sobrecargas que recebem JsonTypeInfo<T>, o que mantém a compatibilidade com Native AOT.