Start Debugging

IEnumerable vs IAsyncEnumerable vs IQueryable в C#: что должен возвращать метод?

Три интерфейса последовательностей, три модели выполнения. Используйте IQueryable, когда база данных может транслировать запрос, IAsyncEnumerable, когда производитель асинхронный и вам нужна потоковая передача, IEnumerable для всего остального в памяти.

Если вы выбираете между IEnumerable<T>, IAsyncEnumerable<T> и IQueryable<T> для сигнатуры метода в C# 14 / .NET 11, правило почти механическое. Возвращайте IQueryable<T> только тогда, когда потребитель может скомпоновать дополнительные вызовы Where/Select/OrderBy и нижележащий провайдер (EF Core 11, LINQ to SQL, клиент OData) может транслировать их в удалённый запрос. Возвращайте IAsyncEnumerable<T>, когда производитель выполняет ввод-вывод на элемент или на пакет и вы хотите, чтобы потребитель начал обрабатывать данные до того, как производитель закончит. Возвращайте IEnumerable<T> для всего, что уже находится в памяти, или что вы решили полностью материализовать на границе. Ошибка, которой нужно избегать, — это утечка IQueryable<T> за пределы репозитория: каждый последующий .Where(...) становится частью SQL, хотите вы того или нет, и “где этот запрос на самом деле выполняется” становится вопросом, на который вам придётся отвечать с помощью отладчика.

Эта публикация — длинная версия. Все примеры нацелены на <TargetFramework>net11.0</TargetFramework> с <LangVersion>14.0</LangVersion> и, где применимо, Microsoft.EntityFrameworkCore 11.0.0.

Три интерфейса, три модели выполнения

На бумаге эти три интерфейса выглядят похожими. Все они представляют собой одну последовательность T. Разница в том, где выполняется работа и когда.

Самое важное следствие: IEnumerable<T>, возвращённый вызовом EF Core, уже покинул базу данных. IQueryable<T>, возвращённый тем же вызовом, — нет. Этот единственный факт ответственен за большее количество тикетов “почему этот запрос медленный”, чем любая другая отдельная причина в коде EF Core.

Матрица возможностей

ВозможностьIEnumerable<T>IAsyncEnumerable<T>IQueryable<T>
Модель выполнениясинхронный pullасинхронный pullотложенная, транслируется провайдером
Где выполняется работавызывающий поток, в памятина стороне производителя, awaitableудалённый провайдер (БД, OData, Cosmos)
Может использовать await между элементаминетдан/д (нет работы на элемент)
Доступные операторы LINQLINQ to ObjectsLINQ to Objects (Async)специфичное для провайдера подмножество
Компонуется после возвратада (в памяти)да (в памяти)да (транслируется удалённо)
Потоковая передача без буферизациида (ленивый yield return)дазависит от провайдера
Отменанет, цикл синхронныйCancellationToken на элементна запрос через ToListAsync(token)
Риск при возврате из репозиториянизкийсредний (время жизни провайдера)высокий (вызывающий может добавить SQL)
Лучше всего подходитколлекции в памятиудалённые потоки, server-sentвнутренние объекты запроса репозитория
Материализуется прикаждом MoveNextкаждом await MoveNextAsyncтерминальном операторе

Матрица — это и есть публикация. Всё ниже — обоснование.

Когда IEnumerable<T> — правильный тип возврата

IEnumerable<T> — значение по умолчанию для “у меня есть элементы, дай мне последовательность”. Он синхронный, имеет все операторы LINQ to Objects и дёшево компонуется. Используйте его для:

Ловушка — использовать IEnumerable<T> как тип возврата метода репозитория, который оборачивает асинхронный вызов ввода-вывода. Это заставляет репозиторий делать .ToList() внутри и терять свойство потоковости, либо заставляет вызывающего использовать .Result и блокировку пула потоков. И то, и другое — неправильно. Если источник асинхронный, сигнатурой должна быть IAsyncEnumerable<T> или Task<List<T>>, а не 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 возвращает IEnumerable<string>, который лениво читает файл. Преобразование остаётся ленивым. Ничто не заставляет файл полностью загружаться до того, как первый элемент достигнет вызывающего.

Ключевое слово yield return — то, что заставляет это работать. Оно сообщает компилятору сгенерировать машину состояний, которая возвращает элементы по одному, приостанавливая метод между yield. Это синхронное зеркало await foreach плюс yield return вместе.

Когда IAsyncEnumerable<T> — правильный тип возврата

IAsyncEnumerable<T> — то, к чему вы прибегаете, когда производителю нужно ожидать с await между элементами. Кардинальный пример — HTTP-эндпоинт с пагинацией: вы извлекаете страницу 1, выдаёте каждый элемент, извлекаете страницу 2, выдаёте каждый элемент. Вы хотите, чтобы потребитель начал работу на странице 1, пока страница 2 ещё в пути. Вы также хотите, чтобы CancellationToken был проброшен, чтобы потребитель мог чисто остановить производителя.

Используйте его для:

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

Две детали, на которых люди спотыкаются. Во-первых, [EnumeratorCancellation] обязателен, чтобы проводить токен из WithCancellation(...) в месте вызова в итератор. Без него вызов await foreach (var x in source.WithCancellation(token)) молча отбрасывает токен. Во-вторых, асинхронный итератор-метод не может использовать try/catch вокруг yield return для исключения, приходящего из нижестоящего оператора; исключение проходит через потребителя, а не через производителя. Оборачивайте вызовы ввода-вывода явно, когда вам нужна логика повторных попыток.

Для EF Core 11 эквивалент на 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);
}

Это сохраняет SQL-датаридер открытым и подтягивает строки по запросу. Полный набор никогда не оказывается в List<Order>. Подробности по EF Core см. в как использовать IAsyncEnumerable с EF Core 11.

Когда IQueryable<T> — правильный тип возврата

IQueryable<T> — правильная форма внутри репозитория или вспомогательного класса построения запросов, где от вызывающего всё ещё ожидается, что он будет компоновать. Это неправильная форма за сетевой границей или из слоя, который следующий вызывающий может не понять.

Используйте его для:

Шаблон, который кусается, — выставлять IQueryable<T> из сервисного слоя, который вызывающий считает возвращающим данные в памяти:

// Антишаблон: не возвращайте IQueryable<T> из публичного сервиса
public IQueryable<Order> GetRecentOrders() => _db.Orders.Where(o => o.At > _start);

// Вызывающий, в километрах отсюда
var bad = service.GetRecentOrders()
                 .Where(o => SomeLocalMethod(o))   // EF Core бросает: не транслируется
                 .OrderBy(o => o.Total)
                 .Take(50)
                 .ToList();

SomeLocalMethod — это метод C#, который EF Core не может транслировать. Вызов Where добавляет выражение, которое провайдер не может опустить до SQL, и при материализации вы получаете исключение. Или хуже: в провайдере, который молча откатывается к вычислению на клиенте, вы случайно вытягиваете все строки по сети, чтобы фильтровать в процессе. EF Core 11 бросает по умолчанию; более старый код с вставленными в середину цепочки переключениями AsEnumerable ещё труднее читать.

Исправление — материализовать на границе:

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

Метод теперь возвращает конкретную, материализованную коллекцию. Вызывающий не может случайно добавить SQL. Если вызывающий хочет другой фильтр, он явно просит его через параметр или новый метод. Это та же логика, которая стоит за как обнаружить запросы N+1 в EF Core 11: будьте явны в том, где находится граница запроса.

Бенчмарк: потоковая передача миллиона строк тремя способами

Реальное число. Настройка: 1 000 000 узких строк (Guid Id, int Status, DateTime At) в SQL Server 2022. Потребитель считает строки, проходящие фильтр (Status == 1), и записывает сумму временных меток. Делаем это тремя способами:

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

Методика: BenchmarkDotNet 0.14.0, .NET 11.0.0 RTM, EF Core 11.0.0, SQL Server 2022 16.0.4135 на той же машине через loopback. Windows 11 24H2, AMD Ryzen 9 7900X, 64 ГБ DDR5. Числа — из одного репрезентативного запуска.

МетодСреднееВыделено
Queryable_Aggregate (baseline)38 ms1.4 KB
StreamAsync1,210 ms410 MB
Materialize_Then_Enumerate1,380 ms432 MB

Шаблон согласуется с тем, как работают три интерфейса. IQueryable<T> позволяет базе данных сделать подсчёт и суммирование и отправить обратно два скаляра. IAsyncEnumerable<T> экономит вам около 12 процентов общего времени по сравнению с ToList-и-цикл, и экономит профиль памяти в форме пика (выделение List<Event> в Materialize_Then_Enumerate видно в dotnet-counters как одиночный пик gen2). Но обе проигрывают queryable-форме в 30 раз, потому что работа принадлежала базе данных, а не клиенту.

Вывод не “всегда используйте IQueryable”. Он таков: если операция может быть выражена на языке запросов провайдера, не вытаскивайте строки. Если вам нужно вытащить строки (экспорт в CSV, преобразование, которое не транслируется, нижестоящий сервис, который хочет отдельные элементы), предпочитайте IAsyncEnumerable<T> материализованному IEnumerable<T>.

Подводные камни, которые решают за вас

Несколько вещей принимают решение за вас независимо от предпочтений.

Мнение, переформулированное

По умолчанию — IEnumerable<T> для работы в памяти. Прибегайте к IAsyncEnumerable<T> в тот момент, когда производителю нужен await, и проводите [EnumeratorCancellation] с первого дня. Держите IQueryable<T> внутри слоя репозитория или построителя запросов; конвертируйте в материализованный IReadOnlyList<T> или в IAsyncEnumerable<T> перед пересечением сервисной границы.

Два следствия, которые стоит закрепить в мышечной памяти:

Связанные материалы

Источники

Comments

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

< Назад