Start Debugging

IEnumerable vs IAsyncEnumerable vs IQueryable en C#: ¿cuál debe devolver el método?

Tres interfaces de secuencia, tres modelos de ejecución. Usa IQueryable cuando una base de datos pueda traducir la consulta, IAsyncEnumerable cuando el productor es asíncrono y quieres transmitir, IEnumerable para todo lo demás en memoria.

Si estás eligiendo entre IEnumerable<T>, IAsyncEnumerable<T> e IQueryable<T> para la firma de un método en C# 14 / .NET 11, la regla es casi mecánica. Devuelve IQueryable<T> solo cuando el consumidor pueda componer más llamadas Where/Select/OrderBy y el proveedor subyacente (EF Core 11, LINQ to SQL, un cliente OData) pueda traducirlas a la consulta remota. Devuelve IAsyncEnumerable<T> cuando el productor hace E/S por elemento o por lote y quieres que el consumidor empiece a procesar antes de que el productor termine. Devuelve IEnumerable<T> para todo lo que ya está en memoria o que has decidido materializar completamente en la frontera. El error a evitar es filtrar IQueryable<T> fuera de un repositorio: cada .Where(...) posterior pasa a formar parte del SQL, lo quieras o no, y “dónde se ejecuta realmente esta consulta” se convierte en una pregunta que tienes que responder con el depurador.

Esta publicación es la versión larga. Todos los ejemplos apuntan a <TargetFramework>net11.0</TargetFramework> con <LangVersion>14.0</LangVersion> y, donde aplica, Microsoft.EntityFrameworkCore 11.0.0.

Tres interfaces, tres modelos de ejecución

Las tres interfaces se parecen sobre el papel. Todas exponen una única secuencia de T. La diferencia es dónde se hace el trabajo y cuándo.

La consecuencia más importante: un IEnumerable<T> devuelto por una llamada a EF Core ya ha salido de la base de datos. Un IQueryable<T> devuelto por la misma llamada no. Ese único hecho es responsable de más tickets de “por qué es lenta esta consulta” que cualquier otra causa única en código de EF Core.

Matriz de características

CapacidadIEnumerable<T>IAsyncEnumerable<T>IQueryable<T>
Modelo de ejecuciónpull síncronopull asíncronodiferido, traducido por un proveedor
Dónde se ejecuta el trabajohilo que llama, en memorialado del productor, esperableproveedor remoto (BD, OData, Cosmos)
Puede usar await entre elementosnon/a (sin trabajo por elemento)
Operadores LINQ disponiblesLINQ to ObjectsLINQ to Objects (Async)subconjunto específico del proveedor
Componible después de retornarsí (en memoria)sí (en memoria)sí (traducido remotamente)
Transmite sin búfersí (yield return lazy)depende del proveedor
Cancelaciónninguna, el bucle es syncCancellationToken por elementopor consulta vía ToListAsync(token)
Riesgo al devolverlo desde un repositoriobajomedio (vida del proveedor)alto (el llamador puede añadir SQL)
Mejor encajecolecciones en memoriastreams remotos, server-sentobjetos de consulta internos al repo
Se materializa cuandoen cada MoveNexten cada await MoveNextAsyncen el operador terminal

La matriz es la publicación. Todo lo de abajo es el razonamiento.

Cuándo IEnumerable<T> es el tipo de retorno correcto

IEnumerable<T> es el valor por defecto para “tengo elementos, dame una secuencia”. Es síncrono, tiene todos los operadores LINQ to Objects y compone barato. Úsalo para:

La trampa es usar IEnumerable<T> como tipo de retorno de un método de repositorio que envuelve una llamada de E/S asíncrona. Eso obliga al repositorio a hacer .ToList() internamente y perder la propiedad de streaming, o fuerza al llamador a usar .Result y un bloqueo del thread pool. Ambas son incorrectas. Si la fuente es asíncrona, la firma debe ser IAsyncEnumerable<T> o Task<List<T>>, no IEnumerable<T>.

