Start Debugging

IEnumerable vs IAsyncEnumerable vs IQueryable em C#: qual o método deve retornar?

Três interfaces de sequência, três modelos de execução. Use IQueryable quando um banco de dados puder traduzir a consulta, IAsyncEnumerable quando o produtor for assíncrono e você quiser transmitir, IEnumerable para tudo o mais em memória.

Se você está escolhendo entre IEnumerable<T>, IAsyncEnumerable<T> e IQueryable<T> para a assinatura de um método em C# 14 / .NET 11, a regra é quase mecânica. Retorne IQueryable<T> apenas quando o consumidor puder compor mais chamadas Where/Select/OrderBy e o provedor subjacente (EF Core 11, LINQ to SQL, um cliente OData) puder traduzi-las na consulta remota. Retorne IAsyncEnumerable<T> quando o produtor faz E/S por item ou por lote e você quer que o consumidor comece a processar antes do produtor terminar. Retorne IEnumerable<T> para tudo o que já está em memória ou que você decidiu materializar completamente na fronteira. O erro a evitar é vazar IQueryable<T> para fora de um repositório: cada .Where(...) subsequente passa a fazer parte do SQL, queira você ou não, e “onde essa consulta realmente executa” vira uma pergunta que você precisa responder com o depurador.

Esta publicação é a versão longa. Todos os exemplos têm como alvo <TargetFramework>net11.0</TargetFramework> com <LangVersion>14.0</LangVersion> e, quando relevante, Microsoft.EntityFrameworkCore 11.0.0.

Três interfaces, três modelos de execução

As três interfaces parecem semelhantes no papel. Todas expõem uma única sequência de T. A diferença é onde o trabalho acontece e quando.

A consequência mais importante: um IEnumerable<T> retornado por uma chamada do EF Core já saiu do banco de dados. Um IQueryable<T> retornado pela mesma chamada não saiu. Esse único fato é responsável por mais tickets de “por que essa consulta está lenta” do que qualquer outra causa única em código de EF Core.

Matriz de recursos

CapacidadeIEnumerable<T>IAsyncEnumerable<T>IQueryable<T>
Modelo de execuçãopull síncronopull assíncronoadiado, traduzido por um provedor
Onde o trabalho executathread chamadora, em memórialado do produtor, aguardávelprovedor remoto (BD, OData, Cosmos)
Pode usar await entre itensnãosimn/a (sem trabalho por item)
Operadores LINQ disponíveisLINQ to ObjectsLINQ to Objects (Async)subconjunto específico do provedor
Componível após o retornosim (em memória)sim (em memória)sim (traduzido remotamente)
Transmite sem buffersim (yield return lazy)simdepende do provedor
Cancelamentonenhum, o loop é síncronoCancellationToken por itempor consulta via ToListAsync(token)
Risco ao retornar de um repositóriobaixomédio (vida do provedor)alto (o chamador pode anexar SQL)
Melhor encaixecoleções em memóriastreams remotos, server-sentobjetos de consulta internos ao repo
Materializa quandoem cada MoveNextem cada await MoveNextAsyncno operador terminal

A matriz é a publicação. Tudo abaixo é o raciocínio.

Quando IEnumerable<T> é o tipo de retorno certo

IEnumerable<T> é o padrão para “eu tenho itens, me dê uma sequência”. É síncrono, tem todos os operadores LINQ to Objects e compõe barato. Use-o para:

A armadilha é usar IEnumerable<T> como tipo de retorno de um método de repositório que envolve uma chamada de E/S assíncrona. Isso força o repositório a fazer .ToList() internamente e perder a propriedade de streaming, ou força o chamador a usar .Result e um bloqueio do thread pool. Ambos estão errados. Se a fonte é assíncrona, a assinatura deve ser IAsyncEnumerable<T> ou Task<List<T>>, não 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 retorna um IEnumerable<string> que lê o arquivo de forma lazy. A transformação permanece lazy. Nada força o arquivo a ser carregado por completo antes que o primeiro item chegue ao chamador.

A palavra-chave yield return é o que faz isso funcionar. Ela diz ao compilador para gerar uma máquina de estados que retorna itens um de cada vez, suspendendo o método entre yields. É o espelho síncrono de await foreach mais yield return juntos.

Quando IAsyncEnumerable<T> é o tipo de retorno certo

IAsyncEnumerable<T> é o que você usa quando o produtor precisa aguardar com await entre itens. O exemplo cardinal é um endpoint HTTP paginado: você busca a página 1, entrega cada item, busca a página 2, entrega cada item. Você quer que o consumidor comece a trabalhar na página 1 enquanto a página 2 ainda está em voo. Você também quer um CancellationToken conectado para que o consumidor possa parar o produtor de forma limpa.

Use-o 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;
    }
}

