ASP.NET Async Streaming

O IAsyncEnumerable permite que uma API ASP.NET 6 envie dados por partes, item a item, em vez de esperar a lista inteira ficar pronta. Neste artigo, vamos entender como ele funciona, o que mudou no ASP.NET 6 e como fazer streaming de dados sem nenhuma configuração adicional.

A ideia central é simples: o cliente começa a receber informações assim que elas existem. Dessa forma, a aplicação consome menos memória e o usuário vê o resultado aparecer de forma progressiva.

O que é IAsyncEnumerable

O IAsyncEnumerable<T> é uma interface do .NET que representa uma sequência de valores produzidos de forma assíncrona. Ela chegou junto com o C# 8 e o .NET Core 3.0, dentro do recurso que a Microsoft chama de async streams.

Em outras palavras, o IAsyncEnumerable é a versão assíncrona do conhecido IEnumerable<T>. Enquanto o segundo entrega itens que já estão disponíveis, o primeiro permite aguardar cada item antes de entregá-lo.

Na prática, isso significa que podemos percorrer uma coleção com await foreach. Assim, cada iteração pode esperar uma consulta ao banco, uma chamada HTTP ou qualquer outra operação de entrada e saída.

Como o yield funciona

Você provavelmente já viu a palavra yield em algum código C#. A tradução mais próxima é render ou produzir, e ela descreve bem o comportamento da instrução.

Com o yield return, um método devolve um valor para quem o chamou sem necessariamente terminar a sua execução. Na próxima iteração, o método continua exatamente do ponto onde parou.

yield return 1000;

Esse mecanismo cria o que chamamos de iterador. Por isso, o compilador gera por baixo dos panos uma máquina de estados que lembra a posição atual da execução.

Diferença entre IEnumerable e IAsyncEnumerable

O IEnumerable<T> com yield já permite produzir valores sob demanda. No entanto, ele não permite usar await dentro do método, então qualquer espera bloqueia a thread atual.

Já o IAsyncEnumerable combina o yield return com o await. Com isso, o método pode aguardar uma operação assíncrona, produzir um valor e liberar a thread enquanto o próximo item não fica pronto.

Essa diferença parece pequena, mas muda bastante o comportamento de uma API. Afinal, threads livres significam mais requisições atendidas ao mesmo tempo pelo servidor.

Como o ASP.NET tratava o IAsyncEnumerable antes da versão 6

Desde o ASP.NET Core 3, podemos utilizar o IAsyncEnumerable como retorno das Actions em nossos Controllers. Porém, a efetividade desse recurso ainda não era completa.

Na prática, o framework aguardava a execução do método da mesma forma como aguardaria por um IList. Isso acontecia porque o conceito de async streaming ainda não estava presente no ASP.NET.

Segundo a documentação de breaking changes do ASP.NET Core 6, o MVC do ASP.NET Core 5 formatava o IAsyncEnumerable colocando toda a sequência em um buffer na memória. Somente depois disso o conteúdo seguia para o cliente.

Ou seja, a enumeração até acontecia de forma assíncrona no servidor. Contudo, o cliente só recebia a resposta quando o último item ficava pronto.

O que mudou no ASP.NET 6

Na versão 6 do ASP.NET, o conceito de async streaming chegou de fato ao framework. Agora podemos "stremar", ou seja, enviar por partes, os dados para as interfaces que consomem a API.

O melhor de tudo é que essa mudança ocorreu por baixo dos panos. Nenhuma alteração de código é necessária para aproveitar o novo comportamento.

A página oficial de novidades do ASP.NET Core 6 resume a mudança assim: retornar um IAsyncEnumerable de uma action não coloca mais o conteúdo da resposta em buffer antes do envio. Como resultado, o uso de memória cai bastante quando retornamos grandes volumes de dados.

O papel do System.Text.Json no streaming com IAsyncEnumerable

O responsável real por essa melhoria é o System.Text.Json. No .NET 6, os métodos assíncronos de serialização passaram a enumerar instâncias de IAsyncEnumerable e a escrevê-las como arrays JSON.