// .NET 11, C# 14
public static IEnumerable<string> ReadLowercaseLines(string path)
{
    foreach (var line in File.ReadLines(path))
    {
        yield return line.ToLowerInvariant();
    }
}

File.ReadLines devuelve un IEnumerable<string> que lee el archivo de forma lazy. La transformación permanece lazy. Nada obliga a que el archivo se cargue por completo antes de que el primer elemento llegue al llamador.

La palabra clave yield return es lo que hace que esto funcione. Le indica al compilador que genere una máquina de estados que devuelva los elementos uno a uno, suspendiendo el método entre yields. Es el espejo síncrono de await foreach más yield return juntos.

Cuándo IAsyncEnumerable<T> es el tipo de retorno correcto

IAsyncEnumerable<T> es lo que usas cuando el productor necesita esperar con await entre elementos. El ejemplo cardinal es un endpoint HTTP paginado: traes la página 1, entregas cada elemento, traes la página 2, entregas cada elemento. Quieres que el consumidor empiece a trabajar sobre la página 1 mientras la página 2 sigue en vuelo. También quieres un CancellationToken conectado para que el consumidor pueda detener al productor de forma limpia.

Úsalo para:

// .NET 11, C# 14
public static async IAsyncEnumerable<Order> FetchAllAsync(
    HttpClient http,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    string? next = "/api/orders?page=1";
    while (next is not null)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var page = await http.GetFromJsonAsync<PageOf<Order>>(next, cancellationToken)
                   ?? throw new InvalidOperationException("page was null");
        foreach (var order in page.Items)
        {
            yield return order;
        }
        next = page.NextLink;
    }
}

Dos detalles que pillan a la gente. Primero, [EnumeratorCancellation] es obligatorio para conectar el token desde WithCancellation(...) en el sitio de la llamada al iterador. Sin él, llamar a await foreach (var x in source.WithCancellation(token)) descarta silenciosamente el token. Segundo, un método iterador asíncrono no puede usar try/catch alrededor de un yield return para una excepción que viene de un operador downstream; la excepción fluye por el consumidor, no por el productor. Envuelve las llamadas de E/S explícitamente cuando necesites lógica de reintentos.

Para EF Core 11, el equivalente en un DbSet<T> es AsAsyncEnumerable:

// .NET 11, C# 14, EF Core 11.0.0
await foreach (var order in db.Orders
    .Where(o => o.Status == "shipped")
    .AsAsyncEnumerable()
    .WithCancellation(cancellationToken))
{
    await sink.WriteAsync(order, cancellationToken);
}

Eso mantiene abierto el lector de datos SQL y trae filas bajo demanda. El conjunto completo nunca se sienta en List<Order>. Para los detalles específicos de EF Core, ver cómo usar IAsyncEnumerable con EF Core 11.

Cuándo IQueryable<T> es el tipo de retorno correcto

IQueryable<T> es la forma correcta dentro de un repositorio o un helper de construcción de consultas, donde todavía se espera que el llamador componga. Es la forma incorrecta a través de una frontera de red o fuera de una capa que el siguiente llamador podría no entender.

Úsalo para:

El patrón que muerde es exponer IQueryable<T> desde una capa de servicio que el llamador asume que devuelve datos en memoria:

// Antipatrón: no devuelvas IQueryable<T> desde un servicio público
public IQueryable<Order> GetRecentOrders() => _db.Orders.Where(o => o.At > _start);

// Llamador, a kilómetros de distancia
var bad = service.GetRecentOrders()
                 .Where(o => SomeLocalMethod(o))   // EF Core lanza: no traducible
                 .OrderBy(o => o.Total)
                 .Take(50)
                 .ToList();

SomeLocalMethod es un método de C# que EF Core no puede traducir. La llamada a Where añade una expresión que el proveedor no puede bajar a SQL, y en la materialización obtienes una excepción. O peor, en un proveedor que cae silenciosamente a evaluación en cliente, accidentalmente jalas todas las filas por el cable para filtrar en proceso. EF Core 11 lanza por defecto; código más antiguo con cambios AsEnumerable insertados en medio de una cadena es aún más difícil de leer.

La solución es materializar en la frontera:

