Um eine Swift-Continuation genau einmal fortzusetzen, müssen Abschluss und Abbruch über dieselbe synchronisierte Zustandsmaschine miteinander konkurrieren. Speichern Sie den Abbruch auch dann, wenn er vor der Installation eintritt, wählen Sie ein einziges Endergebnis aus und geben Sie die Sperre frei, bevor Sie die wartende Aufgabe fortsetzen.

Dieses Tutorial erstellt einen lokalen Adapter von Callback zu Async für ein Mac-Kommandozeilenprogramm. Die synthetische Berechnung verdoppelt eine Ganzzahl; das Beispiel verwendet weder Netzwerkaufrufe noch Datensätze oder Persistenz. Der zentrale Vertrag ist eindeutig: Das erste terminale Ereignis, das den geschützten Zustand ändert, gewinnt.

Was muss der Adapter garantieren?

Eine geprüfte Continuation verbindet synchrone Callbacks mit asynchronem Swift-Code. Apple verlangt, dass jeder Ausführungspfad sie genau einmal fortsetzt. Wird sie nicht fortgesetzt, bleibt die Aufgabe suspendiert; die Laufzeitprüfung hilft dabei, Missbrauch zu erkennen, übernimmt aber nicht die Koordination für Sie. CheckedContinuation

Durch Abbruch kann eine weitere Instanz die Fortsetzung übernehmen. Swift verwendet kooperativen Abbruch: Das Abbrechen einer Aufgabe stoppt weder automatisch einen beliebigen Callback-Vorgang noch führt es dazu, dass eine Continuation einen Fehler auslöst. Der Adapter muss den Abbruch ausdrücklich in das von ihm gewählte Ergebnis umwandeln. Task.cancel()

Für dieses Beispiel gilt folgender Wertevertrag:

  • Eingaben von -1000 bis 1000 ergeben jeweils das Doppelte ihres Werts.
  • Andere Eingaben ergeben CalculationError.outOfRange.
  • Führt der Abbruch den Zustandsübergang durch, ergibt er CancellationError.
  • Sobald ein Endergebnis ausgewählt wurde, können spätere Abschluss- oder Abbruchereignisse es nicht mehr ersetzen.
  • Jeder Aufruf besitzt ein eigenes Gate und installiert eine Continuation.

Das bedeutet, dass der Abschluss gewinnen kann, selbst wenn der Abbruch eintritt, bevor der Aufrufer den Wert erhält. Entscheidend ist der geschützte Zustandsübergang und nicht der Zeitpunkt, zu dem der Aufrufer als Nächstes ausgeführt wird. Dies ist eine für das Beispiel gewählte Anwendungsregel und keine allgemeine Reihenfolgegarantie von Swift.

Warum kann der Abbruch vor der Installation eintreffen?

Apple dokumentiert, dass der Abbruch-Handler vor dem Operation-Closure ausgeführt wird, wenn die Aufgabe bereits abgebrochen ist. Die Operation wird trotzdem ausgeführt. Der Abbruch kann auch gleichzeitig mit einer bereits laufenden Operation eintreten. Daher geht Abbruch verloren, wenn ein Handler lediglich eine optionale Continuation fortsetzt, obwohl diese noch nicht gesetzt wurde. withTaskCancellationHandler(operation:onCancel:isolation:)

Das Gate muss deshalb mehr Informationen enthalten als nur die Frage, ob derzeit eine Continuation vorhanden ist. Eine fehlende Continuation kann bedeuten, dass die Installation noch nicht erfolgt ist, oder dass bereits ein Endergebnis ausgewählt wurde. Diese Fälle erfordern unterschiedliches Verhalten.

Geschützter Zustand Continuation installieren Abschluss oder Abbruch empfangen
Leer Continuation speichern Endergebnis speichern
Wartend Zweite Installation ablehnen Continuation entnehmen und Endergebnis speichern
Abgeschlossen Neu installierte Continuation mit dem gespeicherten Ergebnis fortsetzen Späteres Endereignis ignorieren

Die Tabelle dient auch als Diagnosehilfe. Kann eine Implementierung ein Endergebnis vor der Installation nicht darstellen, sollten Sie den Pfad für bereits abgebrochene Aufgaben prüfen. Können zwei Endereignisse beide die Continuation entnehmen, sollten Sie prüfen, ob Überprüfung und Entnahme in einem kritischen Abschnitt erfolgen.

Ein lokales Beispiel auf dem Mac erstellen

Verwenden Sie eine Swift-6-Toolchain mit dem macOS-SDK. Prüfen Sie die ausgewählte Toolchain in Terminal mit swift --version. Das folgende Programm verwendet nur Foundation und Dispatch und benötigt keine Paketabhängigkeiten. Swift beschreibt in seiner Anleitung für Kommandozeilentools, wie Sie die Installation prüfen und einen asynchronen @main-Einstiegspunkt verwenden. Build a Command-line Tool

Speichern Sie die folgenden drei Swift-Blöcke der Reihe nach in einer Datei namens ContinuationRace.swift. Zusammen bilden sie ein vollständiges Programm. Der erste Block implementiert das Gate, der zweite stellt die Berechnung und den Adapter bereit und der dritte enthält ausführbare Prüfungen. Swift ist in der Forschungsumgebung nicht verfügbar, daher wurde dieses Programm geprüft, hier aber weder kompiliert noch ausgeführt.

Das synchronisierte Gate implementieren

Foundation dokumentiert NSLock als Möglichkeit, einen kritischen Abschnitt zu schützen. Sperren und Entsperren müssen auf demselben Thread erfolgen; die Sperre ist nicht rekursiv. Hier bleibt jeder kritische Abschnitt in einer synchronen Methode, enthält keinen Suspendierungspunkt und endet vor der Fortsetzung der 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()))
    }
}

Das Gate verwendet @unchecked Sendable, weil sein veränderlicher Speicher manuell geschützt wird. Mit dieser Annotation übernimmt die Implementierung die Verantwortung; sie fügt keine Synchronisierung hinzu. Beide veränderlichen Eigenschaften bleiben privat, und jeder Zugriff erfolgt unter derselben Sperre. Apple beschreibt explizit, dass durch Sperren geschützter Zustand die semantischen Anforderungen an nicht überprüfte Sendbarkeit erfüllen kann. Sendable

Beachten Sie, dass finish den Zustand vor dem Entsperren ändert. Ein konkurrierendes Endereignis kann dann nur finished vorfinden, selbst wenn der gewinnende Thread resume noch nicht aufgerufen hat. Der Gewinner hält die entnommene Continuation lokal; kein konkurrierender Aufruf kann sie erneut entnehmen.

Abschluss und Abbruch mit demselben Gate verbinden

Die Callback-Berechnung verwendet eine globale Dispatch-Warteschlange. Apple dokumentiert, dass asynchron eingereichte Arbeit unabhängig vom einreichenden Code fortgesetzt wird und dass Hintergrundarbeit in Dispatch keine garantierte Thread-Identität hat. Daher ist hier eine Thread-unabhängige Koordination angemessen. 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()
    }
}

Erstellen Sie das Gate, bevor Sie den Gültigkeitsbereich des Abbruch-Handlers betreten. So kann eine bereits abgebrochene Aufgabe den Abbruch sofort speichern. Wird die Continuation anschließend installiert, setzt die Installation sie mit diesem gespeicherten Fehler fort und gibt false zurück. Der Adapter überspringt dann den Start der Berechnung.

Der Abbruch kann weiterhin eintreten, nachdem die Installation true zurückgegeben hat und bevor startDoubling aufgerufen wird. In dieser Reihenfolge startet und endet die Berechnung, ihr Ergebnis wird jedoch ignoriert, weil der Abbruch bereits gewonnen hat. Der Wrapper bricht das Warten ab; er verspricht nicht, diese kleine, begrenzte Berechnung zu stoppen.

Die unterstützte Continuation-Brücke ist withCheckedThrowingContinuation; ihr Closure erhält die Continuation, über die ein Wert zurückgegeben oder ein Fehler ausgelöst wird. Im Beispiel werden der ganzzahlige Ergebnistyp und der Fehlertyp der Continuation explizit angegeben, damit die Typinferenz eindeutig bleibt. withCheckedThrowingContinuation(isolation:function:_:)

Ereignisreihenfolgen ohne Wartezeiten prüfen