Antes disso, o serializador transformava um IAsyncEnumerable em um objeto vazio {}. Portanto, o MVC precisava fazer o buffer por conta própria para gerar um JSON correto.

Com o suporte nativo no serializador, o MVC deixou de bufferizar e passou a confiar no System.Text.Json. Vale lembrar que os métodos síncronos de serialização não suportam esse tipo e lançam NotSupportedException.

Criando um método com IAsyncEnumerable

Sabendo que o yield retorna uma informação sem finalizar a execução do método, podemos usar o IAsyncEnumerable para criar uma lista assíncrona. Isso significa que não vamos aguardar toda a lista ser carregada.

Em vez disso, vamos receber os valores assim que eles forem retornados com yield return. Veja o exemplo abaixo.

private static async IAsyncEnumerable<int> GetData()
{
    for (var i = 1; i <= 1000; i++)
    {
        // Simula uma operação demorada de um segundo
        await Task.Delay(1000);
        yield return i;
    }
}

Para este exemplo, utilizamos um IAsyncEnumerable<int>, seguido por um for que vai iterar por 1000 repetições. Assim, o método produz números de 1 até 1000.

Como estamos utilizando IAsyncEnumerable, precisamos do yield return para devolver cada valor. Além disso, adicionamos um Task.Delay(1000) para esperar um segundo entre um retorno e outro.

Por fim, não podemos esquecer de marcar o método como async. Sem essa palavra-chave, o compilador não aceita o uso de await dentro de um iterador assíncrono.

Streaming de dados com IAsyncEnumerable em um controller

Tendo um método que produz dados de forma assíncrona, já podemos fazer a mágica acontecer. Na verdade, o mesmo código produz efeitos diferentes quando roda no ASP.NET 5 e no ASP.NET 6.

No ASP.NET 5, o código aguardaria todos os dados da lista, ou seja, 1000 segundos, para depois enviar tudo para a tela. Enquanto isso, no ASP.NET 6, o envio acontece item a item.

[HttpGet("/")]
public IAsyncEnumerable<int> Get()
{
    IAsyncEnumerable<int> value = GetData();
    return value;
}

Note que temos apenas um retorno do tipo IAsyncEnumerable<int>. Em seguida, recebemos os dados do método GetData() que criamos anteriormente e fazemos o envio para a tela.

Não há necessidade de configurar nenhuma informação adicional aqui. Basta retornar um IAsyncEnumerable para que o ASP.NET 6 cuide do streaming.

O resultado no navegador

Ao abrir o endpoint no navegador, os números começam a aparecer um por um, com um segundo de intervalo. Dessa forma, o usuário não fica olhando para uma tela vazia enquanto o servidor processa tudo.

Navegador exibindo um array JSON que recebe números um a um, demonstrando o streaming com IAsyncEnumerable no ASP.NET 6
Os itens chegam ao navegador conforme o método produz cada valor.

Repare que o JSON chega como um array aberto que cresce aos poucos. O fechamento do colchete só acontece quando o último item sai do servidor.

Testando o IAsyncEnumerable pela linha de comando

Além do navegador, o curl também ajuda a observar o streaming. Por padrão, ele pode guardar parte da saída antes de exibir, então usamos a opção -N para desligar esse buffer local.

# -N desativa o buffer de saída do curl
curl -N https://localhost:5001/

Com esse comando, o terminal mostra o array JSON crescendo a cada segundo. Assim, fica fácil confirmar que a API não espera o fim do laço para responder.

Se você rodar o mesmo projeto no ASP.NET 5, o terminal ficará parado até a enumeração terminar. Essa comparação lado a lado deixa clara a diferença entre as duas versões.

Por que o streaming economiza memória

Quando o framework faz buffer, todos os itens precisam caber na memória do servidor ao mesmo tempo. Em uma lista pequena isso passa despercebido, mas em milhares de registros o custo cresce rapidamente.

