Damit eine Swift-Berechnung abgebrochen werden kann, bewahren Sie ihren Task-Handle auf, rufen Sie bei Bedarf cancel() auf und prüfen Sie innerhalb der Schleife, ob ein Abbruch angefordert wurde. Dieses macOS-Beispiel hält außerdem die Benutzeroberfläche reaktionsfähig und verhindert, dass ein abgebrochener Durchlauf ein verspätetes Ergebnis anzeigt.

Was bewirkt das Abbrechen eines Swift-Tasks tatsächlich?

Swift verwendet kooperative Abbrüche: Eine Abbruchanforderung markiert einen Task, während der laufende Code selbst entscheidet, wann er stoppt. Eine CPU-Schleife, die den Abbruchstatus nie prüft, kann bis zum Ende weiterlaufen. Task.checkCancellation() löst CancellationError aus, sobald der Abbruch erkannt wird. Apples Dokumentation zu Abbrüchen, checkCancellation().

Die Aufgabe hier ist konkret: Fügen Sie einer kleinen Mac-App funktionierende Schaltflächen Start und Cancel hinzu, die eine synthetische Summe berechnet. Sämtliche Eingaben werden lokal erzeugt. Die Berechnung addiert die Quadratwurzeln aufeinanderfolgender positiver Ganzzahlen und liefert erst dann einen Double-Wert, wenn alle angeforderten Terme verarbeitet wurden.

Legen Sie den Ergebnisvertrag fest, bevor Sie den Abbruch implementieren. Dieses Beispiel verwirft eine unvollständige Summe und meldet den Abbruch separat. Für die Rückgabe einer Teilsumme wäre ein anderer Vertrag erforderlich, der angibt, wie viele Terme berücksichtigt wurden. Andernfalls könnte ein Aufrufer unvollständige Arbeit mit dem angeforderten Ergebnis verwechseln. Der Swift-Sprachleitfaden beschreibt das Auslösen eines Fehlers, die Rückgabe eines leeren Ergebnisses und die Rückgabe unvollständiger Arbeit als mögliche Reaktionen auf einen Abbruch. Concurrency.

Es gibt zwei Aufgaben: unnötige Berechnungen stoppen und entscheiden, ob ein abgeschlossener Wert noch in die Benutzeroberfläche gehört. Das Beispiel erledigt beides, auch wenn der Abbruch angefordert wird, nachdem die letzte Prüfung im Worker bereits erfolgt ist, aber bevor sein Ergebnis angezeigt wird.

Abbruchprüfungen in die Berechnung einbauen

Erstellen Sie in Xcode ein macOS-App-Projekt mit SwiftUI und Swift. Verwenden Sie für dieses Beispiel den Swift-6-Sprachmodus und ein Deployment-Ziel ab macOS 13. Fügen Sie die folgende vollständige Datei namens SyntheticCalculation.swift hinzu. Sie ist nicht von der Ansicht abhängig.

// SyntheticCalculation.swift
import Swift

enum SyntheticCalculation {
    nonisolated static func sumOfSquareRoots(
        terms: Int,
        checkEvery: Int = 4_096
    ) throws -> Double {
        precondition(terms >= 0, "terms must be nonnegative")
        precondition(checkEvery > 0, "checkEvery must be positive")

        try Task.checkCancellation()

        var completed = 0
        var total = 0.0

        while completed < terms {
            try Task.checkCancellation()

            let count = min(checkEvery, terms - completed)
            let end = completed + count

            while completed < end {
                completed += 1
                total += Double(completed).squareRoot()
            }
        }

        try Task.checkCancellation()
        return total
    }
}

Die Funktion prüft den Abbruchstatus vor dem Start, an jeder Chunk-Grenze und vor der Rückgabe. Das sind gezielte Haltepunkte: Wird während eines Chunks ein Abbruch angefordert, wird er bei der nächsten Prüfung erkannt. Task.checkCancellation() ist synchron, daher kann die Berechnung eine gewöhnliche Funktion bleiben, die Fehler auslöst. Allein zum Prüfen des Abbruchstatus benötigt sie kein await. checkCancellation().

