.NET Tutorial
Middle5 мин чтенияВопрос 19 из 25

Как работает интерфейс IAsyncEnumerable<T>?

Проблема: выбор между памятью и асинхронностью

C#
// Вариант 1: асинхронно, но всё в памяти — ждём ВСЕ строки
Task<List<Order>> GetAllAsync();

// Вариант 2: потоково, но синхронно — поток блокируется на каждой строке
IEnumerable<Order> GetAll();

// Вариант 3: и потоково, и асинхронно
IAsyncEnumerable<Order> GetAllAsync();

Task<List<T>>

ждём все 1 000 000 строк

весь список в памяти

обработка

IEnumerable<T>

строка 1

поток БЛОКИРОВАН
пока ждёт строку 2

строка 2 ...

IAsyncEnumerable<T>

строка 1 → обработка

await: поток свободен

строка 2 → обработка ...

Task<List<T>>

ждём все 1 000 000 строк

весь список в памяти

обработка

IEnumerable<T>

строка 1

поток БЛОКИРОВАН
пока ждёт строку 2

строка 2 ...

IAsyncEnumerable<T>

строка 1 → обработка

await: поток свободен

строка 2 → обработка ...

Три способа вернуть последовательность

Интерфейсы

C#
public interface IAsyncEnumerable<out T>
{
    IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}

public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
    T Current { get; }
    ValueTask<bool> MoveNextAsync();     // ValueTask — часто элемент уже готов (буфер), без аллокаций
}

Производитель: асинхронный итератор

C#
public async IAsyncEnumerable<Product> GetAllProductsAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    string? cursor = null;
    do
    {
        // постраничный обход внешнего API
        var page = await _http.GetFromJsonAsync<Page<Product>>($"/products?cursor={cursor}", ct);
        foreach (var product in page!.Items)
            yield return product;                     // отдать элемент потребителю
        cursor = page.NextCursor;
    }
    while (cursor is not null);
}

Потребитель: await foreach

C#
await foreach (var product in _catalog.GetAllProductsAsync(ct))
{
    await _index.UpsertAsync(product, ct);
    if (product.IsDiscontinued) break;   // break → DisposeAsync → итератор прекращает загрузку страниц
}

Что разворачивает компилятор:

C#
var enumerator = source.GetAsyncEnumerator(ct);
try
{
    while (await enumerator.MoveNextAsync())
    {
        var product = enumerator.Current;
        // тело цикла
    }
}
finally
{
    await enumerator.DisposeAsync();
}
Внешний APIАсинхронныйитераторawait foreach... товары 3–100 ...итератор завершён,страница 3 незагружаетсяMoveNextAsync()GET страница 1100 товаровtrue, Current = товар 1MoveNextAsync()true, товар 2 — синхронно,из уже загруженнойстраницыMoveNextAsync()GET страница 2100 товаровtrue, товар 101break → DisposeAsync()
Внешний APIАсинхронныйитераторawait foreach... товары 3–100 ...итератор завершён,страница 3 незагружаетсяMoveNextAsync()GET страница 1100 товаровtrue, Current = товар 1MoveNextAsync()true, товар 2 — синхронно,из уже загруженнойстраницыMoveNextAsync()GET страница 2100 товаровtrue, товар 101break → DisposeAsync()
Взаимодействие потребителя и итератора

Отмена

Два пути передачи токена — и они объединяются:

C#
// 1. Прямой вызов метода с токеном
await foreach (var x in GetAllProductsAsync(ct)) { }

// 2. Токен у потребителя, когда последовательность пришла «готовой»
IAsyncEnumerable<Product> products = GetAllProductsAsync();
await foreach (var x in products.WithCancellation(ct)) { }

Атрибут [EnumeratorCancellation] говорит компилятору: токен, переданный в GetAsyncEnumerator(ct) (через WithCancellation), нужно подставить в этот параметр. Без атрибута второй способ не отменит итератор.

Практика в ASP.NET Core и EF Core

C#
// EF Core: строки читаются из DbDataReader по одной, без ToList()
app.MapGet("/export", (AppDbContext db) =>
    db.Orders.AsNoTracking().Where(o => o.Year == 2025).AsAsyncEnumerable());
// System.Text.Json сериализует IAsyncEnumerable потоково: клиент получает JSON-массив по мере чтения

// Обработка большой выборки без загрузки в память
await foreach (var order in db.Orders.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
    await writer.WriteLineAsync(ToCsv(order));
}

LINQ для асинхронных последовательностей

В .NET 10 LINQ-операторы для IAsyncEnumerable (Where, Select, Take, ToListAsync...) встроены в BCL (System.Linq.AsyncEnumerable); в более ранних версиях — пакет System.Linq.Async.

C#
var names = await GetAllProductsAsync(ct)
    .Where(p => p.Price > 100)
    .Select(p => p.Name)
    .Take(50)
    .ToListAsync(ct);

Вопросы на засыпку

Чем IAsyncEnumerable отличается от IObservable?

IAsyncEnumerable — pull-модель: потребитель сам запрашивает следующий элемент и контролирует темп (естественный backpressure). IObservable (Rx) — push: производитель отправляет элементы, когда хочет, и потребителю нужно успевать.

Чем IAsyncEnumerable отличается от Channel<T>?

Channel — буфер между независимыми производителями и потребителями, работающими параллельно. IAsyncEnumerable — ленивая последовательность: производитель выполняется только когда потребитель просит элемент. ChannelReader.ReadAllAsync() превращает канал в IAsyncEnumerable.

Можно ли перечислить IAsyncEnumerable дважды?

Технически да — каждый await foreach вызывает GetAsyncEnumerator и запускает итератор заново (новые запросы к API/БД). Как и с IEnumerable, это часто нежелательно.

Что будет, если внутри await foreach бросить исключение?

Выполнится finally с DisposeAsync(), итератор выполнит свои finally-блоки (например, закроет соединение), исключение пойдёт дальше.

Итог

IAsyncEnumerable<T> объединяет ленивость IEnumerable и неблокирующее ожидание async: элементы обрабатываются по мере поступления, память не растёт, поток не блокируется. Не забывайте про [EnumeratorCancellation] и про время жизни ресурсов, открытых во время стриминга.