Com o streaming, o serializador escreve cada item na resposta e logo depois pode descartá-lo. Portanto, a memória ocupada depende muito mais do tamanho de cada item do que do tamanho total da coleção.

Esse comportamento também reduz a pressão sobre o garbage collector. Afinal, objetos que vivem pouco tempo são coletados de forma mais barata do que grandes listas mantidas até o fim da requisição.

Outro ganho aparece na percepção do usuário. Como o primeiro item chega cedo, a interface pode começar a renderizar o conteúdo antes mesmo de o servidor terminar o trabalho.

Vale reforçar que o tempo total da requisição continua o mesmo. Ou seja, o IAsyncEnumerable não acelera a origem dos dados, mas distribui a entrega ao longo do processamento.

Consumindo o streaming em um cliente .NET

Do lado do cliente, também podemos aproveitar o streaming. O .NET 6 trouxe o método JsonSerializer.DeserializeAsyncEnumerable, que lê um array JSON item a item a partir de um Stream.

Para isso funcionar, o HttpClient precisa devolver a resposta assim que os cabeçalhos chegarem. Por isso, usamos a opção HttpCompletionOption.ResponseHeadersRead.

using System.Net.Http;
using System.Text.Json;

var client = new HttpClient();

using var response = await client.GetAsync(
    "https://localhost:5001/",
    HttpCompletionOption.ResponseHeadersRead);

await using var stream = await response.Content.ReadAsStreamAsync();

// Cada número é processado assim que chega do servidor
await foreach (var number in JsonSerializer.DeserializeAsyncEnumerable<int>(stream))
{
    Console.WriteLine(number);
}

Com isso, o console exibe cada número conforme a API envia. Ou seja, o streaming acontece de ponta a ponta, do servidor até o consumidor.

IAsyncEnumerable com Entity Framework Core

O Entity Framework Core já fornece implementações de IAsyncEnumerable para consultas ao banco de dados. Portanto, a melhoria do ASP.NET 6 também beneficia APIs que retornam dados diretamente de um DbContext.

A documentação oficial mostra um exemplo de action que retorna um DbSet sem converter para lista. No ASP.NET 6, essa action não coloca mais os produtos em memória antes de enviar a resposta.

[HttpGet("movies")]
public IActionResult GetMovies()
{
    // O DbSet implementa IAsyncEnumerable e é enviado em streaming
    return Ok(_context.Movies);
}

Também podemos filtrar os dados durante a enumeração. Nesse caso, usamos AsAsyncEnumerable e await foreach dentro de um iterador assíncrono.

[HttpGet("products/on-sale")]
public async IAsyncEnumerable<Product> GetOnSaleProductsAsync()
{
    var products = _context.Products
        .OrderBy(p => p.Name)
        .AsAsyncEnumerable();

    await foreach (var product in products)
    {
        if (product.IsOnSale)
            yield return product;
    }
}

Esse padrão evita carregar toda a tabela para a memória do servidor. Assim, cada produto segue para o cliente logo depois de sair do banco.

Cancelamento de requisições com IAsyncEnumerable

Um streaming longo pode ficar sem destino se o usuário fechar a aba do navegador. Por isso, vale a pena propagar um CancellationToken até o método que produz os dados.

O ASP.NET preenche automaticamente um parâmetro CancellationToken na action com o token da requisição. Em seguida, basta repassar esse token para o iterador.

[HttpGet("/numbers")]
public IAsyncEnumerable<int> GetNumbers(CancellationToken cancellationToken)
{
    return GetData(cancellationToken);
}

private static async IAsyncEnumerable<int> GetData(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    for (var i = 1; i <= 1000; i++)
    {
        // Interrompe a espera se o cliente desconectar
        await Task.Delay(1000, cancellationToken);
        yield return i;
    }
}

O atributo [EnumeratorCancellation] fica no namespace System.Runtime.CompilerServices. Ele liga o token do parâmetro ao token usado pelo await foreach que consome a sequência.

Dessa forma, o servidor para de trabalhar assim que a conexão cai. Isso economiza CPU, conexões com o banco e outros recursos valiosos.

