$ sudo teach IT

Значения приходят по одному, а не все сразу

Обычная коллекция — массив или словарь — уже вся целиком лежит в памяти: вы просто перебираете то, что есть. Но бывают источники данных, у которых значений заранее нет: строки, которые приходят по сети одна за другой, события от датчика, сообщения из чата, отсчёты таймера. Значение может появиться через секунду, а может через час, и до этого момента цикл должен ждать, не занимая поток целиком.

Для таких случаев в Swift есть асинхронная последовательность — тип, который отдаёт значения по одному, каждый раз с возможной паузой перед следующим.

AsyncSequence и цикл for await

Официально это называется AsyncSequence — протокол, похожий на обычный Sequence, только его метод получения следующего элемента асинхронный. Перебирать такую последовательность позволяет специальная форма цикла:

for await score in scoreStream() {
    print(score)
}

Внешне это обычный for-in с добавленным словом await. Но смысл другой: перед каждой итерацией выполнение может приостановиться и подождать, пока источник не подготовит очередное значение. Пока идёт ожидание, поток не простаивает впустую — он свободен для другой работы, ровно как при обычном await у асинхронной функции. Цикл завершается сам, когда последовательность сообщает, что значений больше не будет.

Что скрыто внутри протокола

Устроено это похоже на обычный Sequence: там итератор синхронно отдаёт следующий элемент методом next(), а у AsyncSequence есть свой асинхронный итератор с методом:

mutating func next() async -> Element?

for await — это просто короткая запись вызова такого next() в цикле: пока он возвращает значение, цикл выполняет тело, а как только приходит nil — останавливается. Писать это вручную почти никогда не нужно: готовые реализации AsyncSequence берут на себя всю асинхронную механику, а вам остаётся только for await.

AsyncStream: готовый поток значений

Чаще всего свою асинхронную последовательность не пишут с нуля, а собирают из готового типа AsyncStream. Ему достаточно один раз объяснить, откуда брать значения:

func countUp(to limit: Int) -> AsyncStream<Int> {
    AsyncStream { continuation in
        var current = 1
        while current <= limit {
            continuation.yield(current)
            current += 1
        }
        continuation.finish()
    }
}

Функция ничего не помечает как async — она обычная и просто возвращает готовый AsyncStream<Int>. А вот воспользоваться результатом можно только через for await:

for await number in countUp(to: 3) {
    print(number)
}
// напечатает 1, 2 и 3, каждое на отдельной строке

continuation: как наполнять поток изнутри

continuation — это объект, который получает замыкание внутри AsyncStream { ... }, и через него поток наполняется значениями:

  • continuation.yield(value) — добавляет очередное значение, его получит следующая итерация for await;
  • continuation.finish() — сообщает, что значений больше не будет, и цикл for await завершится.

По умолчанию AsyncStream буферизует всё, что передано через yield, даже если пока никто не читает поток циклом — значения просто дождутся своей очереди. Именно поэтому в примере выше все три числа успевают попасть в поток ещё до того, как начнётся перебор.

Мост из колбэков в поток

Официальная терминология для этого — «мост» (bridge): оборачивание старого API на колбэках в современный AsyncSequence. Представим, что где-то в проекте есть функция, сообщающая о результатах через замыкания, а не через async:

func fetchScores(onScore: @escaping (Int) -> Void, onDone: @escaping () -> Void) {
    for score in [10, 20, 30] {
        onScore(score)
    }
    onDone()
}

Такую функцию легко превратить в поток: колбэк onScore вызывает continuation.yield, а onDone — continuation.finish:

func scoreStream() -> AsyncStream<Int> {
    AsyncStream { continuation in
        fetchScores(
            onScore: { continuation.yield($0) },
            onDone: { continuation.finish() }
        )
    }
}

После этого весь остальной код может забыть про колбэки и работать через привычный for await, как с любой другой асинхронной последовательностью. Такой мост особенно полезен, когда старый код на колбэках менять нельзя или не хочется, а новый писать удобнее в стиле async/await.

Частые ошибки

  • Забыть вызвать continuation.finish() — тогда for await будет вечно ждать следующее значение, которое никогда не придёт, и программа зависнет.
  • Пытаться перебрать AsyncStream обычным for-in без await — компилятор такое не пропустит: асинхронную последовательность нельзя перебирать синхронно.
  • Вызывать continuation.yield уже после continuation.finish() — поток уже закрыт, и такие значения будут проигнорированы.
  • Думать, что await в for await означает фиксированную паузу — на самом деле это просто точка приостановки, которая срабатывает мгновенно, если следующее значение уже готово в буфере.

Резюме

  • AsyncSequence — протокол для последовательностей, значения которых появляются со временем, а не все сразу.
  • for await value in sequence перебирает такую последовательность, приостанавливаясь перед каждым новым значением.
  • AsyncStream — готовый тип для сборки собственного асинхронного потока без ручной реализации протокола.
  • continuation.yield добавляет значение в поток, continuation.finish() сообщает о его завершении.
  • Через continuation удобно строить мост: превращать старые API на колбэках в современный AsyncSequence.

Проверьте себя

3 вопроса

Поток чётных чисел

Реализуйте функцию evenStream(upTo limit: Int) -> AsyncStream<Int>, которая строит асинхронный поток на основе AsyncStream.

Поток должен по очереди отдать все чётные числа от 2 до limit включительно, по возрастанию, а затем корректно завершиться (после последнего числа continuation.finish() обязан быть вызван, иначе перебор через for await зависнет).

Если подходящих чётных чисел нет (например, limit меньше 2), поток должен сразу завершиться, не отдав ни одного значения.