Continuations в Swift: как безопасно связать callback-API с async/await

С выходом async/await в Swift асинхронный код стал значительно чище и понятнее. Однако в реальных проектах по-прежнему используется большое количество API, построенных на completion-handler’ах, делегатах и callback’ах.

Полностью переписать такие API не всегда возможно или целесообразно. Для таких случаев в Swift существуют continuations — официальный механизм для связывания старого callback-кода с новой моделью async/await.

В статье разберём:

  • что такое continuations и зачем они нужны;
  • какие виды continuations существуют в Swift;
  • как и когда использовать withCheckedThrowingContinuation;
  • какие ошибки встречаются чаще всего;
  • как корректно обрабатывать отмену Task.

Что такое continuation

Continuation — это объект, который позволяет вручную возобновить выполнение async-функции.

Когда Swift выполняет код вида:

let value = await loadValue()

он приостанавливает текущую задачу и ждёт, пока loadValue() завершится. Если внутри loadValue() используется callback-based API, Swift не знает, когда и с каким результатом нужно продолжить выполнение.

В этом случае Swift передаёт разработчику continuation — объект, у которого есть методы resume, позволяющие явно указать:

  • успешный результат;
  • ошибку.

Таким образом continuation — это мост между callback-миром и async/await.

Когда continuations действительно нужны

Continuations используются исключительно для адаптации не-async API:

  • legacy сетевые клиенты;
  • SDK сторонних библиотек;
  • API с completion-handler’ами;
  • делегаты (камера, геолокация, Bluetooth и т.д.).

Если API уже поддерживает async/await — continuations не нужны.

Виды continuations в Swift

Swift предоставляет четыре функции:

  • withCheckedContinuation
  • withCheckedThrowingContinuation
  • withUnsafeContinuation
  • withUnsafeThrowingContinuation

Различие основано на двух параметрах:

  1. Может ли операция завершиться с ошибкой
  2. Нужны ли runtime-проверки корректности

На практике:

  • checked — безопасные, с проверками
  • unsafe — без проверок, потенциально опасные

👉 В большинстве приложений следует использовать checked-версии.

withCheckedThrowingContinuation — основной рабочий инструмент

withCheckedThrowingContinuation используется, когда:

  • адаптируемый API может вернуть ошибку;
  • требуется безопасное поведение с проверками.

Сигнатура:

func withCheckedThrowingContinuation<T>( _ body: (CheckedContinuation<T, Error>) -> Void ) async throws -> T

Swift гарантирует:

  • resume будет вызван ровно один раз;
  • continuation не будет потерян;
  • при нарушении правил приложение упадёт с понятной ошибкой.

Базовый пример

Допустим, есть legacy-функция:

func loadUser(completion: @escaping (Result<String, Error>) -> Void) { DispatchQueue.global().asyncAfter(deadline: .now() + 1) { completion(.success("User")) } }

Адаптация в async/await:

func loadUserAsync() async throws -> String { try await withCheckedThrowingContinuation { continuation in loadUser { result in switch result { case .success(let user): continuation.resume(returning: user) case .failure(let error): continuation.resume(throwing: error) } } } }

Использование:

Task { do { let user = try await loadUserAsync() print(user) } catch { print(error) } }

Ключевое правило continuations

Метод resume должен быть вызван ровно один раз.

Ошибки:

  • resume не вызван → await зависает навсегда;
  • resume вызван дважды → runtime-ошибка.

Checked-continuations выявляют такие ошибки сразу.

Важная проблема: отмена Task

Отмена задачи через:

task.cancel()

не отменяет автоматически callback-based операцию, запущенную внутри continuation.

Это означает:

  • операция может продолжать выполняться;
  • ресурсы не освобождаются;
  • callback может выполниться уже после отмены задачи.

Почему так происходит

Swift Concurrency:

  • не управляет потоками напрямую;
  • отмена — это лишь установка флага isCancelled.

Legacy API об этом флаге не знает.

Правильный подход: withTaskCancellationHandler

Для долгоживущих операций необходимо обрабатывать отмену вручную:

func recordVideoAsync() async throws -> URL { try await withTaskCancellationHandler( operation: { try await withCheckedThrowingContinuation { continuation in startRecording { result in guard !Task.isCancelled else { stopRecording() continuation.resume(throwing: CancellationError()) return } switch result { case .success(let url): continuation.resume(returning: url) case .failure(let error): continuation.resume(throwing: error) } } } }, onCancel: { stopRecording() } ) }

Что здесь важно

  • onCancel — освобождение ресурсов
  • проверка Task.isCancelled перед resume
  • выбрасывание CancellationError

Checked vs Unsafe continuations

unsafe-continuations:

  • не выполняют runtime-проверки;
  • не предотвращают двойной resume;
  • могут приводить к undefined behavior.

По рекомендациям команды Swift:

unsafe-continuations не должны использоваться в обычных приложениях без строгих причин и профилирования.

На практике checked-continuations почти всегда достаточны.

Практические рекомендации

  • Используйте withCheckedThrowingContinuation по умолчанию
  • Никогда не вызывайте resume более одного раза
  • Не храните continuation вне замыкания
  • Обрабатывайте отмену Task для долгих операций
  • Не оптимизируйте преждевременно, выбирая unsafe

Заключение

Continuations — это ключевой инструмент для постепенной миграции к Swift Concurrency без переписывания всей кодовой базы.

Они позволяют:

  • аккуратно оборачивать legacy API;
  • писать линейный, читаемый async-код;
  • контролировать ошибки и отмену задач.

При корректном использовании continuations делают код предсказуемым, безопасным и поддерживаемым — именно то, что ожидается от современного Swift-проекта.

Начать дискуссию