Swift Digest
SE-0300 | Swift Evolution

Continuations for interfacing async tasks with synchronous code

Proposal
SE-0300
Authors
John McCall, Joe Groff, Doug Gregor, Konrad Malawski
Review Manager
Ben Cohen
Status
Implemented (Swift 5.5)

01 何が問題だったのか

Swift に async/await が導入されたあとも、世の中の多くの API は依然としてコンプリーションハンドラやデリゲートメソッドといった同期的なコールバックで非同期処理を表現しています。これは、それらの API が async/await 以前から存在していたり、あるいはイベント駆動の仕組み(通知、ネットワーク、GUI など)と本質的に結び付いているためで、今後も完全には無くなりません。

このような既存のコールバックベース API を async 関数の中から使いたい場合、次のような橋渡しが必要になります。

  • async 関数側は、コールバックが呼ばれるまで自身を中断(suspend)しておきたい
  • イベント駆動の同期コード側は、イベントが起きたら中断中の async 関数を再開(resume)したい
  • コールバックに渡された結果値を、async 関数の戻り値としてそのまま返したい

たとえば次のようなコールバックベースの API があったとします。

func beginOperation(completion: (OperationResult) -> Void)

これを async 関数として提供しようと思っても、標準ライブラリに中断と再開をつなぐ仕組みがなければ、素直に書き下す方法がありません。構造化された async/await の世界と、コールバック中心の同期的な世界の間には、明示的なブリッジが必要です。

さらに、この種のブリッジには「コールバックが一度しか呼ばれないとは限らない」「エラーを投げる場合と値を返す場合が混在する」「キャンセルに対応したい」といった実用上の要件もあり、それらを安全かつ素直に書ける API が求められていました。

02 どのように解決されるのか

標準ライブラリに continuation(継続)という概念を導入し、async タスクの中断ポイントを値として取り出せるようにします。取り出した continuation を同期コード側で保持しておき、任意のタイミングで resume を呼べば、中断していた async タスクをその結果値で再開できます。

これにより、コールバックベースの API を async 関数としてラップするための標準的な手段が与えられます。

基本: withUnsafeContinuation

もっとも基本となるのは withUnsafeContinuation と、エラーを投げられる withUnsafeThrowingContinuation です。渡したクロージャ(operation)は即座に現在のコンテキストで実行され、引数として continuation が渡されてきます。operation が return すると async タスクは中断し、その後 continuation の resume が呼ばれるとタスクが再開され、resume に渡した値が withUnsafeContinuation の戻り値になります。

冒頭の beginOperation は、次のようにして async 関数に変換できます。

func operation() async -> OperationResult {
  return await withUnsafeContinuation { continuation in
    beginOperation(completion: { result in
      continuation.resume(returning: result)
    })
  }
}

continuation の API は次の形です(概略)。

struct UnsafeContinuation<T, E: Error> {
  func resume(returning: T)
  func resume(throwing: E)
  func resume(with result: Result<T, E>)
}

// Void を返す continuation では resume() だけで済む
extension UnsafeContinuation where T == Void {
  func resume()
}

func withUnsafeContinuation<T>(
    _ operation: (UnsafeContinuation<T, Never>) -> ()
) async -> T

func withUnsafeThrowingContinuation<T>(
    _ operation: (UnsafeContinuation<T, Error>) throws -> ()
) async throws -> T

withUnsafeThrowingContinuation の operation 内で投げられた未捕捉のエラーは、resume(throwing:) が呼ばれたのと同じ扱いになります。

exactly-once ルール

どのバリアントでも共通する重要な規約として、continuation の resume はタスクごとに ちょうど 1 回 呼ばなければなりません。

  • 2 回以上 resume すると、Unsafe*Continuation では未定義動作になります。
  • 一度も resume せずに continuation を捨てると、タスクは中断したまま残り、保持しているリソースがリークします。

つまり、複雑なコールバック(「成功で 1 回」「途中で複数回、最後に終了通知が 1 回」「失敗で 1 回」など)をラップするときは、どのパスを通っても最終的に resume が一度だけ呼ばれるように設計する責任が呼び出し側にあります。以下は、やや作為的ながらその典型例です。

