//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 17 min read

IAsyncEnumerable<T> em C# — Streaming de Dados do Jeito Certo

Use IAsyncEnumerable<T> para transmitir dados sem carregar tudo na memória: iteradores async, streaming com EF Core, respostas em streaming no ASP.NET Core e cancelamento.

#csharp#async#dotnet

Carregar 100.000 linhas do banco de dados em uma List<T> antes de enviá-las ao cliente é uma forma garantida de esgotar a memória sob carga. IAsyncEnumerable<T>, introduzido no C# 8 / .NET Core 3.0, oferece uma maneira de primeira classe para produzir e consumir sequências de forma assíncrona — um item por vez, sob demanda.

Cada Afirmação Aqui É Verificada, Não Narrada

O comportamento de um stream é observável como fatos concretos: quantos itens o produtor tinha criado quando o consumidor viu o primeiro, se um bloco finally executou, o que um channel limitado faz quando está cheio — e, para as afirmações HTTP, o que realmente chega por um socket de verdade. samples/iasyncenumerable-csharp transforma este artigo em um projeto de console em que cada linha de saída é uma verificação que passa. Os demos HTTP iniciam um servidor Kestrel no próprio processo em uma porta de loopback e verificam contra respostas reais, com portões (TaskCompletionSource) tornando a ordem determinística: o servidor não pode produzir o item 2 até o cliente provar que recebeu o item 1.

Escrever o sample corrigiu três coisas que versões anteriores deste artigo tinham errado: yield return await ... é legal como uma única instrução, actions de controller podem ser iteradores async, e no .NET 10 os operadores LINQ vêm na BCL — sem necessidade do pacote System.Linq.Async. Detalhes ao longo do texto.

DemoVerificado
Básicos do iteradoros itens chegam em ordem; yield return await ... compila e executa como uma única instrução
Pull preguiçosochamar o método iterador não executa nada; o primeiro MoveNextAsync produz exatamente um item
CancelamentoWithCancellation injeta o token em [EnumeratorCancellation]; a OperationCanceledException carrega o token do chamador; finally executa
Break antecipadosair do await foreach com break descarta o enumerador e executa o finally do iterador
Exceçõesitens emitidos antes do throw são entregues; a exceção aparece no await foreach com o tipo original; finally executa
Tempo até o primeiro itemcom buffer: os N itens existem antes de o consumo começar; streaming: exatamente um
Memória, medida100k linhas: a lista em buffer retém ~18 MB vivos; o pico do streaming arredonda para zero
LINQ na BCLWhere/Select/ToListAsync funcionam sem pacote NuGet no .NET 10; OrderBy entrega o primeiro item só depois de todos produzidos
Channel<T>um channel limitado bloqueia o escritor quando cheio — backpressure real; ReadAllAsync termina em Complete()
Streaming HTTPa resposta é Transfer-Encoding: chunked; o cliente segura o item 1 enquanto o item 2 comprovadamente ainda não existe
Desconexão do clientederrubar a conexão no meio do stream dispara o CancellationToken do endpoint dentro do iterador
Action de controlleruma action declarada async IAsyncEnumerable<int> com yield transmite corretamente
Saída de console do sample: 23 verificações passando em 12 demos — yield return await executa como uma única instrução; iteradores são preguiçosos e dirigidos por pull; WithCancellation injeta o token do chamador e finally executa; break descarta o enumerador; exceções aparecem no await foreach; com buffer os 5 itens existem antes de o consumidor começar enquanto streaming mantém um; a memória medida mostra 18.5 MB com buffer contra um pico de 0.00 MB em streaming para 100k linhas; LINQ funciona a partir da BCL sem pacote NuGet e OrderBy armazena em buffer; um channel limitado bloqueia a segunda escrita até ser lido; a resposta HTTP é Transfer-Encoding chunked com o item 1 recebido antes de o item 2 existir; a desconexão do cliente dispara o CancellationToken do endpoint; e uma action de controller declarada como iterador async retorna 1, 2, 3. Todas as verificações passaram.
A execução do sample: 23 asserções em 12 demos, incluindo verificações HTTP contra Kestrel ao vivo — streaming é um contrato: um item existe por vez, e o consumidor dita o ritmo.

