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.