Continuations for interfacing async tasks with synchronous code
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 してもかまいません。たとえば Foundation の Operation を async 化するなら、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 の世界をつなぐ細い橋」として使うのが想定された使い方です。