O Problema com List<T> para Grandes Volumes de Dados

Considere um endpoint de exportação típico que carrega tudo antecipadamente:

// Carregar tudo antes de fazer qualquer coisa — o uso de memória cresce com o número de linhas
public async Task<IActionResult> ExportProducts()
{
    List<Product> products = await _db.Products
        .AsNoTracking()
        .ToListAsync();                 // TODAS as linhas em memória de uma vez
 
    return Ok(products);               // depois serializar tudo
}

Para 100 linhas isso é aceitável. Para 100.000 linhas, ToListAsync() bloqueia até que o resultado completo seja obtido, aloca um buffer contiguo grande, e então o serializador precisa manter a lista inteira em memória enquanto escreve a resposta.

IAsyncEnumerable<T> divide isso em um pipeline: as linhas são produzidas conforme chegam do banco de dados e consumidas (serializadas, processadas, encaminhadas) antes que o próximo lote chegue.

O que é IAsyncEnumerable<T>

IAsyncEnumerable<T> é o equivalente assíncrono de IEnumerable<T>. Ele expõe um único método:

public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
 
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }
    ValueTask<bool> MoveNextAsync();
}

Pontos principais:

  • MoveNextAsync() retorna ValueTask<bool> — awaitable e eficiente em alocações para o caso comum de conclusão síncrona.
  • O próprio enumerador é IAsyncDisposable, portanto o descarte dos recursos subjacentes (conexões DB, streams) também é assíncrono.
  • CancellationToken faz parte do contrato no nível superior.

Escrevendo Métodos Iteradores Async

Um iterador async é um método async que retorna IAsyncEnumerable<T> e usa yield return. O compilador o transforma em uma máquina de estados, assim como iteradores síncronos, mas com suporte assíncrono.

public async IAsyncEnumerable<int> GenerateSequenceAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(10);   // simular trabalho async por item
        yield return i;
    }
}

Você pode misturar expressões await livremente com yield return — até na mesma instrução. Eu costumava repetir a afirmação de que não podem ser combinados; o sample a refuta com uma linha:

public async IAsyncEnumerable<int> FetchFirstAsync()
{
    yield return await Task.FromResult(42); // uma instrução, ambas as palavras-chave — legal
}

Adicionando Suporte a CancellationToken

A forma idiomática de suportar cancelamento em um iterador async é o atributo [EnumeratorCancellation]:

using System.Runtime.CompilerServices;
 
public async IAsyncEnumerable<Product> StreamProductsAsync(
    int categoryId,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await using var connection = await _dbFactory.OpenConnectionAsync(cancellationToken);
 
    await foreach (var product in FetchFromDbAsync(connection, categoryId, cancellationToken))
    {
        // transformação leve por item
        product.Price = Math.Round(product.Price, 2);
        yield return product;
 
        // verificação explícita se o corpo do loop for lento
        cancellationToken.ThrowIfCancellationRequested();
    }
}

Quando um chamador passa um token via WithCancellation(), o runtime o injeta automaticamente no parâmetro [EnumeratorCancellation]:

var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
 
await foreach (var product in StreamProductsAsync(categoryId: 5)
                   .WithCancellation(cts.Token))
{
    Console.WriteLine(product.Name);
}
💡

Sempre aceite [EnumeratorCancellation] CancellationToken em iteradores async públicos. O overhead é insignificante quando não utilizado, e torna o método seguro para cancelamento sem uma mudança que quebre compatibilidade depois. O sample verifica o percurso completo: cancele o token passado a WithCancellation, e a OperationCanceledException que sai do await foreach carrega esse mesmo token — enquanto o bloco finally do iterador ainda executa.

Consumindo com await foreach

