Continuation — Safe and Performant Async Continuations
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 型も扱えるよう、resume と withContinuation が最初から 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 することが型レベルで不可能になります。consumingなresume— 各resumeはselfを consume します。呼び出した時点で continuation はムーブされ、以降同じ束縛は使えません。2 回目のresumeはコンパイルエラーです。deinitでのトラップとdiscard self— resume されないまま continuation が破棄されるとdeinitがfatalErrorを呼び、静かなハングを診断可能なクラッシュに変えます。resume側はdiscard selfでdeinitを抑制するので、正しく使ったときに追加コストはかかりません。
Sendable にも適合しているので、別タスクや別スレッドから resume するためにisolation boundaryを越えて渡せます。value は sending でもあり、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 では withCheckedContinuation と withCheckedThrowingContinuation を別関数として用意していましたが、SE-0413 の typed throws により、Failure: Error でパラメータ化した 1 つの関数が 3 通りのケースを統一的に扱えるようになります。
Failure == Never—throws(Never)は非投げ。awaitにtryは要りません。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 を複数のコールバックに渡し、ライブラリ側の保証で「いずれか一方だけが呼ばれる」ような使い方は、~Copyable な Continuation では静的に表現できません。エスケープするクロージャにキャプチャされた時点で 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) }
}
このようなケースでは、受け取った Continuation を CheckedContinuation や UnsafeContinuation に変換して、動的なチェックに切り替えるのが推奨されます。
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 忘れもコンパイル時エラーに格上げできる可能性があります。実現を約束するものではありませんが、方向性として示されています。