Quando o IAsyncEnumerable não faz streaming

Apesar da simplicidade, existem cenários em que o comportamento muda. Primeiro, a melhoria vale apenas para o formatador baseado em System.Text.Json, que é o padrão do ASP.NET 6.

Se a aplicação usa Newtonsoft.Json ou formatadores XML, o resultado continua em buffer. Nesse caso, o cliente só recebe os dados no fim da enumeração.

Outro ponto de atenção envolve o lazy loading do Entity Framework Core. Enquanto a sequência ainda está aberta, o carregamento tardio de propriedades pode disparar consultas concorrentes no mesmo contexto.

Como muitos provedores não suportam consultas simultâneas no mesmo DbContext, a aplicação pode falhar. A recomendação oficial, então, é materializar os dados com ToListAsync quando o buffer for necessário.

[HttpGet("movies/buffered")]
public async Task<IActionResult> GetMoviesBuffered()
{
    // Força o carregamento completo antes de serializar
    return Ok(await _context.Movies.ToListAsync());
}

Por fim, lembre que streaming não substitui paginação. As boas práticas do ASP.NET Core ainda recomendam devolver grandes coleções em páginas menores sempre que possível.

Boas práticas ao usar IAsyncEnumerable em APIs

Antes de adotar o IAsyncEnumerable em todos os endpoints, vale considerar alguns cuidados. A lista abaixo resume os principais pontos vistos até aqui.

  • Prefira IAsyncEnumerable a IEnumerable quando a origem dos dados for assíncrona, pois isso evita bloqueio de threads.
  • Propague o CancellationToken para interromper o trabalho quando o cliente desconectar.
  • Mantenha o System.Text.Json como formatador para garantir o streaming real.
  • Evite lazy loading em entidades retornadas por streaming.
  • Combine o streaming com paginação quando o volume de dados for muito grande.

Além disso, tenha em mente que o streaming acontece dentro do pipeline normal do ASP.NET. Se você quiser entender melhor como cada requisição percorre esse caminho, veja o artigo sobre middleware no ASP.NET.

Também vale testar o endpoint com ferramentas que exibem a resposta parcial. Algumas ferramentas aguardam o fim da resposta para mostrar o conteúdo, o que pode esconder o efeito do streaming.

Conclusão

Podemos utilizar o IAsyncEnumerable para enviar dados para a tela ou para outras interfaces no formato de stream, de forma simples e fácil no ASP.NET 6. Basta retornar a interface em uma action e deixar o framework cuidar do resto.

A grande mudança aconteceu no System.Text.Json, que passou a serializar sequências assíncronas sem buffer. Com isso, o MVC parou de carregar tudo para a memória antes de responder.

Por outro lado, o recurso exige atenção com formatadores alternativos, lazy loading e cancelamento. Seguindo esses cuidados, o IAsyncEnumerable deixa suas APIs mais leves e mais responsivas.

FAQ

Preciso configurar algo para fazer streaming com IAsyncEnumerable no ASP.NET 6?

Não. Basta retornar um IAsyncEnumerable na action, desde que a aplicação use o formatador padrão baseado em System.Text.Json.

O IAsyncEnumerable funciona com Newtonsoft.Json?

Funciona, mas sem streaming. Com Newtonsoft.Json ou formatadores XML, o ASP.NET coloca a sequência inteira em buffer antes de enviar.

Qual a diferença entre IEnumerable e IAsyncEnumerable?

O IEnumerable produz itens de forma síncrona. Já o IAsyncEnumerable permite usar await entre um item e outro, liberando a thread durante a espera.

Posso usar IAsyncEnumerable com Entity Framework Core?

Sim. O EF Core implementa IAsyncEnumerable nas consultas, mas evite lazy loading para não gerar consultas concorrentes no mesmo DbContext.

Como cancelar um streaming quando o cliente desconecta?

Receba um CancellationToken na action e repasse para o iterador. No método iterador, marque o parâmetro com o atributo EnumeratorCancellation.