クラウド Mac で検証する Swift のコールバック非同期変換

クラウド Mac で検証する Swift のコールバック非同期変換

クラウド Mac で検証する Swift のコールバック非同期変換

旧 SDK の完了コールバックはローカルのテストでは問題なく動いていたのに、クラウド Mac に移して並列実行すると、まれに continuation が二重に再開されたり、タスクをキャンセルしても結果が返ってこなくなったりします。多くの場合、原因はマシンの性能ではありません。アダプターが「コールバックは一度しか呼ばれない」「必ず非同期で呼ばれる」という前提に依存していることが問題です。ここでは、その二つの前提を取り除き、実際のネットワークやタイミング任せの待機に頼らず、単独で実行できる Swift プログラムで境界条件を検証します。

最終結果を決めるルールを先に定義する

ここで扱うのは、一度だけ結果を返す操作です。成功・失敗・キャンセルのうち、状態マシンに最初に確定された結果を採用します。それ以降の通知は結果を変更せず、待機側を再び再開することもできません。ストリーミングや、複数回の更新を通知するインターフェースには、この契約は適用できません。

保持すべき状態は、continuation がまだ登録されていない状態と、最終結果がすでに確定している状態の二つです。キャンセルは continuation の登録より先に発生する場合があるため、単一の Optional 変数だけではタスクが終了したかどうかを判断できません。

checked continuation は誤った使い方を検出しますが、重複通知を排除してくれるわけではありません。また、タスクのキャンセルを元の操作のキャンセルに自動で変換することもありません。

採用するのは「先に確定した結果を優先する」というルールであり、「常にキャンセルを優先する」というルールではありません。成功がすでに確定していれば、再開後の処理がまだ実行されていなくても、後から届いたキャンセルで成功を上書きすることはできません。業務要件としてキャンセルにもっと強い優先権を持たせるなら、結果を確定するタイミングを別途定義する必要があります。戻り値の前後に場当たり的なチェックを追加するべきではありません。

短いクリティカルセクションで完了状態を保護する

次のコードを BridgeCheck.swift として保存してください。このサンプルは Swift 6 と、NSLock.withLock を提供する macOS SDK を使用します。ロックで保護するのは状態の読み書きだけです。外部コールバックの呼び出し、リクエストの開始、continuation の再開はロックの外で行います。

import Foundation

final class CompletionGate<Value: Sendable>: @unchecked Sendable {
    private let lock = NSLock()
    private var outcome: Result<Value, any Error>?
    private var waiter: CheckedContinuation<Value, any Error>?

    func install(_ continuation: CheckedContinuation<Value, any Error>) {
        let ready: Result<Value, any Error>? = lock.withLock {
            if let outcome {
                return outcome
            }
            precondition(waiter == nil)
            waiter = continuation
            return nil
        }
        if let ready {
            continuation.resume(with: ready)
        }
    }

    func finish(_ result: Result<Value, any Error>) {
        let continuation: CheckedContinuation<Value, any Error>? =
            lock.withLock {
                guard outcome == nil else {
                    return nil
                }
                outcome = result
                let current = waiter
                waiter = nil
                return current
            }
        continuation?.resume(with: result)
    }
}

func bridge(
    start: @Sendable (
        @escaping @Sendable (Result<Int, any Error>) -> Void
    ) -> Void
) async throws -> Int {
    let gate = CompletionGate<Int>()
    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            gate.install(continuation)
            start { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.finish(.failure(CancellationError()))
    }
}

@unchecked Sendable は、チェックを無効にして後は気にしなくてよい、という意味ではありません。共有される可変フィールドをすべて同じロックで保護し、結果の値が Sendable を満たすことを、保守する側が保証する宣言です。今後カウンターやハンドルを追加する際も、その同期方法を確認する必要があります。

登録と完了の順序は二通りだけです。登録が先なら、完了時に待機側の continuation を取り出して再開します。完了が先なら、結果を保存し、continuation が登録された時点で再開します。待機側を保持するフィールドを空にすれば参照を速やかに解放できますが、終了したかどうかを判断する基準は、あくまで結果の状態です。フィールドが空になったことを、再び完了を受け付けられる状態に戻ったと解釈してはいけません。

決定的なサンプルで重要な実行順序を検証する

次のエントリーポイントを同じファイルに追加してください。最初のケースでは、意図的にコールバックを同期的に二度呼び出します。二つ目では、コールバックを一切呼ばない操作を開始してから、待機しているタスクをキャンセルします。どちらも、スリープを挟んでスケジューリングのタイミングを推測する方法は使っていません。

@main
struct BridgeCheck {
    static func main() async throws {
        let value = try await bridge { complete in
            complete(.success(7))
            complete(.success(9))
        }
        precondition(value == 7)

        let pending = Task {
            try await bridge { _ in }
        }
        pending.cancel()

        do {
            _ = try await pending.value
            preconditionFailure("Expected cancellation")
        } catch is CancellationError {
        }
    }
}
xcrun swiftc -parse-as-library -swift-version 6 \
  BridgeCheck.swift -o BridgeCheck
./BridgeCheck

正常に動作すれば、終了コードはゼロになります。同期完了のケースでは、「先に操作を開始し、後から continuation を保存する」という誤った実装を検出できます。重複通知のケースでは、保護されていない二度目の再開を検出できます。キャンセルのケースでは、コールバックが来なくても待機側が終了できるかを確認します。

プロジェクトのテストに組み込んだら、状態マシンに対して「完了が先、登録が後」の順序も直接テストしてください。さらに、成功の後に失敗が届く場合と、失敗の後に成功が届く場合をそれぞれ実行します。並行実行の負荷テストでカバレッジを補うことはできますが、実行順序を明示したこれらのテストの代わりにはなりません。

待機のキャンセルと元の操作の停止は別である

このアダプターが保証するのは待機側の終了であり、元の処理の停止ではありません。キャンセルが先に発生しても start が実行される可能性はあり、その後の通知は無視されます。これは意図的に設けた責務の境界です。このサンプルを、そのまま完全なネットワーククライアントとして扱ってはいけません。

キャンセル用ハンドルがある場合の拡張方法

実際のインターフェースがキャンセル用ハンドルを返すなら、同じ保護対象の状態にハンドルを登録する必要があります。キャンセルが先に来た場合はフラグを記録し、後からハンドルが返された時点ですぐに取り出してキャンセルします。ハンドルが先に得られた場合は、キャンセル処理側で取り出します。実際のキャンセル呼び出しは、引き続きロックの外で行います。キャンセルによって完了コールバックが同期的に呼ばれる可能性があるためです。

開始前に一度だけキャンセル状態を確認する方法では不十分です。チェックを通過してからハンドルが返るまでの間にも競合が残ります。また、ロックを保持したままキャンセル API を呼び出してはいけません。同期コールバックが状態マシンに再入すると、デッドロックするおそれがあります。

元の操作が一切通知せず、呼び出し側もキャンセルしない場合、待機は続きます。業務上必要なタイムアウトも同じ完了処理の入口を通し、元の操作が使用するリソースの解放も併せて扱う必要があります。continuation 自体にタイムアウト機能が備わっていると考えてはいけません。

クラウドでの実行手順に検証を組み込む

クラウド Mac のビルド前処理でこの検証プログラムをコンパイルして実行し、コンパイラのバージョン、コミット識別子、終了コードを記録します。サンプルに GUI セッションは不要なので、コマンドラインからの反復実行に適しています。

マージ前には、呼び出しごとに独立した状態マシンを使っていること、同期コールバックを取りこぼさないこと、重複通知で二重に再開しないこと、キャンセル後に待機側が取り残されないことの四点を確認してください。ハンドルを持つ実際のインターフェースには、「ハンドルが返る前にキャンセルされる」ケースの専用テストも追加します。

最後に、クロージャーが何を保持しているかを確認します。待機が終わっていても、元の操作が完了クロージャーを保持し続けている可能性があります。状態マシンによる重複排除だけでは、ライフサイクルに関するすべての問題は解決できません。完了の契約、停止の契約、リソースの解放をそれぞれ個別に検証して初めて、非同期ラッパーとしての対応が完結します。

よくある質問

checked continuation は二度目の通知を無視しますか?

無視しません。二重 resume は契約違反です。ロックで保護した状態管理で結果を一つに確定し、ロックを解放してから continuation を再開します。

タスクをキャンセルすると元のリクエストも止まりますか?

自動では止まりません。このアダプターは待機側をキャンセルエラーで終了させます。元の操作には専用の停止処理と、ハンドル取得前のキャンセル対策が必要です。

次のビルドは専用クラウドMacで

MacFTPでは、macOSをネイティブ実行するApple Silicon搭載の専用物理マシンをご利用いただけます。日・週・月・四半期からレンタル期間をお選びください。まず構成を確認し、お使いのツールチェーンをノードに接続します。実際の稼働状況はコントロールパネルにリアルタイムで表示されます。

クラウドMacを今すぐレンタル

記事からプロジェクトへ

構成を確認して、納品の準備を

モデルとレンタル期間を比較するか、ブログで技術情報を引き続きご覧ください。