Cargar 100,000 filas de una base de datos en una List<T> antes de enviarlas al cliente es una forma segura de agotar la memoria bajo carga. IAsyncEnumerable<T>, introducido en C# 8 / .NET Core 3.0, ofrece una manera de primera clase para producir y consumir secuencias de forma asíncrona — un elemento a la vez, bajo demanda.
Cada Afirmación Aquí Está Verificada, No Narrada
El comportamiento de un stream es observable como hechos duros: cuántos elementos había
creado el productor cuando el consumidor vio el primero, si un bloque finally se ejecutó,
qué hace un channel acotado cuando está lleno — y, para las afirmaciones HTTP, qué llega
realmente por un socket real.
samples/iasyncenumerable-csharp
convierte este artículo en un proyecto de consola donde cada línea de salida es una
verificación que pasa. Los demos HTTP arrancan un servidor Kestrel en proceso sobre un
puerto de loopback y verifican contra respuestas reales, con compuertas
(TaskCompletionSource) que hacen el orden determinista: el servidor no puede producir el
elemento 2 hasta que el cliente demuestra que recibió el elemento 1.
Escribir el sample corrigió tres cosas que versiones anteriores de este artículo tenían
mal: yield return await ... es legal como una sola sentencia, las acciones de
controlador pueden ser iteradores async, y en .NET 10 los operadores LINQ vienen en el
BCL — sin necesidad del paquete System.Linq.Async. Detalles en línea más abajo.
| Demo | Verificado |
|---|---|
| Básicos del iterador | los elementos llegan en orden; yield return await ... compila y corre como una sola sentencia |
| Pull perezoso | llamar al método iterador no ejecuta nada; el primer MoveNextAsync produce exactamente un elemento |
| Cancelación | WithCancellation inyecta el token en [EnumeratorCancellation]; el OperationCanceledException porta el token del llamador; finally se ejecuta |
| Break temprano | salir de await foreach con break desecha el enumerador y ejecuta el finally del iterador |
| Excepciones | los elementos emitidos antes del throw se entregan; la excepción aparece en await foreach con su tipo original; finally se ejecuta |
| Tiempo al primer elemento | con buffer: los N elementos existen antes de que empiece el consumo; streaming: exactamente uno |
| Memoria, medida | 100k filas: la lista en buffer retiene ~18 MB vivos; el pico del streaming redondea a cero |
| LINQ en el BCL | Where/Select/ToListAsync funcionan sin paquete NuGet en .NET 10; OrderBy entrega su primer elemento solo después de producirse todos |
Channel<T> | un channel acotado bloquea al escritor cuando está lleno — backpressure real; ReadAllAsync termina en Complete() |
| Streaming HTTP | la respuesta es Transfer-Encoding: chunked; el cliente tiene el elemento 1 en mano mientras el elemento 2 demostrablemente no existe aún |
| Desconexión del cliente | cortar la conexión a mitad del stream dispara el CancellationToken del endpoint dentro del iterador |
| Acción de controlador | una acción declarada async IAsyncEnumerable<int> con yield transmite correctamente |

