Swift continuation を正確に一度だけ再開するには、完了とキャンセルを、同期された一つの状態機械で競合させます。インストール前のキャンセルも記録し、終端結果を一つ選び、待機中のタスクを再開する前にロックを解放します。

このチュートリアルでは、Mac のコマンドラインプログラム向けに、ローカルなコールバックから async へのアダプターを作成します。例で使う合成計算は整数を 2 倍にするもので、ネットワーク呼び出し、レコード、永続化は使いません。中心となる契約は明確です。保護された状態を最初に変更した終端イベントが勝ちます。

アダプターが保証すべきことは?

チェック付き continuation は、同期コールバックを非同期の Swift コードにつなぎます。Apple は、どの実行経路でも必ず一度だけ再開するよう求めています。再開されないとタスクは中断されたままになります。ランタイムチェックは誤用の特定に役立ちますが、調整処理を実装してくれるわけではありません。CheckedContinuation

キャンセルが発生すると、その再開を担う可能性のある主体がもう一つ加わります。Swift のキャンセルは協調的です。タスクをキャンセルしても、任意のコールバック処理が自動的に停止したり、continuation が自動的にエラーをスローしたりはしません。アダプターは、キャンセルを選択した結果に明示的に変換する必要があります。Task.cancel()

この例での値に関する契約は次のとおりです。

  • -1000 から 1000 までの入力は、その値の 2 倍を返します。
  • それ以外の入力は CalculationError.outOfRange を返します。
  • 状態遷移でキャンセルが勝った場合、キャンセルは CancellationError を返します。
  • 終端結果が一度選ばれた後は、後続の完了イベントやキャンセルイベントで置き換わることはありません。
  • 呼び出しごとに新しいゲートを作成し、continuation を一つインストールします。

つまり、呼び出し元が値を受け取る前にキャンセルが起きても、完了が勝つことがあります。決定のタイミングは、呼び出し元が次に実行される時点ではなく、保護された状態遷移の時点です。これはこの例で選んだアプリケーション上のポリシーであり、Swift が提供する普遍的な順序保証ではありません。

インストール前にキャンセルが届くことがあるのはなぜ?

Apple の説明によると、タスクがすでにキャンセルされている場合、キャンセルハンドラーは operation クロージャーより先に実行されます。それでも operation は実行されます。また、すでに実行中の operation と並行してキャンセルが実行される場合もあります。そのため、任意の continuation を再開するだけのハンドラーでは、その任意値がまだ設定されていない場合にキャンセルを取りこぼします。withTaskCancellationHandler(operation:onCancel:isolation:)

そこでゲートには、continuation が現在存在するかどうかだけでは不十分です。continuation がない状態は、インストール前か、終端結果がすでに選ばれた後のどちらかです。この二つでは必要な動作が異なります。

保護された状態 continuation のインストール 完了またはキャンセルの受信
空 continuation を保存する 終端結果を保存する
待機中 2 回目のインストールを拒否する continuation を取り出して終端結果を保存する
終了済み 保存済みの結果で新しい continuation を再開する 後続の終端イベントを無視する

この表は診断にも役立ちます。実装でインストール前の終端結果を表現できない場合は、すでにキャンセル済みの経路を確認してください。二つの終端イベントが両方とも continuation を取り出せる場合は、確認と削除が一つのクリティカルセクション内で行われているかを確認してください。

Mac でローカルな例を作成する

macOS SDK を含む Swift 6 ツールチェーンを使います。Terminal で swift --version を実行し、選択されているツールチェーンを確認してください。このプログラムは Foundation と Dispatch のみを使い、パッケージ依存関係はありません。Swift のコマンドラインガイドでは、インストールの確認方法と非同期の @main エントリーポイントの使い方が説明されています。Build a Command-line Tool

以下の Swift コードブロックを順番に一つの ContinuationRace.swift というファイルに保存してください。これらを合わせると完全なプログラムになります。最初のブロックでゲートを実装し、2 番目で計算とアダプターを用意し、3 番目で実行可能なチェックを追加します。調査環境では Swift を利用できないため、このプログラムは内容を確認しましたが、コンパイルも実行もしていません。

同期されたゲートを実装する

Apple の説明によると、NSLock はクリティカルセクションを保護するために使えます。ロックとアンロックは同じスレッドで行う必要があり、ロックは再帰的ではありません。ここでは各クリティカルセクションを同期メソッド内に置き、中断点を含めず、continuation を再開する前に終了します。NSLock

