Swift Digest
SE-0528 | Swift Evolution

Continuation — Safe and Performant Async Continuations

Proposal
SE-0528
Authors
Fabian Fett, Konrad Malawski
Review Manager
Joe Groff
Status
Active Review (April 15...28, 2026)

01 何が問題だったのか

SE-0300 で導入された continuation は、コールバックベースの API を async 関数に橋渡しするための標準的な手段です。しかし既存の UnsafeContinuation / CheckedContinuation は、どちらを選んでも痛し痒しの状態でした。

  • UnsafeContinuation — オーバーヘッドはゼロですが、二重 resume は未定義動作、resume し忘れると中断中のタスクが静かにリークしたまま残ります。どちらの誤用もコンパイラからも実行時からも警告が出ません。
  • CheckedContinuation — これらの誤用を検出してくれますが、そのためにアロケーションとアトミック操作というランタイムコストを払っています。

continuation は本質的に ちょうど 1 回だけ使われるべき値(use-exactly-once) であり、これは move-only 型が型システムで強制できる契約そのものです。Swift は型システムでバグを防ぐ伝統を持ち、SE-0390~Copyable やそれに続く一連の Proposal によって non-copyable 型を言語全体で扱える土台は既に整っています。continuation の誤用もここに載せたい、というのが今回の動機です。

加えて、既存の resume(returning:)Success 型がコピー可能であることを要求していました。~Copyable な値(たとえばファイルハンドルのようなリソースを所有する型)を async 関数の戻り値にしたい場合、continuation 経由でその値を返せなかったわけです。non-copyable 型を一級市民として扱うためには、continuation 側もそれを運べる必要があります。

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

標準ライブラリに新しい continuation 型 Continuation<Success, Failure> を追加します。これは ~Copyable な構造体で、move-only セマンティクスと consuming メソッド、そして deinit トラップの組み合わせによって continuation の誤用を型システムと実行時で確実に検出します。ファストパス上はアロケーションもアトミック操作も行いません。あわせて、non-copyable な Success 型も扱えるよう、resumewithContinuation が最初から Success: ~Copyable に一般化されています。

新しい Continuation

@frozen
public struct Continuation<Success: ~Copyable, Failure: Error>: ~Copyable, Sendable {
    deinit {
        fatalError("The continuation was dropped without resuming.")
    }

    @inlinable
    public consuming func resume(returning value: consuming sending Success) { ... }

    @inlinable
    public consuming func resume(throwing error: Failure) { ... }
}

ポイントは次の 3 つです。

  • ~Copyable — continuation 自体をコピーできないため、別の経路から 2 回 resume することが型レベルで不可能になります。
  • consumingresume — 各 resumeself を consume します。呼び出した時点で continuation はムーブされ、以降同じ束縛は使えません。2 回目の resume はコンパイルエラーです。
  • deinit でのトラップと discard self — resume されないまま continuation が破棄されると deinitfatalError を呼び、静かなハングを診断可能なクラッシュに変えます。resume 側は discard selfdeinit を抑制するので、正しく使ったときに追加コストはかかりません。

Sendable にも適合しているので、別タスクや別スレッドから resume するためにisolation boundaryを越えて渡せます。valuesending でもあり、non-Sendable な値を安全に async タスク側へ transfer できます。consuming Success のおかげで non-copyable な値もそのまま resume 時に渡せます。

利便性のために void 版と Result 版のオーバーロードも用意されます。

extension Continuation {
    @inlinable
    public consuming func resume() where Success == Void { ... }

    @inlinable
    public consuming func resume(with result: consuming sending Result<Success, Failure>) { ... }
}

二重 resume — コンパイルエラーになる

actor LegacyBridge {
    var continuation: Continuation<String, Never>?

    func store(_ continuation: consuming Continuation<String, Never>) {
        self.continuation = consume continuation
    }

    func complete(with value: String) {
        if let continuation {
            continuation.resume(returning: value) // OK: consumes continuation

            continuation.resume(returning: value) // compile error:
                                                  // 'continuation' used after consuming use
        }
    }
}

2 回目の resume はコンパイル時に弾かれます。

resume 忘れ — 実行時にトラップ

actor LegacyBridge {
    var continuation: Continuation<String, any Error>?

    func store(_ continuation: consuming Continuation<String, any Error>) {
        self.continuation = consume continuation
    }

    func cancel() {
        // Bug: resume せずにクリアしている
        self.continuation = nil // runtime trap: "The continuation was dropped without resuming."
    }
}

保持していた continuation が resume されずに上書き・破棄されると、その瞬間に deinit が発火して明確なメッセージでトラップします。

suspend するには withContinuation を使う

Continuation 型は直接インスタンス化できません。代わりに withContinuation を使ってタスクを中断しつつ continuation を受け取ります。