Die Chunk-Berechnung hält außerdem die Eingabegrenzen klar ein. Bei null Termen ergibt sich für einen nicht abgebrochenen Task null. Negative Termzahlen und eine nicht positive Chunk-Größe sind Programmierfehler, die durch Vorbedingungen abgefangen werden. Die Berechnung jedes Chunks anhand der verbleibenden Anzahl verhindert, dass sein Endpunkt über terms hinausgeht.

Der Akkumulator bleibt lokal im Worker. Ein ausgelöster Abbruchfehler beendet die Funktion, ohne den Akkumulator zurückzugeben. So wird der gewählte Vertrag für vollständige Ergebnisse umgesetzt. Da es sich um Gleitkommaaddition handelt, ist der abgeschlossene Wert eine Näherung der mathematischen Summe.

Die Cancel-Schaltfläche reaktionsfähig halten

Abbruchprüfungen helfen einer Schaltflächenaktion nicht, wenn sie keine Gelegenheit bekommt auszuführen. Apple erklärt, dass umfangreiche synchrone Arbeit in einem einfachen Task, der in einer SwiftUI-Ansicht erstellt wird, weiterhin den Main Actor belegen kann, weil der Task den umgebenden Actor-Kontext übernimmt. Apple beschreibt einen Detached Task als unterstützte Möglichkeit, synchrone Arbeit außerhalb dieses übernommenen Kontexts auszuführen. App-Reaktionsfähigkeit verbessern.

Hier erfasst der Detached Worker nur eine unveränderliche Ganzzahl und ruft eine ausdrücklich nonisolated deklarierte synchrone Funktion auf. Der gesamte Zustand der Benutzeroberfläche bleibt auf dem Main Actor. Ein separater Task auf dem Main Actor wartet auf den Wert des Workers und aktualisiert die Anzeige.

Ersetzen Sie die generierte App-Eintragsdatei durch die folgende vollständige Datei und entfernen Sie die generierte ContentView.swift, falls sie noch vorhanden ist. Im Target darf es genau eine @main-Deklaration geben, wie es der App-Lebenszyklus von SwiftUI erfordert. Zusammen bilden diese beiden Dateien die Beispiel-App. App.

// CancellationDemoApp.swift
import Foundation
import SwiftUI

@main
struct CancellationDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CalculationView()
        }
    }
}

@MainActor
struct CalculationView: View {
    @State private var worker: Task<Double, Error>?
    @State private var runID: UUID?
    @State private var status = "Ready"

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic square-root sum")
                .font(.headline)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Start") {
                    start()
                }
                .disabled(worker != nil)

                Button("Cancel") {
                    guard let worker else { return }
                    worker.cancel()
                    status = "Cancellation requested"
                }
                .disabled(worker == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 420, minHeight: 160)
        .onDisappear {
            worker?.cancel()
            runID = nil
            worker = nil
            status = "Ready"
        }
    }

    private func start() {
        guard worker == nil else { return }

        let id = UUID()
        let terms = 500_000_000
        runID = id
        status = "Calculating"

        let run = Task.detached(priority: .userInitiated) {
            try SyntheticCalculation.sumOfSquareRoots(terms: terms)
        }
        worker = run

        Task { @MainActor in
            do {
                let value = try await run.value
                guard runID == id else { return }

                if run.isCancelled {
                    status = "Cancelled"
                } else {
                    status = "Result: \(value)"
                }
            } catch is CancellationError {
                guard runID == id else { return }
                status = "Cancelled"
            } catch {
                guard runID == id else { return }
                status = "Failed: \(error)"
            }

            guard runID == id else { return }
            worker = nil
            runID = nil
        }
    }
}