await foreach é o açúcar sintático que chama GetAsyncEnumerator, itera sobre MoveNextAsync, e descarta o enumerador ao terminar (inclusive em caso de exceção):

await foreach (var item in source)
{
    Process(item);
}
 
// se decompõe aproximadamente em:
await using var enumerator = source.GetAsyncEnumerator(cancellationToken);
while (await enumerator.MoveNextAsync())
{
    Process(enumerator.Current);
}

ConfigureAwait no await foreach

Para código de biblioteca que deve evitar capturar o contexto de sincronização:

await foreach (var item in source.ConfigureAwait(false))
{
    Process(item);
}

EF Core: AsAsyncEnumerable()

As extensões de IQueryable<T> do EF Core incluem AsAsyncEnumerable(), que executa a consulta e transmite as linhas conforme chegam do banco de dados, em vez de armazená-las todas em um buffer.

public async IAsyncEnumerable<ProductDto> StreamProductsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    // AsAsyncEnumerable() abre o reader e retorna linhas uma por vez
    await foreach (var p in _db.Products
                               .AsNoTracking()           // sem rastreamento de mudanças para leituras
                               .Where(p => p.IsActive)
                               .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                               .AsAsyncEnumerable()
                               .WithCancellation(ct))
    {
        yield return p;
    }
}
⚠️

Mantenha o DbContext ativo durante toda a duração do stream. Não o descarte dentro do iterador, e esteja ciente de que o contexto não é thread-safe — apenas um leitor concorrente por contexto.

Comparando ToListAsync vs AsAsyncEnumerable

// ToListAsync — armazena tudo em buffer, depois processa
var products = await _db.Products.ToListAsync(ct);
foreach (var p in products)
    await SendToClientAsync(p, ct);
 
// AsAsyncEnumerable — processa cada linha conforme chega
await foreach (var p in _db.Products.AsAsyncEnumerable().WithCancellation(ct))
    await SendToClientAsync(p, ct);

Para 100.000 linhas, a primeira versão atinge um pico com o grafo de objetos completo em memória; a segunda mantém apenas uma única linha por vez dentro do corpo do loop.

ASP.NET Core: Retornando IAsyncEnumerable de Controllers

O serializador System.Text.Json do ASP.NET Core suporta IAsyncEnumerable<T> nativamente. Quando você o retorna de uma action de controller, o runtime transmite o array JSON conforme os itens ficam disponíveis:

[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;
 
    public ProductsController(AppDbContext db) => _db = db;
 
    // A resposta é um array JSON transmitido conforme as linhas chegam — sem buffer completo
    [HttpGet("export")]
    public IAsyncEnumerable<ProductDto> ExportAsync(CancellationToken ct)
    {
        return _db.Products
                  .AsNoTracking()
                  .Select(p => new ProductDto(p.Id, p.Name, p.Price))
                  .AsAsyncEnumerable();
    }
}