import Foundation
import Dispatch

final class IntContinuationGate: @unchecked Sendable {
    private enum State {
        case empty
        case waiting(CheckedContinuation<Int, any Error>)
        case finished(Result<Int, any Error>)
    }

    private let lock = NSLock()
    private var state: State = .empty
    private var didInstall = false

    // Returns false if a terminal event already won.
    func install(
        _ continuation: CheckedContinuation<Int, any Error>
    ) -> Bool {
        lock.lock()
        precondition(!didInstall, "Install exactly once per gate")
        didInstall = true

        switch state {
        case .empty:
            state = .waiting(continuation)
            lock.unlock()
            return true

        case .waiting:
            preconditionFailure("Unexpected second installation")

        case .finished(let result):
            lock.unlock()
            continuation.resume(with: result)
            return false
        }
    }

    func finish(_ result: Result<Int, any Error>) {
        let continuation: CheckedContinuation<Int, any Error>?

        lock.lock()
        switch state {
        case .empty:
            continuation = nil
            state = .finished(result)

        case .waiting(let waiting):
            continuation = waiting
            state = .finished(result)

        case .finished:
            lock.unlock()
            return
        }
        lock.unlock()

        continuation?.resume(with: result)
    }

    func cancel() {
        finish(.failure(CancellationError()))
    }
}

ゲートが @unchecked Sendable を使うのは、可変ストレージを手動で保護しているためです。この注釈は実装側に責任を委ねるものであり、同期を追加するものではありません。どちらの可変プロパティも private のままで、すべてのアクセスが同じロックの下で行われます。Apple は、ロックで保護された状態が unchecked sendability の意味上の要件を満たす方法の一つだと明記しています。Sendable

finish がアンロック前に状態を変更する点に注目してください。これにより、勝者となったスレッドがまだ resume を呼び出していなくても、競合する終端イベントには finished だけが見えるようになります。勝者は取り出した continuation をローカルに保持するため、他の呼び出しが再び取り出すことはできません。

完了とキャンセルを同じゲートにつなぐ

コールバックによる計算では、グローバルな dispatch queue を使います。Apple の説明によると、非同期で送信した処理は送信元のコードとは別に進み、バックグラウンドの dispatch 処理ではスレッド ID が保証されません。そのため、ここでは特定のスレッドに依存しない調整が適しています。DispatchQueue

enum CalculationError: Error {
    case outOfRange
}

func startDoubling(
    _ value: Int,
    completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
    DispatchQueue.global().async {
        guard (-1000...1000).contains(value) else {
            completion(.failure(CalculationError.outOfRange))
            return
        }
        completion(.success(value * 2))
    }
}

func doubled(_ value: Int) async throws -> Int {
    let gate = IntContinuationGate()

    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            guard gate.install(continuation) else { return }

            startDoubling(value) { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.cancel()
    }
}

キャンセルハンドラーのスコープに入る前にゲートを作成します。これにより、すでにキャンセルされたタスクでも、すぐにキャンセルを記録できます。その後 continuation をインストールすると、保存済みのエラーで再開し、false を返すため、アダプターは計算の開始をスキップします。

インストールが true を返した後、startDoubling が呼び出される前にキャンセルされることもあります。この順序では計算が開始され、完了しますが、キャンセルがすでに勝っているため、その結果は無視されます。このラッパーがキャンセルするのは待機であり、この小さく有界な計算の停止を保証するものではありません。

サポートされている continuation ブリッジは withCheckedThrowingContinuation です。そのクロージャーは、値を返すかエラーをスローするために使う continuation を受け取ります。この例では、型推論を明確にするため、continuation の整数の結果型とエラー型を明示しています。withCheckedThrowingContinuation(isolation:function:_:)

スリープに頼らずイベントの順序を確認する

時間を空けても、競合でどちらのイベントが勝ったかは確定しません。以下のチェックでは、インストール、完了、キャンセルの 6 通りの順序をすべてゲートに直接与えて確認します。各シーケンスではさらに終端イベントを追加し、選択された結果が変わらないことも確認します。

enum GateEvent {
    case install
    case complete
    case cancel
}

func checkOrder(
    _ events: [GateEvent],
    expected: Int?
) async throws {
    let gate = IntContinuationGate()

    do {
        let value: Int = try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            for event in events {
                switch event {
                case .install:
                    _ = gate.install(continuation)
                case .complete:
                    gate.finish(.success(42))
                case .cancel:
                    gate.cancel()
                }
            }

            // Neither of these may replace the selected result.
            gate.finish(.success(99))
            gate.cancel()
        }

        guard let expected else {
            preconditionFailure("Expected cancellation")
        }
        precondition(value == expected, "Unexpected value")
    } catch is CancellationError {
        precondition(expected == nil, "Unexpected cancellation")
    }
}