public nonisolated(nonsending) func withContinuation<Success: ~Copyable, Failure: Error>(
    of: Success.Type,
    throws: Failure.Type,
    _ body: (consuming Continuation<Success, Failure>) -> Void
) async throws(Failure) -> Success { ... }

public nonisolated(nonsending) func withContinuation<Success: ~Copyable>(
    of: Success.Type,
    _ body: (consuming Continuation<Success, Never>) -> Void
) async -> Success { ... }

of: ラベルで Success を、throws: ラベルで Failure を呼び出し側に明示します。既存の with*Continuation ではクロージャ引数に型注釈を書く必要がありがちでしたが、今回の API では呼び出し位置で型が決まるため、クロージャ側は注釈なしで書けます。

// 従来: クロージャ内で型注釈が必要になりがち
let data = await withCheckedContinuation { (continuation: UnsafeContinuation<Data, Never>) in
    bridge.store(continuation)
}

// 新 API: 呼び出し位置で型が明示される
let data = await withContinuation(of: Data.self) { continuation in
    bridge.store(continuation)
}

typed throws と一本化された throws:

SE-0300 では withCheckedContinuationwithCheckedThrowingContinuation を別関数として用意していましたが、SE-0413 の typed throws により、Failure: Error でパラメータ化した 1 つの関数が 3 通りのケースを統一的に扱えるようになります。

  • Failure == Neverthrows(Never) は非投げ。awaittry は要りません。
  • Failure == MySpecificError — 呼び出し側は try が必要で、投げうるエラー型もコンパイラが把握します。
  • Failure == any Error — 従来の untyped な throws と等価です。
// 非投げ。throws: のデフォルトは Never.self
let data = await withContinuation(of: Data.self) { continuation in
    // ...
}

// typed throws: NetworkError のみ投げうる
let data = try await withContinuation(
    of: Data.self, throws: NetworkError.self
) { continuation in
    // ...
}

// untyped throws: 従来の withCheckedThrowingContinuation 相当
let data = try await withContinuation(
    of: Data.self, throws: (any Error).self
) { continuation in
    // ...
}

CheckedContinuation / UnsafeContinuation は残る

continuation を複数のコールバックに渡し、ライブラリ側の保証で「いずれか一方だけが呼ばれる」ような使い方は、~CopyableContinuation では静的に表現できません。エスケープするクロージャにキャプチャされた時点で consume されたり、複数のクロージャが同じ continuation を consume しようとしたりして、コンパイルエラーになります。

try await withContinuation(of: Int.self, throws: (any Error).self) { c in
    // noncopyable 'c' cannot be consumed when captured by an escaping closure
    // さらに 'c' consumed more than once
    someLib.onSuccess { c.resume(returning: $0) }
    someLib.onFailure { c.resume(throwing: $0) }
}

このようなケースでは、受け取った ContinuationCheckedContinuationUnsafeContinuation に変換して、動的なチェックに切り替えるのが推奨されます。

try await withContinuation(of: Int.self, throws: (any Error).self) { c in
    let checked = CheckedContinuation(c)
    // もしくは: let unsafeCC = UnsafeContinuation(c)
    someLib.onSuccess { checked.resume(returning: $0) } // OK(動的チェック)
    someLib.onFailure { checked.resume(throwing: $0) }
}

この理由から、CheckedContinuation / UnsafeContinuation非推奨にはならず、そのまま残ります

既存コードからの移行

機械的な置き換えで移行できます。

従来
withCheckedContinuation { ... } withContinuation(of: T.self) { ... }
withCheckedThrowingContinuation { ... } withContinuation(of: T.self, throws: (any Error).self) { ... }
CheckedContinuation<T, E> Continuation<T, E>

ただし Continuation~Copyable なので、従来 continuation を暗黙にコピーしていたコード(例: エスケープするクロージャへのキャプチャ)はコンパイルエラーになります。そのような箇所は前節の変換パターンで CheckedContinuation / UnsafeContinuation を使い続けるのが現実的です。

振る舞いの比較

シナリオ UnsafeContinuation CheckedContinuation Continuation
ちょうど 1 回 resume OK OK OK
二重 resume 未定義動作 ランタイムトラップ コンパイルエラー
resume 忘れ 静かにハング ランタイム警告 ランタイムトラップ
ランタイムオーバーヘッド なし アロケーション + アトミック操作 なし

Future Directions(今後の見通し)

Continuation が resume 忘れを検出する方法は、いまのところ deinit でのランタイムトラップです。将来的に ~Copyable 型に「deinit を暗黙に挿入せず、明示的な consume を強制する」モード(仮に ~Discardable のようなもの)が導入されれば、resume 忘れもコンパイル時エラーに格上げできる可能性があります。実現を約束するものではありませんが、方向性として示されています。