El Problema con List<T> para Grandes Volúmenes de Datos
Considera un endpoint de exportación típico que carga todo de antemano:
// Cargar todo antes de hacer algo — el uso de memoria aumenta con el número de filas
public async Task<IActionResult> ExportProducts()
{
List<Product> products = await _db.Products
.AsNoTracking()
.ToListAsync(); // TODAS las filas en memoria de una vez
return Ok(products); // luego serializar todo
}Para 100 filas esto está bien. Para 100,000 filas, ToListAsync() bloquea hasta que se obtiene el resultado completo, asigna un buffer contiguo grande, y luego el serializador debe mantener la lista completa en memoria mientras escribe la respuesta.
IAsyncEnumerable<T> rompe esto en un pipeline: las filas se producen a medida que llegan de la base de datos y se consumen (serializan, procesan, reenvían) antes de que llegue el siguiente lote.
Qué es IAsyncEnumerable<T>
IAsyncEnumerable<T> es el equivalente asíncrono de IEnumerable<T>. Expone un ú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();
}Puntos clave:
MoveNextAsync()devuelveValueTask<bool>— awaitable y eficiente en asignaciones para el caso común de completación síncrona.- El enumerador en sí es
IAsyncDisposable, por lo que la liberación de recursos subyacentes (conexiones DB, streams) también es asíncrona. CancellationTokenforma parte del contrato en el nivel superior.
Escribiendo Métodos Iteradores Async
Un iterador async es un método async que devuelve IAsyncEnumerable<T> y usa yield return. El compilador lo transforma en una máquina de estados, igual que los iteradores síncronos, pero con soporte async.
public async IAsyncEnumerable<int> GenerateSequenceAsync(int count)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(10); // simular trabajo async por elemento
yield return i;
}
}Puedes mezclar expresiones await libremente con yield return — incluso en la misma sentencia. Yo solía repetir la afirmación de que no se pueden combinar; el sample la refuta con una línea:
public async IAsyncEnumerable<int> FetchFirstAsync()
{
yield return await Task.FromResult(42); // una sentencia, ambas palabras clave — legal
}Agregar Soporte para CancellationToken
La forma idiomática de soportar cancelación en un iterador async es el 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))
{
// transformación ligera por elemento
product.Price = Math.Round(product.Price, 2);
yield return product;
// verificación explícita si el cuerpo del bucle es lento
cancellationToken.ThrowIfCancellationRequested();
}
}Cuando un llamador pasa un token mediante WithCancellation(), el runtime lo inyecta automáticamente en el 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);
}Acepta siempre [EnumeratorCancellation] CancellationToken en los iteradores async públicos. El costo es insignificante cuando no se usa, y hace que el método sea seguro para cancelación sin cambios que rompan compatibilidad después. El sample verifica el recorrido completo: cancela el token pasado a WithCancellation, y el OperationCanceledException que sale de await foreach porta ese mismo token — mientras el bloque finally del iterador aún se ejecuta.
Consumir con await foreach
await foreach es el azúcar sintáctico que llama a GetAsyncEnumerator, itera sobre MoveNextAsync, y elimina el enumerador al terminar (incluso en caso de excepción):
await foreach (var item in source)
{
Process(item);
}
// se descompone aproximadamente en:
await using var enumerator = source.GetAsyncEnumerator(cancellationToken);
while (await enumerator.MoveNextAsync())
{
Process(enumerator.Current);
}ConfigureAwait en await foreach
Para código de biblioteca que debe evitar capturar el contexto de sincronización:
await foreach (var item in source.ConfigureAwait(false))
{
Process(item);
}EF Core: AsAsyncEnumerable()
Las extensiones de IQueryable<T> de EF Core incluyen AsAsyncEnumerable(), que ejecuta la consulta y transmite las filas a medida que llegan desde la base de datos, en lugar de almacenarlas todas en un buffer.
public async IAsyncEnumerable<ProductDto> StreamProductsAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
// AsAsyncEnumerable() abre el reader y devuelve filas de una en una
await foreach (var p in _db.Products
.AsNoTracking() // sin seguimiento de cambios para lecturas
.Where(p => p.IsActive)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return p;
}
}Mantén el DbContext activo durante toda la duración del stream. No lo elimines dentro del iterador, y ten en cuenta que el contexto no es thread-safe — solo un lector concurrente por contexto.
Comparando ToListAsync vs AsAsyncEnumerable
// ToListAsync — almacena todo en buffer, luego procesa
var products = await _db.Products.ToListAsync(ct);
foreach (var p in products)
await SendToClientAsync(p, ct);
// AsAsyncEnumerable — procesa cada fila a medida que llega
await foreach (var p in _db.Products.AsAsyncEnumerable().WithCancellation(ct))
await SendToClientAsync(p, ct);Para 100,000 filas, la primera versión alcanza un pico con el grafo de objetos completo en memoria; la segunda mantiene solo una única fila a la vez dentro del cuerpo del bucle.
ASP.NET Core: Retornar IAsyncEnumerable desde Controladores
El serializador System.Text.Json de ASP.NET Core soporta IAsyncEnumerable<T> de forma nativa. Cuando lo retornas desde una acción de controlador, el runtime transmite el array JSON a medida que los elementos están disponibles:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly AppDbContext _db;
public ProductsController(AppDbContext db) => _db = db;
// La respuesta es un array JSON transmitido a medida que llegan las filas — sin 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();
}
}No se necesita await ni wrapper Ok() — ASP.NET Core detecta IAsyncEnumerable<T> y maneja el resto. El sample verifica lo que realmente viaja por el cable: la respuesta es Transfer-Encoding: chunked (sin Content-Length, sin buffering completo), y el serializador hace flush cada vez que MoveNextAsync tiene que esperar — el cliente del sample lee [1 del socket mientras una compuerta garantiza que el elemento 2 aún no fue producido. Un matiz que vale conocer: para elementos que llegan seguidos sin await entre medio, System.Text.Json acumula en su buffer interno y hace flush por tamaño, no por elemento.
El parámetro CancellationToken ct en un método de acción se vincula automáticamente al token HttpContext.RequestAborted. El sample lo verifica de punta a punta: el cliente lee el primer elemento, corta la conexión a mitad del stream, y el token se dispara dentro del iterador — su bloque finally observa la cancelación.
Versión con 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 Puente Productor/Consumidor
Channel<T> (de System.Threading.Channels) es útil cuando los datos son empujados desde una fuente externa (p. ej., una cola de mensajes, WebSocket o tarea en segundo plano) y quieres exponerlos como IAsyncEnumerable<T>.
public async IAsyncEnumerable<OrderEvent> WatchOrdersAsync(
string customerId,
[EnumeratorCancellation] CancellationToken ct = default)
{
// Canal acotado provee contrapresión — el productor bloquea cuando está lleno
var channel = Channel.CreateBounded<OrderEvent>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
// Productor: se ejecuta independientemente, escribe en el 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(); // señal de fin de stream
}
}, ct);
// Consumidor: expone el canal como IAsyncEnumerable
await foreach (var evt in channel.Reader.ReadAllAsync(ct))
yield return evt;
await producer; // propagar excepciones del productor
}Channel<T> desacopla la tasa del productor de la del consumidor y proporciona contrapresión natural a través de la capacidad acotada.
Channel vs Iteración Directa
| Escenario | Enfoque |
|---|---|
| Fuente pull (DB, archivo, API paginada) | Iterador async directo |
| Fuente push (eventos, sockets, colas) | Puente Channel<T> |
| Fan-out a múltiples consumidores | Channel<T> con múltiples lectores |
IEnumerable vs IAsyncEnumerable vs IObservable
IEnumerable<T> | IAsyncEnumerable<T> | IObservable<T> (Rx) | |
|---|---|---|---|
| Modelo de ejecución | Pull síncrono | Pull asíncrono | Push (observer notificado) |
| Contrapresión | Implícita (el llamador controla) | Implícita (el llamador controla) | Requiere operadores (p. ej., Buffer) |
| Cancelación | No integrada | CancellationToken de primera clase | Suscripción IDisposable |
| Manejo de errores | Excepciones se propagan normalmente | Excepciones se propagan normalmente | Callback OnError |
| Soporte LINQ | Completo (System.Linq) | Completo en el BCL desde .NET 10 (antes, paquete System.Linq.Async) | Completo (Rx.NET) |
| Modelo de hilos | Hilo del llamador | Hilo del llamador (sin scheduling implícito) | Basado en Scheduler |
| Mejor para | Colecciones en memoria | Fuentes async (DB, archivos, APIs) | Streams de eventos, operadores complejos |
Prefiere IAsyncEnumerable<T> para streaming de bases de datos e I/O. Recurre a IObservable<T> solo cuando necesites operadores reactivos (debounce, merge, throttle) o cuando un modelo push sea fundamentalmente más natural.
Uso de Memoria: List<T> vs Streaming
El sample mide la diferencia entre almacenar en buffer y transmitir 100,000 filas (un record pequeño con un id, un nombre de ~50 caracteres y un precio) comparando instantáneas de GC.GetTotalMemory contra una línea base previa:
// Enfoque A: almacenar todo en 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;
}
// Enfoque B: transmitir fila por fila
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 | Con Buffer | Streaming |
|---|---|---|
| Heap vivo, medido (100k filas) | 18.5 MB — todo el grafo de objetos a la vez | el pico redondea a 0 MB — cada fila es basura antes de que exista la siguiente |
| Tiempo hasta el primer resultado (verificado) | las 100,000 filas existen antes de que el consumidor vea la primera | exactamente 1 fila existe cuando el consumidor ve la primera |
| Adecuado para | Conjuntos pequeños a medianos | Conjuntos grandes o ilimitados |
Los números medidos vienen del productor en memoria del sample, así que aíslan el efecto del buffering en sí; una base de datos real agrega buffering del driver encima, y filas más pesadas escalan la cifra con buffer linealmente mientras el pico del streaming se mantiene plano.
Patrón Real: Endpoint de Exportación Grande
Juntando todo — una exportación en streaming lista para producción que maneja cancelación, aplica una transformación y transmite JSON al 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 a RequestAborted automáticamente
{
var fromDate = from.ToDateTime(TimeOnly.MinValue);
var toDate = to.ToDateTime(TimeOnly.MaxValue);
return StreamOrdersAsync(fromDate, toDate, ct);
}
// Método privado separado mantiene la lógica del iterador limpia
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 progreso cada 1000 filas sin bloquear el stream
if (count % 1000 == 0)
_logger.LogInformation("Exportadas {Count} órdenes hasta ahora", count);
}
_logger.LogInformation("Exportación completa. Total de filas: {Count}", count);
}
}
public record OrderExportRow(
Guid Id,
string CustomerName,
decimal Total,
DateTime CreatedAt,
int ItemCount);Decisiones de diseño clave:
- Método iterador privado separado — una decisión de estilo, no un requisito. Yo solía afirmar que las acciones de controlador no pueden ser iteradores async; el sample lo refuta con una acción declarada
async IAsyncEnumerable<int>usandoyield, y transmite correctamente. Delegar a un método privado igual se gana su lugar aquí: mantiene el parseo de parámetros (DateOnly→DateTime) fuera del iterador perezoso, así una entrada inválida lanza cuando se llama la acción y no cuando empieza la enumeración. AsNoTracking()— el seguimiento de cambios agrega costo por entidad; nunca uses tracking para exportaciones de solo lectura.Selectlleva la proyección al SQL — solo las columnas necesarias viajan por la red.- Logging de progreso cada 1,000 filas da visibilidad sin saturar los logs.
CancellationTokende la acción — si el cliente se desconecta a mitad de la exportación, la consulta DB se cancela.
LINQ sobre IAsyncEnumerable<T>
Desde .NET 10, los operadores LINQ para IAsyncEnumerable<T> (Where, Select, OrderBy, ToListAsync y el resto) vienen en el BCL — el sample los usa sin referenciar ningún paquete. En .NET 9 y anteriores, agrega el paquete NuGet System.Linq.Async para obtener los mismos operadores:
dotnet add package System.Linq.Async # solo necesario en .NET 9 y anterioresusing System.Linq;
// Filtrar y 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 en memoria después del streaming
await foreach (var p in expensiveProducts)
Console.WriteLine($"{p.Name}: {p.Price:C}");OrderBy sobre un IAsyncEnumerable<T> almacena toda la secuencia en buffer antes de devolver el primer elemento — el sample verifica que el primer elemento ordenado llega solo después de que cada elemento de la fuente fue producido. Para ordenar streams grandes, lleva el ORDER BY a la base de datos mediante IQueryable<T> antes de llamar a AsAsyncEnumerable().
Manejo de Excepciones en Iteradores Async
Las excepciones lanzadas dentro de un iterador async se propagan al llamador del await foreach exactamente como las excepciones síncronas se propagan a través de foreach — el sample verifica que los elementos emitidos antes del throw se entregan, y que la excepción aparece en el bucle con su tipo original. (Para lo que pasa con las excepciones async en general — AggregateException, Task.WhenAll, tareas no observadas — ve Manejo de Excepciones Async en C#.)
public async IAsyncEnumerable<string> RiskyStreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
yield return "primero";
await Task.Delay(10, ct);
throw new InvalidOperationException("algo salió mal");
// nada después del throw se alcanza jamás
}
try
{
await foreach (var item in RiskyStreamAsync())
Console.WriteLine(item);
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Stream falló: {ex.Message}");
}El bloque finally (y la limpieza de IAsyncDisposable) aún se ejecuta cuando una excepción 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
{
// se ejecuta incluso si el llamador lanza excepción o cancela
_logger.LogInformation("Reader cerrado");
}
}Resumen
| Escenario | Recomendación |
|---|---|
| Conjunto pequeño (< ~1,000 filas) | ToListAsync() — más simple, impacto en memoria despreciable |
| Conjunto grande, solo lectura | AsAsyncEnumerable() con await foreach |
| Exportación HTTP en streaming | Retornar IAsyncEnumerable<T> desde la acción del controlador |
| Fuente de eventos push | Channel<T> conectado a IAsyncEnumerable<T> |
| Operadores reactivos complejos | IObservable<T> (Rx.NET) |
| Cancelación | Agregar siempre [EnumeratorCancellation] CancellationToken |
| Código entre bibliotecas | Agregar .ConfigureAwait(false) a await foreach |
IAsyncEnumerable<T> es el valor predeterminado correcto para cualquier secuencia async que provenga de una base de datos, archivo, API externa u otra fuente de I/O. Mantiene la memoria estable, inicia el consumidor en cuanto el primer elemento está listo, y se integra limpiamente con ASP.NET Core y EF Core sin ninguna plomería extra.
Cada afirmación de comportamiento de arriba está verificada por
samples/iasyncenumerable-csharp
— clónalo y ejecuta dotnet run si quieres ver alguna fallar en un runtime futuro.
Dos temas vecinos valen la pena como siguiente lectura: MoveNextAsync devuelve
ValueTask<bool> por una razón — ValueTask vs Task
explica qué aporta eso en la ruta mayormente síncrona — y la distinción
Canceled-vs-Faulted de la que dependen los demos del iterador se cubre en
CancellationToken en C# — Patrones Prácticos.