@main
struct ContinuationRaceDemo {
    static func main() async throws {
        try await checkOrder(
            [.install, .complete, .cancel], expected: 42
        )
        try await checkOrder(
            [.install, .cancel, .complete], expected: nil
        )
        try await checkOrder(
            [.complete, .install, .cancel], expected: 42
        )
        try await checkOrder(
            [.complete, .cancel, .install], expected: 42
        )
        try await checkOrder(
            [.cancel, .install, .complete], expected: nil
        )
        try await checkOrder(
            [.cancel, .complete, .install], expected: nil
        )

        let value = try await doubled(21)
        precondition(value == 42)

        do {
            _ = try await doubled(1001)
            preconditionFailure("Expected outOfRange")
        } catch CalculationError.outOfRange {
            // Expected input-domain error.
        }

        // Integration smoke check: either terminal outcome is valid.
        for _ in 0..<100 {
            let task = Task { try await doubled(21) }
            task.cancel()
            do {
                let result = try await task.value
                precondition(result == 42)
            } catch is CancellationError {
                // Cancellation won.
            }
        }

        print("Event-order checks and integration checks passed.")
    }
}

ファイルのあるディレクトリでコンパイルして実行します。

swiftc -swift-version 6 -parse-as-library ContinuationRace.swift -o ContinuationRace
./ContinuationRace

成功時に想定される出力は Event-order checks and integration checks passed. です。明示的な順序チェックでは状態遷移を検証します。統合ループでは実際のアダプターを実行しますが、特定のスケジュール上の順序が発生することを保証するものではなく、すべての並行実行をテストした証明にもなりません。

実装が一度だけ再開するのはなぜ?

再開が最大一度であることを確認するには、まず finish の呼び出しが初めて成功する場合を考えます。この呼び出しは、現在の状態を finished にアトミックに置き換えます。continuation が待機中なら、それを取り出すのはこの呼び出しだけです。後続の finish の呼び出しはすべて何も再開せずに戻ります。まだ continuation がない場合は、結果を単一のインストールまで保持します。

最終的に再開する経路は二つあります。インストールが先なら、最初の終端イベントが待機中の continuation を再開します。終端イベントが先なら、インストール時に保存済みの結果で再開します。この議論は、インストールが一度だけ行われ、少なくとも一つの終端イベントが発生することを前提としています。一度も完了せず、タスクもキャンセルされない一般的なコールバックソースでは、呼び出し元は待機し続けます。

この例のコールバックは有界な計算を行い、成功時と入力エラー時の両方で完了を呼び出します。勝者が決まった後の重複した終端コールバックは意図的に無視します。これは指定された単一結果の契約には適していますが、意味のある値をストリームで扱う場合は別のインターフェースが必要です。

どのような簡略化で契約が破られる?

任意の continuation を確認してから、それを nil に設定するだけでは、同期されていない場合は不十分です。二つの呼び出し元が、どちらかが削除する前に同じ nil 以外の continuation を読み取る可能性があります。確認、取り出し、終端状態への変更は、一つの保護された操作でなければなりません。

continuation を作成する前にキャンセルを確認する方法にも隙間があります。確認の直後にキャンセルが発生する可能性があるためです。ハンドラーと終端状態の記録によって、この隙間を埋めます。Swift の言語ガイドでは、キャンセルは協調的であると説明され、即時通知の仕組みとしてキャンセルハンドラーが挙げられています。Concurrency — The Swift Programming Language

共有ロックを保持したまま再開すると、別の問題が生じます。Apple は、キャンセルハンドラーが内部タスクロックを保持した状態で実行されることがあり、continuation の再開でそれらのロックが取得される可能性があると具体的に警告しています。そのため、この例ではすべての resume 呼び出しの前にアンロックします。Cancellation handlers and locks

最後に、continuation が返った後に Task.checkCancellation() を追加すると、この例の外部向け契約が変わります。ゲートで完了が勝った後でも、キャンセルによって値が置き換わる可能性が出ます。このポリシーは慎重に決めてください。ここで実装している契約では、インストール、完了、キャンセルのすべてが一つの状態機械を使い、最初の終端遷移によって呼び出し元の結果が決まります。

参考資料