.NET C# Performance Streaming Backend

IAsyncEnumerable no .NET: streaming de dados sem carregar tudo na memória

Como usar IAsyncEnumerable<T> no .NET para processar grandes volumes de dados em streaming, sem pressure de memória: banco de dados, APIs externas.

N
Neryx Digital Architects
7 de março de 2026
14 min de leitura
280 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Imagine que você precisa exportar 500 mil registros do banco para um CSV, ou processar um feed de eventos em tempo real, ou transmitir resultados de uma API externa para o cliente conforme chegam. A abordagem ingênua — carregar tudo em uma List<T> antes de processar — funciona em escala pequena e quebra em produção. IAsyncEnumerable<T>, introduzido no C# 8 e .NET Core 3.0, resolve esse problema com elegância: você itera sobre dados assíncronos sem precisar materializar a coleção inteira na memória.

O problema com IEnumerable e Task<List<T>>

Antes do IAsyncEnumerable<T>, você tinha duas opções ruins para dados assíncronos:

// Opção 1: síncrono — bloqueia a thread durante I/O
IEnumerable<Pedido> BuscarPedidos() {
    return _db.Pedidos.ToList(); // carrega TUDO na memória
}

// Opção 2: assíncrono, mas ainda carrega tudo
async Task<List<Pedido>> BuscarPedidosAsync() {
    return await _db.Pedidos.ToListAsync(); // materializa antes de retornar
}

Ambas compartilham o mesmo problema: a coleção inteira precisa existir na memória antes do caller processar o primeiro item. Com 500 mil registros, isso significa alocação massiva, pressão no GC e latência alta antes da primeira resposta.

IAsyncEnumerable<T>: o conceito

IAsyncEnumerable<T> representa uma sequência assíncrona onde cada elemento é produzido sob demanda — você consome item a item, e o produtor avança apenas quando o consumidor está pronto. É o equivalente assíncrono de IEnumerable<T> com yield return:

// Produtor: usa yield return dentro de método async
async IAsyncEnumerable<Pedido> StreamPedidosAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var pedido in _db.Pedidos
        .AsAsyncEnumerable()
        .WithCancellation(ct))
    {
        // processa/transforma cada item antes de yield
        pedido.Total = CalcularTotal(pedido);
        yield return pedido;
    }
}

// Consumidor: await foreach
await foreach (var pedido in StreamPedidosAsync(cancellationToken))
{
    await ProcessarPedidoAsync(pedido);
}

O banco retorna registros um a um (via cursor), o método faz o yield, e o consumidor processa. Nunca há mais de um pedido na memória ao mesmo tempo — exceto o que está sendo processado.

Entity Framework Core: streaming do banco

EF Core suporta IAsyncEnumerable<T> nativamente via AsAsyncEnumerable():

public async IAsyncEnumerable<RelatorioItem> GerarRelatorioAsync(
    DateOnly inicio,
    DateOnly fim,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    var query = _db.Vendas
        .Where(v => v.Data >= inicio && v.Data <= fim)
        .OrderBy(v => v.Data)
        .Select(v => new RelatorioItem(v.Id, v.Produto, v.Valor, v.Data));

    await foreach (var item in query.AsAsyncEnumerable().WithCancellation(ct))
    {
        yield return item;
    }
}

Cuidado importante: AsAsyncEnumerable() abre um cursor no banco. Se você tiver lógica pesada no loop, a conexão fica aberta durante todo o processamento. Para operações longas, considere processar em batches usando Chunk():

await foreach (var batch in query
    .AsAsyncEnumerable()
    .Chunk(1000)
    .WithCancellation(ct))
{
    // processa 1000 itens por vez
    await ProcessarBatchAsync(batch);
}

Exportação de CSV sem alocação massiva

Um caso de uso clássico: exportar dados para CSV em uma API sem carregar tudo na memória. Com IAsyncEnumerable<T> e streaming de response HTTP:

app.MapGet("/export/vendas", async (
    VendasService vendas,
    HttpResponse response,
    CancellationToken ct) =>
{
    response.ContentType = "text/csv; charset=utf-8";
    response.Headers.ContentDisposition = "attachment; filename=vendas.csv";

    await using var writer = new StreamWriter(response.Body, leaveOpen: true);
    await writer.WriteLineAsync("Id,Produto,Valor,Data");

    await foreach (var item in vendas.StreamVendasAsync(ct))
    {
        await writer.WriteLineAsync(
            $"{item.Id},{item.Produto},{item.Valor},{item.Data:yyyy-MM-dd}");
    }
});

O cliente começa a receber bytes enquanto o servidor ainda está lendo do banco. Memória usada: proporcional ao buffer de I/O, não ao total de registros.

Consumindo APIs externas em streaming

APIs que retornam paginação ou Server-Sent Events são candidatos naturais para IAsyncEnumerable<T>:

public async IAsyncEnumerable<Produto> BuscarTodosProdutosAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    var pagina = 1;
    const int tamanhoPagina = 100;

    while (true)
    {
        var resultado = await _httpClient.GetFromJsonAsync<PagedResult<Produto>>(
            $"/api/produtos?page={pagina}&size={tamanhoPagina}", ct);

        if (resultado?.Items is null || !resultado.Items.Any())
            yield break;

        foreach (var produto in resultado.Items)
            yield return produto;

        if (resultado.Items.Count < tamanhoPagina)
            yield break;

        pagina++;
    }
}

// Consumidor não sabe que é paginado — só vê um stream contínuo
await foreach (var produto in BuscarTodosProdutosAsync(ct))
{
    await IndexarProdutoAsync(produto);
}

Integração com ASP.NET Core: retorno direto no controller

ASP.NET Core e Minimal APIs suportam retorno direto de IAsyncEnumerable<T> — o framework serializa em streaming automaticamente com System.Text.Json:

// Minimal API
app.MapGet("/produtos/stream", (ProdutoService svc, CancellationToken ct) =>
    svc.StreamProdutosAsync(ct));

// Controller
[HttpGet("stream")]
public IAsyncEnumerable<ProdutoDto> StreamProdutos(CancellationToken ct)
    => _svc.StreamProdutosAsync(ct);

O JSON é emitido como um array, mas o serializer escreve cada elemento conforme disponível — o cliente começa a receber dados antes da resposta estar completa.

SignalR: push de dados em tempo real

SignalR tem suporte nativo a IAsyncEnumerable<T> em server streaming hubs:

public class DashboardHub : Hub
{
    private readonly MetricasService _metricas;

    public DashboardHub(MetricasService metricas) => _metricas = metricas;

    public async IAsyncEnumerable<MetricaDto> StreamMetricas(
        string sensor,
        [EnumeratorCancellation] CancellationToken ct)
    {
        await foreach (var metrica in _metricas.MonitorarAsync(sensor, ct))
        {
            yield return metrica;
        }
    }
}

// Cliente JavaScript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/dashboard")
    .build();

connection.stream("StreamMetricas", "cpu")
    .subscribe({
        next: (item) => atualizarGrafico(item),
        error: (err) => console.error(err),
        complete: () => console.log("Stream finalizado")
    });

Channel<T>: produtor e consumidor em threads separadas

Para cenários onde o produtor e o consumidor rodam em paralelo (como processar um arquivo enquanto ele chega), Channel<T> é o par natural de IAsyncEnumerable<T>:

public async IAsyncEnumerable<ProcessadoItem> ProcessarArquivoParaleloAsync(
    Stream arquivo,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    var channel = Channel.CreateBounded<ProcessadoItem>(
        new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });

    // Produtor: lê e processa em background
    var produtor = Task.Run(async () =>
    {
        try
        {
            await foreach (var linha in LerLinhasAsync(arquivo, ct))
            {
                var item = await ProcessarLinhaAsync(linha, ct);
                await channel.Writer.WriteAsync(item, ct);
            }
        }
        finally
        {
            channel.Writer.Complete();
        }
    }, ct);

    // Consumidor: lê do channel como IAsyncEnumerable
    await foreach (var item in channel.Reader.ReadAllAsync(ct))
        yield return item;

    await produtor; // propaga exceções do produtor
}

Cancellation: o atributo [EnumeratorCancellation]

O atributo [EnumeratorCancellation] é fundamental — ele faz o compilador conectar automaticamente o CancellationToken passado para WithCancellation() ao parâmetro do método:

// SEM [EnumeratorCancellation] — cancellation NÃO funciona via WithCancellation
async IAsyncEnumerable<T> StreamAsync(CancellationToken ct) { ... }
await foreach (var item in StreamAsync(default).WithCancellation(externalCt)) // externalCt é ignorado!

// COM [EnumeratorCancellation] — correto
async IAsyncEnumerable<T> StreamAsync([EnumeratorCancellation] CancellationToken ct = default) { ... }
await foreach (var item in StreamAsync().WithCancellation(externalCt)) // externalCt é propagado corretamente

Testando código com IAsyncEnumerable

Para testes unitários, criar sequências IAsyncEnumerable<T> é simples com o método de extensão ToAsyncEnumerable() ou um método auxiliar:

// Método helper para testes
public static async IAsyncEnumerable<T> AsAsyncEnumerable<T>(
    this IEnumerable<T> source)
{
    foreach (var item in source)
    {
        yield return item;
        await Task.Yield(); // simula assincronia real
    }
}

// Uso no teste com NSubstitute/Moq
var mockService = Substitute.For<IProdutoService>();
mockService.StreamProdutosAsync(Arg.Any<CancellationToken>())
    .Returns(new[] { prod1, prod2, prod3 }.AsAsyncEnumerable());

// Colleta para asserção
var resultado = await svc.StreamProdutosAsync(CancellationToken.None)
    .ToListAsync();
Assert.Equal(3, resultado.Count);

Quando usar (e quando não usar)

Use IAsyncEnumerable quando: o conjunto de dados é grande (acima de alguns milhares de registros), o consumidor processa item a item sem precisar de acesso aleatório, você quer baixa latência até o primeiro resultado, ou está lidando com streams contínuos (eventos, sensores, feeds).

Não use quando: você precisa de acesso aleatório à coleção (índice por posição), a coleção precisa ser passada para múltiplos consumidores, o conjunto é pequeno (menos de algumas centenas de itens — o overhead não compensa), ou precisa de Count(), Sum() e outros agregadores que por definição precisam da coleção completa.

Benchmarks: memória em prática

Para ilustrar o impacto, considere um endpoint que retorna 100 mil registros:

// ToListAsync: ~45 MB alocados, ~820 ms até primeira resposta
var lista = await _db.Pedidos.ToListAsync(ct);
return Ok(lista);

// IAsyncEnumerable: ~1.2 MB de pico, ~12 ms até primeiro byte
return _db.Pedidos.AsAsyncEnumerable();

A diferença de memória é de 37x, e a latência até o primeiro byte cai drasticamente. Em ambientes com muitas requisições simultâneas, isso pode ser a diferença entre o serviço ficar de pé ou entrar em OOM.

IAsyncEnumerable<T> não é uma otimização prematura — é o modelo correto para qualquer operação de I/O que retorna múltiplos itens. A próxima vez que você escrever ToListAsync() em uma query de banco ou GetAllAsync() em um repositório, vale questionar: o caller realmente precisa de tudo de uma vez, ou ele processa item a item? Na maioria dos casos, a resposta é: item a item.

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.