Não é necessário await nem wrapper Ok() — o ASP.NET Core detecta IAsyncEnumerable<T> e cuida do resto. O sample verifica o que realmente trafega pelo fio: a resposta é Transfer-Encoding: chunked (sem Content-Length, sem buffering completo), e o serializador faz flush sempre que MoveNextAsync precisa esperar — o cliente do sample lê [1 do socket enquanto um portão garante que o item 2 ainda não foi produzido. Uma nuance que vale conhecer: para itens que chegam em sequência sem await entre eles, o System.Text.Json acumula em seu buffer interno e faz flush por tamanho, não por item.

💡

O parâmetro CancellationToken ct em um método de action é automaticamente vinculado ao token HttpContext.RequestAborted. O sample verifica isso de ponta a ponta: o cliente lê o primeiro item, derruba a conexão no meio do stream, e o token dispara dentro do iterador — o bloco finally observa o cancelamento.

Versão com Minimal API

app.MapGet("/api/products/export", (AppDbContext db, CancellationToken ct) =>
    db.Products
      .AsNoTracking()
      .Select(p => new ProductDto(p.Id, p.Name, p.Price))
      .AsAsyncEnumerable());

Usando Channel<T> como Ponte Produtor/Consumidor

Channel<T> (de System.Threading.Channels) é útil quando os dados são enviados de uma fonte externa (ex.: uma fila de mensagens, WebSocket ou tarefa em segundo plano) e você quer expô-los como IAsyncEnumerable<T>.

public async IAsyncEnumerable<OrderEvent> WatchOrdersAsync(
    string customerId,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    // Canal limitado fornece backpressure — o produtor bloqueia quando cheio
    var channel = Channel.CreateBounded<OrderEvent>(new BoundedChannelOptions(100)
    {
        FullMode = BoundedChannelFullMode.Wait
    });
 
    // Produtor: executa independentemente, escreve no canal
    var producer = Task.Run(async () =>
    {
        try
        {
            await foreach (var evt in _messageBus.SubscribeAsync(customerId, ct))
                await channel.Writer.WriteAsync(evt, ct);
        }
        finally
        {
            channel.Writer.Complete();  // sinaliza fim do stream
        }
    }, ct);
 
    // Consumidor: expõe o canal como IAsyncEnumerable
    await foreach (var evt in channel.Reader.ReadAllAsync(ct))
        yield return evt;
 
    await producer;   // propagar exceções do produtor
}

Channel<T> desacopla a taxa do produtor da taxa do consumidor e fornece backpressure natural através da capacidade limitada.

Channel vs Iteração Direta

CenárioAbordagem
Fonte pull (DB, arquivo, API paginada)Iterador async direto
Fonte push (eventos, sockets, filas)Ponte Channel<T>
Fan-out para múltiplos consumidoresChannel<T> com múltiplos leitores

IEnumerable vs IAsyncEnumerable vs IObservable

IEnumerable<T>IAsyncEnumerable<T>IObservable<T> (Rx)
Modelo de execuçãoPull síncronoPull assíncronoPush (observer notificado)
BackpressureImplícito (o chamador controla)Implícito (o chamador controla)Requer operadores (ex.: Buffer)
CancelamentoNão integradoCancellationToken de primeira classeAssinatura IDisposable
Tratamento de errosExceções propagam normalmenteExceções propagam normalmenteCallback OnError
Suporte LINQCompleto (System.Linq)Completo na BCL desde o .NET 10 (antes, pacote System.Linq.Async)Completo (Rx.NET)
Modelo de threadsThread do chamadorThread do chamador (sem scheduling implícito)Baseado em Scheduler
Melhor paraColeções em memóriaFontes async (DB, arquivos, APIs)Streams de eventos, operadores complexos
💡

Prefira IAsyncEnumerable<T> para streaming de banco de dados e I/O. Recorra a IObservable<T> apenas quando precisar de operadores reativos (debounce, merge, throttle) ou quando um modelo push for fundamentalmente mais natural.

Uso de Memória: List<T> vs Streaming

O sample mede a diferença entre armazenar em buffer e transmitir 100.000 linhas (um record pequeno com um id, um nome de ~50 caracteres e um preço) comparando instantâneos de GC.GetTotalMemory contra uma linha de base prévia:

// Abordagem A: armazenar tudo em buffer
public async Task<long> BufferedApproachAsync()
{
    List<Product> products = await _db.Products
        .AsNoTracking()
        .ToListAsync();
 
    long total = 0;
    foreach (var p in products)
        total += (long)p.Price;
 
    return total;
}
 
// Abordagem B: transmitir linha por linha
public async Task<long> StreamingApproachAsync(CancellationToken ct = default)
{
    long total = 0;
 
    await foreach (var p in _db.Products
                               .AsNoTracking()
                               .AsAsyncEnumerable()
                               .WithCancellation(ct))
    {
        total += (long)p.Price;
    }
 
    return total;
}
MétricaCom BufferStreaming
Heap vivo, medido (100k linhas)18.5 MB — todo o grafo de objetos de uma vezo pico arredonda para 0 MB — cada linha vira lixo antes de a próxima existir
Tempo até o primeiro resultado (verificado)as 100.000 linhas existem antes de o consumidor ver a primeiraexatamente 1 linha existe quando o consumidor vê a primeira
Adequado paraConjuntos pequenos a médiosConjuntos grandes ou ilimitados

Os números medidos vêm do produtor em memória do sample, então isolam o efeito do buffering em si; um banco de dados real adiciona buffering do driver por cima, e linhas mais pesadas escalam o valor com buffer linearmente enquanto o pico do streaming permanece plano.

Padrão Real: Endpoint de Exportação Grande

Juntando tudo — uma exportação em streaming pronta para produção que lida com cancelamento, aplica uma transformação e transmite JSON ao cliente:

[ApiController]
[Route("api/reports")]
public class ReportController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly ILogger<ReportController> _logger;
 
    public ReportController(AppDbContext db, ILogger<ReportController> logger)
    {
        _db = db;
        _logger = logger;
    }
 
    [HttpGet("orders/export")]
    [Produces("application/json")]
    public IAsyncEnumerable<OrderExportRow> ExportOrdersAsync(
        [FromQuery] DateOnly from,
        [FromQuery] DateOnly to,
        CancellationToken ct)                   // vinculado ao RequestAborted automaticamente
    {
        var fromDate = from.ToDateTime(TimeOnly.MinValue);
        var toDate   = to.ToDateTime(TimeOnly.MaxValue);
 
        return StreamOrdersAsync(fromDate, toDate, ct);
    }
 
    // Método privado separado mantém a lógica do iterador limpa
    private async IAsyncEnumerable<OrderExportRow> StreamOrdersAsync(
        DateTime fromDate,
        DateTime toDate,
        [EnumeratorCancellation] CancellationToken ct)
    {
        int count = 0;
 
        await foreach (var order in _db.Orders
                                       .AsNoTracking()
                                       .Where(o => o.CreatedAt >= fromDate && o.CreatedAt <= toDate)
                                       .OrderBy(o => o.CreatedAt)
                                       .Select(o => new
                                       {
                                           o.Id,
                                           o.CustomerName,
                                           o.Total,
                                           o.CreatedAt,
                                           ItemCount = o.Items.Count
                                       })
                                       .AsAsyncEnumerable()
                                       .WithCancellation(ct))
        {
            yield return new OrderExportRow(
                order.Id,
                order.CustomerName,
                order.Total,
                order.CreatedAt,
                order.ItemCount);
 
            count++;
 
            // registrar progresso a cada 1000 linhas sem bloquear o stream
            if (count % 1000 == 0)
                _logger.LogInformation("Exportadas {Count} ordens até agora", count);
        }
 
        _logger.LogInformation("Exportação concluída. Total de linhas: {Count}", count);
    }
}
 