Dois detalhes que pegam as pessoas. Primeiro, [EnumeratorCancellation] é obrigatório para conectar o token de WithCancellation(...) no local da chamada ao iterador. Sem ele, chamar await foreach (var x in source.WithCancellation(token)) silenciosamente descarta o token. Segundo, um método iterador assíncrono não pode usar try/catch ao redor de um yield return para uma exceção que vem de um operador downstream; a exceção flui pelo consumidor, não pelo produtor. Envolva as chamadas de E/S explicitamente quando precisar de lógica de retry.

Para EF Core 11, o equivalente em um DbSet<T> é 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);
}

Isso mantém o leitor de dados SQL aberto e puxa linhas sob demanda. O conjunto completo nunca fica em List<Order>. Para os detalhes específicos do EF Core, veja como usar IAsyncEnumerable com EF Core 11.

Quando IQueryable<T> é o tipo de retorno certo

IQueryable<T> é a forma certa dentro de um repositório ou de um helper de construção de consultas, onde o chamador ainda é esperado para compor. É a forma errada cruzando uma fronteira de rede ou saindo de uma camada que o próximo chamador pode não entender.

Use-o para:

O padrão que morde é expor IQueryable<T> de uma camada de serviço que o chamador assume que retorna dados em memória:

// Antipadrão: não retorne IQueryable<T> de um serviço público
public IQueryable<Order> GetRecentOrders() => _db.Orders.Where(o => o.At > _start);

// Chamador, a quilômetros de distância
var bad = service.GetRecentOrders()
                 .Where(o => SomeLocalMethod(o))   // EF Core lança: não traduzível
                 .OrderBy(o => o.Total)
                 .Take(50)
                 .ToList();

SomeLocalMethod é um método C# que o EF Core não consegue traduzir. A chamada Where anexa uma expressão que o provedor não consegue rebaixar para SQL, e na materialização você obtém uma exceção. Ou pior, em um provedor que cai silenciosamente para avaliação em cliente, você acidentalmente puxa todas as linhas pela rede para filtrar em processo. O EF Core 11 lança por padrão; código mais antigo com mudanças AsEnumerable inseridas no meio de uma cadeia é ainda mais difícil de ler.

A correção é materializar na fronteira:

// .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);
}

O método agora retorna uma coleção concreta e materializada. O chamador não pode acidentalmente anexar SQL. Se o chamador quer um filtro diferente, ele pede explicitamente via um parâmetro ou um novo método. Esta é a mesma razão que motiva como detectar consultas N+1 no EF Core 11: seja explícito sobre onde fica a fronteira da consulta.

O benchmark: transmitindo um milhão de linhas de três formas

Um número real. A configuração: 1.000.000 de linhas estreitas (um Guid Id, um int Status, um DateTime At) no SQL Server 2022. O consumidor conta linhas que passam um filtro (Status == 1) e escreve uma soma de timestamps. Fazemos isso de três 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;
    }
}

Metodologia: BenchmarkDotNet 0.14.0, .NET 11.0.0 RTM, EF Core 11.0.0, SQL Server 2022 16.0.4135 na mesma máquina sobre loopback. Windows 11 24H2, AMD Ryzen 9 7900X, 64 GB DDR5. Os números são uma execução representativa.

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

O padrão é consistente com como as três interfaces funcionam. IQueryable<T> deixa o banco de dados fazer a contagem e a soma e enviar dois escalares de volta. IAsyncEnumerable<T> te economiza cerca de 12 por cento do tempo total em relação a ToList-e-loop, e te economiza o perfil de memória em forma de pico (a alocação de List<Event> em Materialize_Then_Enumerate é visível em dotnet-counters como um único pico gen2). Mas ambas perdem para a forma queryable por 30x porque o trabalho pertencia ao banco de dados, não ao cliente.

A conclusão não é “sempre use IQueryable”. É: se a operação pode ser expressa na linguagem de consulta do provedor, não retire as linhas. Se você precisa retirar as linhas (exportação CSV, uma transformação que não traduz, um serviço downstream que quer itens individuais), prefira IAsyncEnumerable<T> em vez de um IEnumerable<T> materializado.

As armadilhas que decidem por você

Algumas coisas tomam a decisão por você, independente de preferência.

A recomendação opinativa, reformulada

Por padrão, use IEnumerable<T> para trabalho em memória. Vá para IAsyncEnumerable<T> no momento em que o produtor precisa usar await, e conecte [EnumeratorCancellation] desde o primeiro dia. Mantenha IQueryable<T> dentro da camada de repositório ou construtor de consultas; converta para um IReadOnlyList<T> materializado ou para IAsyncEnumerable<T> antes de cruzar uma fronteira de serviço.

Dois corolários que vale a pena memorizar:

Relacionados

Fontes

Comments

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

< Voltar