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.
| Demo | Verificado |
|---|---|
| Básicos do iterador | os itens chegam em ordem; yield return await ... compila e executa como uma única instrução |
| Pull preguiçoso | chamar o método iterador não executa nada; o primeiro MoveNextAsync produz exatamente um item |
| Cancelamento | WithCancellation injeta o token em [EnumeratorCancellation]; a OperationCanceledException carrega o token do chamador; finally executa |
| Break antecipado | sair do await foreach com break descarta o enumerador e executa o finally do iterador |
| Exceções | itens emitidos antes do throw são entregues; a exceção aparece no await foreach com o tipo original; finally executa |
| Tempo até o primeiro item | com buffer: os N itens existem antes de o consumo começar; streaming: exatamente um |
| Memória, medida | 100k linhas: a lista em buffer retém ~18 MB vivos; o pico do streaming arredonda para zero |
| LINQ na BCL | Where/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 HTTP | a resposta é Transfer-Encoding: chunked; o cliente segura o item 1 enquanto o item 2 comprovadamente ainda não existe |
| Desconexão do cliente | derrubar a conexão no meio do stream dispara o CancellationToken do endpoint dentro do iterador |
| Action de controller | uma action declarada async IAsyncEnumerable<int> com yield transmite corretamente |

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()retornaValueTask<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. CancellationTokenfaz 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ário | Abordagem |
|---|---|
| Fonte pull (DB, arquivo, API paginada) | Iterador async direto |
| Fonte push (eventos, sockets, filas) | Ponte Channel<T> |
| Fan-out para múltiplos consumidores | Channel<T> com múltiplos leitores |
IEnumerable vs IAsyncEnumerable vs IObservable
IEnumerable<T> | IAsyncEnumerable<T> | IObservable<T> (Rx) | |
|---|---|---|---|
| Modelo de execução | Pull síncrono | Pull assíncrono | Push (observer notificado) |
| Backpressure | Implícito (o chamador controla) | Implícito (o chamador controla) | Requer operadores (ex.: Buffer) |
| Cancelamento | Não integrado | CancellationToken de primeira classe | Assinatura IDisposable |
| Tratamento de erros | Exceções propagam normalmente | Exceções propagam normalmente | Callback OnError |
| Suporte LINQ | Completo (System.Linq) | Completo na BCL desde o .NET 10 (antes, pacote System.Linq.Async) | Completo (Rx.NET) |
| Modelo de threads | Thread do chamador | Thread do chamador (sem scheduling implícito) | Baseado em Scheduler |
| Melhor para | Coleções em memória | Fontes 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étrica | Com Buffer | Streaming |
|---|---|---|
| Heap vivo, medido (100k linhas) | 18.5 MB — todo o grafo de objetos de uma vez | o 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 primeira | exatamente 1 linha existe quando o consumidor vê a primeira |
| Adequado para | Conjuntos pequenos a médios | Conjuntos 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:
- 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>usandoyield, e ela transmite corretamente. Delegar para um método privado ainda merece o lugar aqui: mantém o parsing de parâmetros (DateOnly→DateTime) 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. AsNoTracking()— o rastreamento de mudanças adiciona custo por entidade; nunca use tracking para exportações somente leitura.Selectempurra a projeção para o SQL — apenas as colunas necessárias trafegam pela rede.- Log de progresso a cada 1.000 linhas oferece visibilidade sem saturar os logs.
CancellationTokenda 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 anterioresusing 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ário | Recomendação |
|---|---|
| Conjunto pequeno (< ~1.000 linhas) | ToListAsync() — mais simples, impacto em memória desprezível |
| Conjunto grande, somente leitura | AsAsyncEnumerable() com await foreach |
| Exportação HTTP em streaming | Retornar IAsyncEnumerable<T> da action do controller |
| Fonte de eventos push | Channel<T> conectado a IAsyncEnumerable<T> |
| Operadores reativos complexos | IObservable<T> (Rx.NET) |
| Cancelamento | Sempre adicionar [EnumeratorCancellation] CancellationToken |
| Código entre bibliotecas | Adicionar .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.