worker aufzubewahren ist entscheidend. Wenn Sie einen Task-Handle verwerfen, wird der Task dadurch nicht abgebrochen; Sie verlieren lediglich die Möglichkeit, ihn über diesen Handle abzubrechen. Durch das Warten auf den Wert des fehlerwerfenden Workers mit value wird außerdem sein Fehler erfasst. Detached Tasks erfordern eine explizite Verwaltung ihres Lebenszyklus. Deshalb brechen die Schaltfläche und der Handler für das Verschwinden der Ansicht den Worker direkt ab. Nur den Task abzubrechen, der auf sein Ergebnis wartet, gewährleistet keine Weitergabe des Abbruchs an diesen Detached Worker. Dokumentation zu Detached Tasks.

Foundation’s UUID liefert eine Kennung für jeden Durchlauf. Der Kennungsvergleich ist eine Anwendungsregel: Sobald das Verschwinden der Ansicht einen Durchlauf ungültig macht, kann dessen späterer Abschluss den aktuellen Status dieser Ansicht nicht mehr aktualisieren. Die Kennung stoppt die Berechnung nicht; dafür sorgen worker.cancel() und die Prüfungen in der Schleife. UUID.

Die Schaltfläche Start bleibt deaktiviert, bis der Worker abgeschlossen ist oder einen Fehler auslöst. Wenn Sie Cancel drücken, wird zunächst Cancellation requested angezeigt. Der Completion-Observer ändert diesen Status bei der Verarbeitung des Ergebnisses in Cancelled. So bleibt eine Abbruchanforderung klar von der Bestätigung getrennt, dass der Worker beendet ist.

Wie sollte ein verspätetes Ergebnis behandelt werden?

Betrachten Sie folgende Abfolge: Der Worker besteht seine letzte Prüfung und wird fertig. Anschließend drückt der Benutzer Cancel, bevor der Main Actor seinen Wert übernimmt. Apple weist ausdrücklich darauf hin, dass ein Abbruch nach der letzten möglichen Prüfung möglicherweise keine erkennbare Wirkung auf den Worker hat. Eine abschließende Prüfung verkleinert dieses Zeitfenster, kann es aber nicht beseitigen. cancel().

Deshalb prüft das Beispiel nach dem Warten auf einen erfolgreichen Wert run.isCancelled. Dabei wird der Handle des Workers geprüft. Die statische Eigenschaft Task.isCancelled würde an dieser Stelle stattdessen den Observer-Task prüfen. Der Swift-Sprachleitfaden unterscheidet zwischen der Prüfung des aktuellen Tasks über die Typeigenschaft und der Prüfung eines anderen Tasks über seine Instanzeigenschaft. Concurrency.

Damit gilt eine konkrete Annahmeregel: Ein Abbruch, der vor der Übernahme des Ergebnisses verarbeitet wird, hat Vorrang. Sobald die Übernahme abgeschlossen ist und der Worker freigegeben wurde, gibt es für die deaktivierte Schaltfläche Cancel kein abgeschlossenes Ergebnis mehr, das sie zurücknehmen könnte. Die Regel folgt der Reihenfolge der Aktionen auf dem Main Actor und verspricht nicht, dass ein Abbruch bereits abgeschlossene Arbeit rückgängig macht.

Ein geeignetes Intervall für Abbruchprüfungen wählen

Die Standard-Chunk-Größe von 4,096 Termen ist ein beispielhafter Ausgangspunkt. Kleinere Chunks bieten häufiger Gelegenheit, einen Abbruch zu erkennen, und führen zu mehr Prüfungen. Größere Chunks verringern die Häufigkeit der Prüfungen, lassen zwischen den Haltepunkten aber mehr Berechnung zu.

Nehmen Sie als grobe Planungsrechnung an, ein Chunk enthält 4,096 Terme und jeder Term dauert beispielhaft zwei Mikrosekunden. Dieser Chunk entspricht etwa 8.2 Millisekunden Berechnung. Das sind erfundene Werte, die die Rechnung veranschaulichen sollen, keine Messwerte für Swift oder einen bestimmten Mac. Die Zeitplanung und Aktualisierungen der Benutzeroberfläche können weitere Verzögerungen verursachen.

Beobachtung Was prüfen oder ändern
Die Benutzeroberfläche reagiert während der Berechnung nicht mehr Prüfen Sie, ob die Schleife im Detached Worker läuft und außerhalb der Main-Actor-Isolation bleibt.
Die Benutzeroberfläche reagiert, aber der Abbruch dauert zu lange Verringern Sie checkEvery und messen Sie die längste Chunk-Dauer.
Ein Chunk erhält später eine zeitaufwendige Operation Fügen Sie Haltepunkte innerhalb dieser Operation ein, sofern ihre Implementierung das unterstützt.
Ein abgebrochener Durchlauf zeigt ein Ergebnis an Prüfen Sie den Worker-Handle zum Zeitpunkt der Übernahme und behalten Sie die Durchlauf-Kennung bei.

Fügen Sie keinen Abbruch-Handler hinzu, nur um für diese Schleife ein weiteres Flag zu setzen. Der Task stellt den Abbruchstatus bereits bereit, und der Swift-Sprachleitfaden erklärt, dass auch mit einem installierten Handler das Stoppen weiterhin kooperativ bleibt. Für diese lokale Berechnung bieten explizite Prüfungen den erforderlichen Mechanismus zum Anhalten. Concurrency.

Abschluss, Abbruch und wiederholte Durchläufe prüfen

In der Forschungsumgebung waren kein Swift-Compiler und keine macOS-Laufzeitumgebung verfügbar. Daher wurde dieses Beispiel geprüft, aber nicht kompiliert oder ausgeführt. Erstellen Sie beide Dateien in Xcode und überprüfen Sie dann die folgenden unterschiedlichen Verhaltensweisen:

  1. Kleines, abgeschlossenes Ergebnis: Ändern Sie terms vorübergehend in 4. Das angezeigte Ergebnis sollte ungefähr 6.146264369941973 betragen, abgesehen von Rundungsabweichungen bei Gleitkommazahlen.
  2. Leere Eingabe: Ändern Sie terms in 0. Ein nicht abgebrochener Durchlauf sollte null anzeigen.
  3. Abbruch während der Berechnung: Stellen Sie 500_000_000 wieder her, drücken Sie Start und anschließend während der Berechnung Cancel. Es sollte Cancellation requested und danach Cancelled angezeigt werden, ohne dass ein Teilergebnis erscheint.
  4. Weiterer Durchlauf: Drücken Sie nach Abschluss des Abbruchs erneut Start. Der neue Durchlauf sollte normal beginnen.
  5. Ansicht verschwindet: Schließen Sie ein Fenster, während gerade berechnet wird. Der Handler für das Verschwinden der Ansicht sollte den Abbruch anfordern und Aktualisierungen der Benutzeroberfläche für diesen Durchlauf ungültig machen.

Die hohe Termzahl soll genug Zeit für eine Interaktion lassen, aber die Dauer hängt vom Mac und der Build-Konfiguration ab. Falls die Berechnung abgeschlossen ist, bevor Sie abbrechen können, erhöhen Sie die Zahl innerhalb des unterstützten Ganzzahlbereichs. Überprüfen Sie abgeschlossene Berechnungen anhand der kleinen Eingabe, statt auf das Ende der großen Demonstration zu warten.

Das fertige Design hat einen klaren Abbruchpfad und eine eindeutige Annahmeregel: Die Schaltfläche bricht den gespeicherten Worker ab, die Chunk-Grenzen ermöglichen es der Berechnung, einen Fehler auszulösen, und der Main Actor übernimmt nur ein nicht abgebrochenes Ergebnis aus dem aktuellen Durchlauf. Mit diesen drei Entscheidungen wird der Abbruch für eine gewöhnliche Mac-Berechnung nützlich.

Quellen