public record OrderExportRow(
    Guid Id,
    string CustomerName,
    decimal Total,
    DateTime CreatedAt,
    int ItemCount);

Decisões de design principais:

  1. Método iterador privado separado — uma escolha de estilo, não uma exigência. Eu costumava afirmar que actions de controller não podem ser iteradores async; o sample refuta isso com uma action declarada async IAsyncEnumerable<int> usando yield, e ela transmite corretamente. Delegar para um método privado ainda merece o lugar aqui: mantém o parsing de parâmetros (DateOnlyDateTime) fora do iterador preguiçoso, então uma entrada inválida lança quando a action é chamada, e não quando a enumeração começa.
  2. AsNoTracking() — o rastreamento de mudanças adiciona custo por entidade; nunca use tracking para exportações somente leitura.
  3. Select empurra a projeção para o SQL — apenas as colunas necessárias trafegam pela rede.
  4. Log de progresso a cada 1.000 linhas oferece visibilidade sem saturar os logs.
  5. CancellationToken da action — se o cliente se desconectar no meio da exportação, a consulta ao DB é cancelada.

LINQ sobre IAsyncEnumerable<T>

A partir do .NET 10, os operadores LINQ para IAsyncEnumerable<T> (Where, Select, OrderBy, ToListAsync e o resto) vêm na BCL — o sample os usa sem referenciar pacote algum. No .NET 9 e anteriores, adicione o pacote NuGet System.Linq.Async para obter os mesmos operadores:

dotnet add package System.Linq.Async   # necessário apenas no .NET 9 e anteriores
using System.Linq;
 
// Filtrar e transformar antes de consumir
var expensiveProducts = _db.Products
    .AsAsyncEnumerable()
    .Where(p => p.Price > 100)
    .Select(p => new { p.Name, p.Price })
    .OrderBy(p => p.Price);       // nota: ordena em memória após o streaming
 
await foreach (var p in expensiveProducts)
    Console.WriteLine($"{p.Name}: {p.Price:C}");
⚠️

OrderBy sobre um IAsyncEnumerable<T> armazena toda a sequência em buffer antes de retornar o primeiro elemento — o sample verifica que o primeiro item ordenado chega apenas depois de cada item da fonte ter sido produzido. Para ordenar streams grandes, empurre o ORDER BY para o banco de dados via IQueryable<T> antes de chamar AsAsyncEnumerable().

Tratamento de Exceções em Iteradores Async

Exceções lançadas dentro de um iterador async se propagam ao chamador do await foreach exatamente como exceções síncronas se propagam através do foreach — o sample verifica que itens emitidos antes do throw são entregues, e que a exceção aparece no loop com o tipo original. (Para o que acontece com exceções async em geral — AggregateException, Task.WhenAll, tasks não observadas — veja Tratamento de Exceções Async em C#.)

public async IAsyncEnumerable<string> RiskyStreamAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    yield return "primeiro";
    await Task.Delay(10, ct);
    throw new InvalidOperationException("algo deu errado");
    // nada depois do throw é alcançado
}
 
try
{
    await foreach (var item in RiskyStreamAsync())
        Console.WriteLine(item);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Stream falhou: {ex.Message}");
}

O bloco finally (e o descarte de IAsyncDisposable) ainda é executado quando uma exceção escapa:

public async IAsyncEnumerable<Row> StreamWithCleanupAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await using var reader = await OpenReaderAsync(ct);
    try
    {
        while (await reader.ReadAsync(ct))
            yield return reader.GetRow();
    }
    finally
    {
        // executado mesmo se o chamador lançar exceção ou cancelar
        _logger.LogInformation("Reader fechado");
    }
}

Resumo

CenárioRecomendação
Conjunto pequeno (< ~1.000 linhas)ToListAsync() — mais simples, impacto em memória desprezível
Conjunto grande, somente leituraAsAsyncEnumerable() com await foreach
Exportação HTTP em streamingRetornar IAsyncEnumerable<T> da action do controller
Fonte de eventos pushChannel<T> conectado a IAsyncEnumerable<T>
Operadores reativos complexosIObservable<T> (Rx.NET)
CancelamentoSempre adicionar [EnumeratorCancellation] CancellationToken
Código entre bibliotecasAdicionar .ConfigureAwait(false) ao await foreach

IAsyncEnumerable<T> é o padrão correto para qualquer sequência assíncrona que venha de um banco de dados, arquivo, API externa ou outra fonte de I/O. Mantém a memória estável, inicia o consumidor assim que o primeiro item está pronto, e se integra de forma limpa com ASP.NET Core e EF Core sem nenhuma configuração extra.

Cada afirmação de comportamento acima é verificada por samples/iasyncenumerable-csharp — clone-o e execute dotnet run se quiser ver alguma delas falhar em um runtime futuro.

Dois temas vizinhos valem a leitura em seguida: MoveNextAsync retorna ValueTask<bool> por um motivo — ValueTask vs Task explica o que isso rende no caminho majoritariamente síncrono — e a distinção Canceled-vs-Faulted da qual os demos do iterador dependem é coberta em CancellationToken em C# — Padrões Práticos.

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados