yield, итераторы, IAsyncEnumerable

↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая

Обязательно~6 мин чтенияУровень: middle

Зачем это на собесе

yield объясняет, как работает LINQ и отложенное выполнение. IAsyncEnumerable позволяет стримить большие выборки из БД и ClickHouse, читать постраничные внешние API и отдавать данные клиенту без загрузки всего в память.

Объяснение

yield return

Метод с yield return компилируется в класс-итератор (конечный автомат, state machine). Тело выполняется по частям при каждом MoveNext().

public static IEnumerable<int> Range(int from, int count)
{
    for (var i = 0; i < count; i++)
    {
        Console.WriteLine($"generate {from + i}");
        yield return from + i;            // приостановка: значение отдано, состояние сохранено
    }
    // yield break; — досрочное завершение
}
 
foreach (var n in Range(1, 3).Take(2)) Console.WriteLine(n);
// generate 1, 1, generate 2, 2 — третий элемент даже не сгенерирован
  • Код до первого yield не выполняется до первого MoveNext(). Отсюда нюанс с валидацией аргументов: исключение появится не при вызове метода, а при переборе.
public static IEnumerable<T> Batch<T>(IEnumerable<T> source, int size)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);   // НЕ сработает при вызове Batch(...)!
    return Iterator();                                          // решение: валидировать снаружи, итератор — локальная функция
    IEnumerable<T> Iterator() { var buf = new List<T>(size); foreach (var x in source) { buf.Add(x); if (buf.Count == size) { yield return buf; buf = new(size); } } if (buf.Count > 0) yield return buf; }
}
  • try/finally в итераторе выполнится при Dispose() перечислителя (foreach вызывает его автоматически, в том числе при break).

IAsyncEnumerable<T> и await foreach

Асинхронный поток: каждый элемент может ожидать I/O.

// чтение постраничного внешнего API (FHIR Bundle, 1С OData, Битрикс24 — все отдают страницами)
public async IAsyncEnumerable<Patient> ReadAllAsync([EnumeratorCancellation] CancellationToken ct = default)
{
    string? next = "/Patient?_count=100";
    while (next is not null)
    {
        var page = await _http.GetFromJsonAsync<Page<Patient>>(next, ct);
        foreach (var p in page!.Items) yield return p;
        next = page.NextLink;
    }
}
 
await foreach (var patient in client.ReadAllAsync(ct))
    await mapper.UpsertAsync(patient, ct);            // обработка по мере поступления
  • [EnumeratorCancellation] связывает токен из WithCancellation(ct) с параметром.
  • В связке с EF Core: db.Observations.AsAsyncEnumerable() читает строки из курсора потоково.
  • ASP.NET Core умеет возвращать IAsyncEnumerable<T> из эндпоинта — JSON-массив стримится клиенту.
app.MapGet("/api/export/observations", (AppDb db, CancellationToken ct) =>
    db.Observations.AsNoTracking().Where(o => o.Date >= from).AsAsyncEnumerable());

System.Linq.Async и .NET 10

Операторы LINQ для IAsyncEnumerable (WhereAwait, SelectAwait, ToListAsync) — пакет System.Linq.Async. В .NET 10 они вошли в BCL (System.Linq.AsyncEnumerable).

Channels как альтернатива

Когда производитель и потребитель работают независимо (разная скорость, несколько потребителей) — Channel<T> и его ReadAllAsync() (тоже IAsyncEnumerable).

Нюансы и подводные камни

  • Итератор, возвращённый из метода с using (например, DbContext или соединение), выполнится после выхода из метода, когда ресурс уже освобождён. Ресурс должен жить внутри итератора.
  • Повторное перечисление итератора выполняет генерацию заново (повторные запросы к API или БД).
  • yield нельзя использовать в try с catch (только try/finally), в лямбдах и с ref/out-параметрами.
  • Стриминг из EF Core держит соединение и курсор открытыми всё время перебора: не делайте долгую обработку каждого элемента, иначе блокируете соединение из пула.

Тестирование

[Fact]
public async Task Reads_all_pages()
{
    var handler = new FakePagedHandler(pages: 3, perPage: 2);   // HttpMessageHandler-заглушка
    var client = new FhirClient(new HttpClient(handler) { BaseAddress = new("http://test") });
    var items = await client.ReadAllAsync().ToListAsync();
    items.Should().HaveCount(6);
}

Вопросы с ответами

Связанные темы