// .NET 11, C# 14
public async Task<IReadOnlyList<Order>> GetRecentOrdersAsync(
    int count, CancellationToken ct)
{
    return await _db.Orders
        .Where(o => o.At > _start)
        .OrderByDescending(o => o.At)
        .Take(count)
        .ToListAsync(ct);
}

El método ahora devuelve una colección concreta y materializada. El llamador no puede añadir SQL accidentalmente. Si el llamador quiere un filtro diferente, lo pide explícitamente vía un parámetro o un método nuevo. Esta es la misma razón que impulsa cómo detectar consultas N+1 en EF Core 11: sé explícito sobre dónde está la frontera de la consulta.

El benchmark: transmitir un millón de filas de tres formas

Un número real. La configuración: 1,000,000 de filas estrechas (un Guid Id, un int Status, un DateTime At) en SQL Server 2022. El consumidor cuenta filas que pasan un filtro (Status == 1) y escribe una suma de timestamps. Lo hacemos de tres formas:

// .NET 11, C# 14, EF Core 11.0.0, BenchmarkDotNet 0.14.0
[MemoryDiagnoser]
public class SequenceShapes
{
    private AppDb _db = null!;

    [GlobalSetup] public void Setup() => _db = AppDb.Connect();

    [Benchmark]
    public long Materialize_Then_Enumerate()
    {
        var rows = _db.Events.ToList();              // pull all 1,000,000
        long sum = 0; long count = 0;
        foreach (var r in rows)
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark]
    public async Task<long> StreamAsync()
    {
        long sum = 0; long count = 0;
        await foreach (var r in _db.Events.AsAsyncEnumerable())
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark(Baseline = true)]
    public async Task<long> Queryable_Aggregate()
    {
        var count = await _db.Events.Where(e => e.Status == 1).CountAsync();
        var sum   = await _db.Events.Where(e => e.Status == 1)
                                    .SumAsync(e => (long)e.At.Ticks);
        return sum + count;
    }
}

Metodología: BenchmarkDotNet 0.14.0, .NET 11.0.0 RTM, EF Core 11.0.0, SQL Server 2022 16.0.4135 en la misma máquina sobre loopback. Windows 11 24H2, AMD Ryzen 9 7900X, 64 GB DDR5. Los números son una ejecución representativa.

MétodoMediaAsignado
Queryable_Aggregate (baseline)38 ms1.4 KB
StreamAsync1,210 ms410 MB
Materialize_Then_Enumerate1,380 ms432 MB

El patrón es consistente con cómo funcionan las tres interfaces. IQueryable<T> deja que la base de datos haga el conteo y la suma y envíe dos escalares de vuelta. IAsyncEnumerable<T> te ahorra alrededor del 12 por ciento del tiempo total frente a ToList y bucle, y te ahorra el perfil de memoria en forma de pico (la asignación de List<Event> en Materialize_Then_Enumerate es visible en dotnet-counters como un único pico gen2). Pero ambas pierden frente a la forma queryable por 30x porque el trabajo pertenecía a la base de datos, no al cliente.

La conclusión no es “usa siempre IQueryable”. Es: si la operación se puede expresar en el lenguaje de consulta del proveedor, no saques las filas. Si tienes que sacar las filas (exportación CSV, una transformación que no se traduce, un servicio downstream que quiere elementos individuales), prefiere IAsyncEnumerable<T> sobre un IEnumerable<T> materializado.

Las trampas que deciden por ti

Algunas cosas toman la decisión por ti más allá de la preferencia.

La recomendación opinionada, reformulada

Por defecto, usa IEnumerable<T> para trabajo en memoria. Recurre a IAsyncEnumerable<T> en el momento en que el productor necesita usar await, y conecta [EnumeratorCancellation] desde el primer día. Mantén IQueryable<T> dentro de la capa de repositorio o constructor de consultas; conviértelo a un IReadOnlyList<T> materializado o a IAsyncEnumerable<T> antes de cruzar una frontera de servicio.

Dos corolarios que vale la pena memorizar:

Relacionados

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver