Swiftのモデルをアクターに移行する際にアトミックな比較置換を維持するには、比較と代入を1つの同期的なアクター隔離メソッド内にまとめます。呼び出し側ではawaitを使いますが、メソッド自体はチェックと置換の間で中断してはなりません。

この解説は、以前はロックで保護されていた小規模なデスクトップモデルを移行するMac開発者を対象としています。具体的な作業は、現在の値が期待値と一致する場合に限り、合成した図形ラベルを置き換えることです。この例はすべてメモリ内で動作し、ネットワークリクエスト、外部レコード、永続化は使用しません。

モデルを変更する前に値の契約を定義する

この例では、compareAndReplace(expected:replacement:)には3つのルールがあります。現在のラベルをexpectedと比較すること、一致した場合はreplacementを代入してtrueを返すこと、それ以外の場合はラベルを変更せずにfalseを返すことです。比較にはSwiftの値の等価性を使います。標準ライブラリのEquatableドキュメントでは、この比較を==によって定義し、値の等価性とクラスインスタンスの同一性を区別しています。

比較には、操作の実行時点での値を使う必要があります。以前のスナップショットを期待値として使うことはできますが、それだけでは後で置換が成功すべきかどうかは決まりません。その判断は、変更を担う操作の中で行う必要があります。

現在の値 期待値 置換後の値 結果 操作後の値
"Circle" "Circle" "Square" true "Square"
"Square" "Circle" "Triangle" false "Square"
"Square" "Square" "Square" true "Square"

最後の行は意図的なものです。成功とは、比較が一致し、代入が受け入れられたことを意味します。目に見える値が変わったという意味ではありません。移行時にこの戻り値の規約を変えると、契約も変わってしまいます。

ロックを使う実装では、比較と代入の全体を同じ保護されたクリティカルセクション内に置く必要があります。AppleはFoundationのNSLockを、クリティカルセクションを保護してアトミックに実行する方法として説明しています。アクターへの移行でも、この操作境界を維持します。処理の一部を呼び出し側に分散させず、判断全体をアクターに移します。

ここでの「アトミック」とは、このアクター上の別の操作が比較と代入の間に割り込めないことを意味します。この例は、ハードウェアのアトミック命令やロックフリー実行について主張するものではありません。

呼び出し側のawaitが操作を中断しない理由

同期的なアクターメソッドを隔離ドメインの外から呼び出す場合、awaitが必要になることがあります。呼び出し側はアクターに入るまで待つ間、中断する場合があります。メソッドの実行が始まると、通常の同期的な本体には中断ポイントがないため、別のタスクが入って同じアクターの状態を変更することはできません。Swiftはこの違いをSE-0306: アクターで説明しています。

言語ガイドでは、潜在的な中断ポイントを含められないことから、更新処理を同期的なアクターメソッドにカプセル化することを推奨しています。Appleのアクターに関するセッションでも、同期的なアクターコードは、別のタスクにアクター隔離下の実行を中断されることなく完了すると説明されています。この実装は、これらの保証に基づいています。並行処理とSwiftアクターで可変状態を保護するを参照してください。

したがって、適切な境界とは、条件とそれに伴う変更の両方を含む1回のアクター呼び出しです。呼び出し側は先にスナップショットを読み取り、ローカルで計算してから、条件付きの置換を送信できます。アクターは、その送信を処理するときに条件を再度確認する必要があります。

読み取りと書き込みを分けると何が起きるか

移行時に、スナップショット取得メソッドと無条件置換メソッドを別々に公開するとします。ある呼び出し側が"Circle"を読み取ります。別の呼び出し側がラベルを"Square"に変更します。最初の呼び出し側が先ほど読み取った値に基づいて"Triangle"を書き込みます。個々のメソッドがアクター隔離を守っていたとしても、この一連の処理では意図した比較置換の契約を満たせません。

この例示的な実行順序は、特定のタスクスケジューラーに依存せずに問題を説明しています。アクター隔離は各操作を保護しますが、アプリケーションの条件を維持できる大きさの操作を選ぶのはプログラマーの責任です。

Macで完結する例を作成する

インストール済みのSwift 6ツールチェーンを使います。Swift公式のmacOSインストールガイドでは、Swiftlyを使ったインストールとXcodeでの開発方法を説明しています。続行する前に、ターミナルで選択されているコンパイラーを確認してください。

swiftc --version

次の完全なプログラムを作業フォルダーにCompareReplace.swiftとして保存します。明示的なインポートなしで使えるSwift標準ライブラリのみを使用します。このファイルには、アクター、初期状態、すべてのサンプル入力、非同期エントリーポイントが含まれています。

actor ShapeLabelModel {
    private var label: String

    init(initial: String) {
        label = initial
    }

    func snapshot() -> String {
        label
    }

    func compareAndReplace(
        expected: String,
        replacement: String
    ) -> Bool {
        guard label == expected else {
            return false
        }
        label = replacement
        return true
    }
}

@main
struct CompareReplaceDemo {
    static func main() async {
        let model = ShapeLabelModel(initial: "Circle")

        let matched = await model.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        let afterMatch = await model.snapshot()
        precondition(matched && afterMatch == "Square")

        let stale = await model.compareAndReplace(
            expected: "Circle", replacement: "Triangle"
        )
        let afterStale = await model.snapshot()
        precondition(!stale && afterStale == "Square")

        let unchanged = await model.compareAndReplace(
            expected: "Square", replacement: "Square"
        )
        precondition(unchanged)

        let contested = ShapeLabelModel(initial: "Circle")
        async let first = contested.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        async let second = contested.compareAndReplace(
            expected: "Circle", replacement: "Triangle"
        )
        let (firstWon, secondWon) = await (first, second)
        let finalLabel = await contested.snapshot()

        precondition(firstWon != secondWon)
        precondition(
            finalLabel == (firstWon ? "Square" : "Triangle")
        )

        let cycle = ShapeLabelModel(initial: "Circle")
        let oldSnapshot = await cycle.snapshot()
        let movedAway = await cycle.compareAndReplace(
            expected: "Circle", replacement: "Square"
        )
        let movedBack = await cycle.compareAndReplace(
            expected: "Square", replacement: "Circle"
        )
        let matchedAgain = await cycle.compareAndReplace(
            expected: oldSnapshot, replacement: "Triangle"
        )
        let afterCycle = await cycle.snapshot()
        precondition(movedAway && movedBack && matchedAgain)
        precondition(afterCycle == "Triangle")

        print("Matching replacement: \(matched)")
        print("Stale replacement: \(stale)")
        print("Equal replacement: \(unchanged)")
        print("Exactly one competing replacement succeeded.")
        print("A restored value matches the old snapshot.")
    }
}

同じフォルダーからコンパイルして実行します。-parse-as-libraryオプションは明示的な@mainエントリーポイントに対応し、-swift-version 6はSwift 6言語モードを選択します。

swiftc -swift-version 6 -parse-as-library CompareReplace.swift -o compare-demo
./compare-demo

プログラム内のチェックから導かれる期待出力は次のとおりです。

一致する置換: true
古い値に基づく置換: false
同じ値への置換: true
競合する置換のうち、成功したのは1つだけです。
復元された値は古いスナップショットと一致します。

コードはドキュメントと照らし合わせて確認しましたが、この環境にはSwiftコンパイラーがないため実行していません。含まれている事前条件は、実行時にローカルで確認するためのものであり、実行が完了したという主張ではありません。

競合する呼び出しから分かること

2つのasync let宣言は、最初の結果を待たずに子タスクを作成します。この構造化並行処理の使い方は、言語ガイドの非同期呼び出しを並行実行する説明に沿っています。両方の呼び出し側が"Circle"を期待し、それぞれ異なる置換値を提案します。

この設定では、必ず一方だけが成功します。先に実行された方が値を"Circle"以外に変え、もう一方は比較に失敗するためです。どちらが成功するかは問いません。Swiftは、アクターを待つタスクが元の待機順どおりに実行されることを保証していません。この点はSE-0306に記載されています。

この例では、awaitを使う呼び出しとprecondition式も分けています。まず各結果をローカル定数に格納し、その定数を確認します。これにより、非同期処理が同期的なアサーション式の外に置かれます。

最終比較の前に非同期の準備を済ませる

将来のバージョンでは、他の合成したローカル処理を使って、ラベルの候補を非同期に計算することも考えられます。その準備を完了してから、同期的な比較置換メソッドを呼び出してください。元の期待値と計算した置換値を一緒に渡し、falseは却下された試行として処理します。

等価性を確認して準備をawaitし、その後無条件に代入するアクターメソッドには隙間があります。中断中に別のタスクがアクターの状態を変更する可能性があります。再開したメソッドは、すでに成り立たなくなっているかもしれない条件に依存してしまいます。Swiftの文書化されたアクターの再入可能性モデルでは、このような割り込みが明示的に許可されています。

準備処理が非同期アクターメソッドにある場合は、準備の完了後に同期比較メソッドを呼び出します。この最後のメソッドは改めて比較を行う必要があります。中断前のチェックは不要な処理を避けるのに役立ちますが、最終的な代入を許可する根拠にはなりません。

不一致の場合、デスクトップインターフェースに表示する必要があれば候補結果を保持し、新しいスナップショットを取得して、再計算するか競合を報告するかを選びます。期待値を自動的に更新して再試行すると、「自分が観測した値を置き換える」という要求が「新しい値に対して何度も試す」という要求に変わります。この動作を採用するかどうかは、明示的に製品上の判断を行ってください。

古いFoundationロックをawaitをまたいで保持しないでください。Appleは、NSLockをロックしたのと同じスレッドでロック解除するよう求めています。ここで示したアクター設計では、非同期処理をまたいでロックを保持することなく、短い同期的な隔離操作によって必要な結果を得ています。

等価性の意味を保ち、その限界を理解する

循環チェックは、微妙な境界を示しています。呼び出し側が"Circle"をスナップショットとして取得し、モデルが"Square"に変わった後、再び"Circle"に戻ります。比較では変更に至るまでの経緯ではなく現在の値を見るため、古いスナップショットとの比較は成功します。

実際の要件が「スナップショット取得後に受け入れられた変更が一度もない場合に限って置換する」であれば、値の比較だけでは不十分です。対応する設計として、値とリビジョントークンの両方を含むスナップショットを返し、1つの同期的なアクターメソッド内でトークンを確認して値を更新する方法があります。これはより強い、別の契約です。実装する前に、トークンの更新と上限到達時の動作を定義してください。値を維持する移行時に、これを暗黙に導入してはいけません。

より大きなモデルでは、置換に関係するすべてのフィールドを等価性の対象とするスナップショット値を使います。Equatableリファレンスによると、すべての格納プロパティが適合していれば、Swiftは構造体のEquatableを合成できます。関連するフィールドを無視する等価性の実装では、そのフィールドが変更されていても置換が許可されます。

アクター境界を越える値は、並行利用に適したものにしてください。Sendableドキュメントでは、Sendableな値は並行処理ドメイン間で渡せること、またSendableな構造体はSendableなメンバーを含む必要があることを説明しています。任意の可変参照を構造体で包んでも、その性質が保証されるわけではありません。

このローカルモデルでは、可変ラベルをアクターが所有し、すべての条件付き置換が統合された操作を通り、呼び出し側が元の契約に従ってBooleanの結果を解釈すれば、移行は完了です。最後の比較と代入をまとめておき、却下された試行の意味は周囲のデスクトップワークフローで決めます。

出典