Eine Zeitverzögerung legt nicht fest, welches Ereignis einen Wettlauf gewinnt. Die folgenden Prüfungen durchlaufen stattdessen alle sechs Reihenfolgen von Installation, Abschluss und Abbruch direkt über das Gate. Jede Sequenz erhält außerdem zusätzliche Endereignisse, um zu prüfen, ob das ausgewählte Ergebnis unverändert bleibt.

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.")
    }
}

Kompilieren und ausführen Sie das Programm aus dem Verzeichnis, das die Datei enthält:

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

Die erwartete Erfolgsausgabe lautet Event-order checks and integration checks passed. Die expliziten Reihenfolgeprüfungen validieren die Zustandsübergänge. Die Integrationsschleife führt den echten Adapter aus, garantiert aber weder, dass eine bestimmte Scheduling-Reihenfolge eintritt, noch dass jede nebenläufige Ausführung geprüft wurde.

Warum setzt die Implementierung die Continuation genau einmal fort?

Betrachten Sie für die höchstens einmalige Fortsetzung den ersten erfolgreichen Aufruf von finish. Er ersetzt den aktuellen Zustand atomar durch finished. Wartet eine Continuation, entnimmt ausschließlich dieser Aufruf sie. Jeder spätere Aufruf von finish kehrt zurück, ohne etwas fortzusetzen. Ist noch keine Continuation vorhanden, wartet das Ergebnis auf die einmalige Installation.

Für die garantierte Fortsetzung gibt es zwei Pfade. Zuerst erfolgt die Installation, und das erste Endereignis setzt die wartende Continuation fort; oder zuerst tritt ein Endereignis ein, und die Installation setzt sie mit dem gespeicherten Ergebnis fort. Diese Argumentation setzt voraus, dass die Installation einmal erfolgt und mindestens ein Endereignis eintritt. Bei einer allgemeinen Callback-Quelle, die nie abgeschlossen wird, und einer Aufgabe, die nie abgebrochen wird, müsste der Aufrufer weiterhin warten.

Der Callback des Beispiels führt eine begrenzte Berechnung aus und ruft den Abschluss sowohl bei Erfolg als auch bei einem Eingabefehler auf. Doppelte End-Callbacks werden nach dem Gewinner absichtlich ignoriert. Das eignet sich für den festgelegten Vertrag mit einem einzigen Ergebnis; ein Strom bedeutsamer Werte benötigt eine andere Schnittstelle.

Welche Abkürzungen verletzen den Vertrag?

Eine optionale Continuation zu prüfen und sie anschließend auf nil zu setzen, reicht nicht aus, wenn diese Aktionen nicht synchronisiert sind. Zwei nebenläufige Aufrufer können beide dieselbe nicht-nil-Continuation lesen, bevor einer von ihnen sie entfernt. Prüfung, Entnahme und Änderung des Endzustands müssen eine einzige geschützte Operation bilden.

Eine Abbruchprüfung vor dem Erstellen der Continuation lässt ebenfalls eine Lücke: Der Abbruch kann unmittelbar nach der Prüfung eintreten. Der Handler und der gespeicherte Endzustand schließen diese Lücke. Swift beschreibt in seinem Sprachleitfaden den Abbruch als kooperativ und nennt Abbruch-Handler als Mechanismus für die sofortige Benachrichtigung. Concurrency — The Swift Programming Language

Eine Fortsetzung unter Sperre der gemeinsam genutzten Sperre bringt ein weiteres Problem mit sich. Apple warnt ausdrücklich, dass Abbruch-Handler ausgeführt werden können, während interne Aufgabensperren gehalten werden, und dass die Fortsetzung einer Continuation diese Sperren erwerben kann. Daher entsperrt das Beispiel vor jedem Aufruf von resume. Cancellation handlers and locks

Schließlich würde das Hinzufügen von Task.checkCancellation() nach der Rückkehr der Continuation den äußeren Vertrag dieses Beispiels ändern: Der Abbruch könnte einen Wert ersetzen, selbst nachdem der Abschluss das Gate gewonnen hat. Legen Sie diese Regel bewusst fest. Beim hier implementierten Vertrag verwenden Installation, Abschluss und Abbruch dieselbe Zustandsmaschine, und der erste terminale Übergang bestimmt das Ergebnis für den Aufrufer.

Quellen