func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
  try await withUnsafeThrowingContinuation { continuation in
    var veggies: [Vegetable] = []

    buyVegetables(
      shoppingList: shoppingList,
      onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
      onGotVegetable: { v in veggies.append(v) },
      onNoMoreVegetables: { continuation.resume(returning: veggies) },
      onNoVegetablesInStore: { error in continuation.resume(throwing: error) }
    )
  }
}

安全版: withCheckedContinuation

Unsafe*Continuation はオーバーヘッドが小さい代わりに、誤用するとプロセスが壊れるほど危険です。そこで標準ライブラリは、API 形状がまったく同じチェック付きの CheckedContinuation も提供します。

func withCheckedContinuation<T>(
    _ operation: (CheckedContinuation<T, Never>) -> ()
) async -> T

func withCheckedThrowingContinuation<T>(
  _ operation: (CheckedContinuation<T, Error>) throws -> ()
) async throws -> T
  • 同じ continuation を 2 回以上 resume しようとすると トラップ します(未定義動作ではなく、確実にクラッシュとして現れます)。
  • resume されないまま continuation が破棄された場合は 警告ログ を出します(この検出はクラスの deinit に依存するため、トラップではなく警告という扱いになっています)。

これらのチェックは最適化レベルに依存しません。API 形状が Unsafe 版と同一なので、実装を書くときはまず withCheckedThrowingContinuation で検証し、性能が必要な箇所だけ呼び出しを withUnsafeThrowingContinuation に差し替える、という流れで使えます。

func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
  try await withCheckedThrowingContinuation { continuation in
    // 中身は Unsafe 版とまったく同じ
    ...
  }
}

一般的には、まず CheckedContinuation を使い、プロファイルで支配的なコストだと分かった場合にだけ UnsafeContinuation へ切り替えるのが推奨されます。

コールバック以外のブリッジ

continuation は「コールバック 1 つを async に変換する」ためだけのものではありません。exactly-once さえ守れば、どこから resume してもかまいません。たとえば FoundationOperationasync 化するなら、finish() の中で resume するといった使い方ができます。

class MyOperation: Operation {
  let continuation: UnsafeContinuation<OperationResult, Never>
  var result: OperationResult?

  init(continuation: UnsafeContinuation<OperationResult, Never>) {
    self.continuation = continuation
  }

  override func finish() {
    continuation.resume(returning: result!)
  }
}

func doOperation() async -> OperationResult {
  return await withUnsafeContinuation { continuation in
    MyOperation(continuation: continuation).start()
  }
}

キャンセルとの組み合わせ

構造化された並行性の Task.withCancellationHandler と組み合わせれば、Task のキャンセルに応じて基盤 API をキャンセルしつつ、結果は continuation で受け取る、といった実用的なラッパも書けます。

func download(url: URL) async throws -> Data? {
  var urlSessionTask: URLSessionTask?

  return try Task.withCancellationHandler {
    urlSessionTask?.cancel()
  } operation: {
    let result: Data? = try await withUnsafeThrowingContinuation { continuation in
      urlSessionTask = URLSession.shared.dataTask(with: url) { data, _, error in
        if case (let cancelled as NSURLErrorCancelled)? = error {
          continuation.resume(returning: nil)
        } else if let error {
          continuation.resume(throwing: error)
        } else {
          continuation.resume(returning: data)
        }
      }
      urlSessionTask?.resume()
    }
    if let result {
      return result
    } else {
      Task.cancel()
      return nil
    }
  }
}

使い分けのまとめ

  • 既存のコールバック/デリゲート/イベント駆動 API を async として公開したいときに使います。
  • 基本は withCheckedContinuation / withCheckedThrowingContinuation を使い、誤用を早期に検出します。
  • 性能が重要でかつ使い方が正しいと確認できた箇所だけ withUnsafeContinuation / withUnsafeThrowingContinuation に切り替えます。
  • どのバリアントでも resume はちょうど 1 回。これだけは呼び出し側の責任として守る必要があります。

continuation は async の中断ポイント 1 回分だけを表す軽量な仕掛けで、Task のようなライフタイム全体を制御する存在ではありません。タスク間の調停は基本的に構造化された並行性の仕組み側に任せ、continuation は「async と非 async の世界をつなぐ細い橋」として使うのが想定された使い方です。