Aby umożliwić anulowanie obliczeń w Swift, zachowaj uchwyt zadania, wywołaj cancel() na żądanie i sprawdzaj stan anulowania wewnątrz pętli. Ten przykład dla macOS utrzymuje też responsywność interfejsu i zapobiega wyświetleniu spóźnionego wyniku po anulowaniu zadania.

Co właściwie robi anulowanie zadania Swift?

Swift używa anulowania kooperacyjnego: żądanie anulowania oznacza zadanie jako anulowane, a kod, który je wykonuje, decyduje, kiedy się zatrzymać. Pętla obciążająca procesor, która nigdy nie sprawdza stanu anulowania, może działać aż do końca. Wywołanie Task.checkCancellation() zgłasza CancellationError, gdy anulowanie zostanie wykryte. Dokumentacja Apple dotycząca anulowania, checkCancellation().

Cel tego przykładu jest konkretny: dodać do małej aplikacji na Maca działające przyciski Start i Cancel, które służą do obliczania sztucznej sumy. Wszystkie dane wejściowe są generowane lokalnie. Obliczenie sumuje pierwiastki kwadratowe kolejnych dodatnich liczb całkowitych i zwraca jedną wartość Double dopiero po przetworzeniu wszystkich żądanych składników.

Przed wdrożeniem anulowania zdecyduj, jak ma wyglądać kontrakt wyniku. Ten przykład odrzuca niedokończoną sumę i osobno sygnalizuje anulowanie. Zwracanie sumy częściowej wymagałoby innego kontraktu, który określa, ile składników uwzględniono; w przeciwnym razie wywołujący mógłby uznać niepełne obliczenie za żądany wynik. Przewodnik języka Swift opisuje zgłaszanie błędu, zwracanie pustego wyniku i zwracanie częściowego wyniku jako możliwe sposoby obsługi anulowania. Współbieżność.

Należy zaimplementować dwie rzeczy: zatrzymać zbędne obliczenia i ustalić, czy ukończony wynik nadal powinien zostać pokazany w interfejsie. Przykład obsługuje oba przypadki, w tym anulowanie zgłoszone po ostatnim sprawdzeniu przez zadanie robocze, ale przed wyświetleniem wyniku.

Umieść sprawdzanie anulowania wewnątrz obliczeń

Utwórz w Xcode projekt aplikacji na macOS, używając SwiftUI i Swifta. W tym przykładzie ustaw tryb języka Swift 6 i minimalny docelowy system macOS 13. Dodaj poniższy kompletny plik o nazwie SyntheticCalculation.swift. Nie zależy on od widoku.

// 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
    }
}

Funkcja sprawdza stan anulowania przed rozpoczęciem, na granicy każdego fragmentu i przed zwróceniem wyniku. To celowo wybrane punkty zatrzymania: żądanie anulowania zgłoszone podczas przetwarzania fragmentu zostanie wykryte przy następnym sprawdzeniu. Task.checkCancellation() jest synchroniczne, więc obliczenie może pozostać zwykłą funkcją zgłaszającą błędy; nie potrzebuje await tylko po to, by sprawdzać stan anulowania. checkCancellation().

Obliczanie fragmentów zapewnia też przejrzyste granice danych wejściowych. Zero składników daje zero, jeśli zadanie nie zostało anulowane. Ujemna liczba składników i nie dodatni rozmiar fragmentu są błędami programisty, wymuszanymi przez warunki wstępne. Obliczanie każdego fragmentu na podstawie pozostałej liczby składników zapobiega przesunięciu jego końca poza terms.

Akumulator pozostaje lokalny dla zadania roboczego. Zgłoszenie błędu anulowania kończy funkcję bez zwracania akumulatora, co realizuje wybrany kontrakt kompletnego wyniku. Ponieważ jest to dodawanie liczb zmiennoprzecinkowych, ukończony wynik jest przybliżeniem sumy matematycznej.

Zapewnij responsywność przycisku Cancel

Sprawdzanie anulowania nie pomoże, jeśli akcja przycisku nie ma szansy się wykonać. Apple wyjaśnia, że umieszczenie synchronicznych, ciężkich obliczeń w zwykłym Task utworzonym z widoku SwiftUI może nadal obciążyć głównego aktora, ponieważ zadanie dziedziczy kontekst aktora z otoczenia. Apple opisuje zadanie odłączone jako obsługiwany sposób wykonywania pracy synchronicznej poza odziedziczonym kontekstem. Poprawa responsywności aplikacji.

W tym przykładzie odłączone zadanie robocze przechwytuje tylko niezmienną liczbę całkowitą i wywołuje synchroniczną funkcję z jawną adnotacją nonisolated. Cały stan interfejsu pozostaje na głównym aktorze. Osobne zadanie na głównym aktorze oczekuje na wynik zadania roboczego i aktualizuje widok.

Zastąp wygenerowany plik punktu wejścia aplikacji poniższym kompletnym plikiem i usuń wygenerowany ContentView.swift, jeśli nadal istnieje. W obiekcie docelowym pozostaw dokładnie jedną deklarację @main, zgodnie z wymaganiami cyklu życia aplikacji SwiftUI. Te dwa pliki razem tworzą przykładową aplikację. 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
        }
    }
}

Zachowanie worker jest niezbędne. Porzucenie uchwytu zadania nie anuluje zadania; odbiera możliwość anulowania go za pomocą tego uchwytu. Oczekiwanie na value zadania roboczego zgłaszającego błędy pozwala też odebrać jego błąd. Zadania odłączone wymagają jawnego zarządzania cyklem życia, dlatego przycisk i obsługa zniknięcia widoku anulują zadanie robocze bezpośrednio. Anulowanie wyłącznie zadania oczekującego na jego wynik nie zapewniłoby propagacji anulowania do tego odłączonego zadania roboczego. Dokumentacja zadań odłączonych.

UUID z Foundation zapewnia identyfikator dla każdego uruchomienia. Porównywanie identyfikatorów to reguła na poziomie aplikacji: gdy zniknięcie widoku unieważni dane uruchomienie, jego późniejsze zakończenie nie może zaktualizować bieżącego stanu tego widoku. Identyfikator nie zatrzymuje obliczeń; służą do tego worker.cancel() i sprawdzenia w pętli. UUID.

Przycisk Start pozostaje nieaktywny do czasu zakończenia lub zgłoszenia błędu przez zadanie robocze. Po naciśnięciu Cancel najpierw pojawia się komunikat Cancellation requested; obserwator zakończenia zmienia go na Cancelled, gdy obsłuży wynik. Dzięki temu żądanie anulowania jest odróżnione od potwierdzenia, że zadanie robocze się zakończyło.

Jak obsłużyć spóźniony wynik?

Rozważ sytuację, w której zadanie robocze przechodzi ostatnie sprawdzenie, kończy się, a następnie użytkownik naciska Cancel, zanim główny aktor zaakceptuje jego wynik. Apple wyraźnie zaznacza, że anulowanie po ostatnim możliwym sprawdzeniu może nie mieć zauważalnego wpływu na zadanie robocze. Końcowe sprawdzenie skraca ten przedział, ale nie może go wyeliminować. cancel().

Dlatego po odebraniu poprawnego wyniku przykład sprawdza run.isCancelled. To sprawdzenie dotyczy uchwytu zadania roboczego. Użycie tam statycznej właściwości Task.isCancelled sprawdzałoby zadanie obserwatora. Przewodnik języka Swift rozróżnia sprawdzanie bieżącego zadania za pomocą właściwości typu od sprawdzania innego zadania za pomocą jego właściwości instancji. Współbieżność.

W ten sposób powstaje konkretna reguła akceptacji: jeśli anulowanie zostanie obsłużone przed zaakceptowaniem wyniku, ma pierwszeństwo. Jeśli akceptacja już się zakończyła i uchwyt zadania roboczego został usunięty, nieaktywny przycisk Cancel nie ma już ukończonego wyniku, który mógłby wycofać. Reguła wynika z kolejności działań głównego aktora i nie obiecuje cofnięcia ukończonej pracy.

Dobierz odstęp między sprawdzeniami anulowania

Domyślny rozmiar fragmentu wynoszący 4,096 składników to orientacyjna wartość początkowa. Mniejsze fragmenty częściej dają możliwość wykrycia anulowania, a zarazem wymagają częstszych sprawdzeń. Większe fragmenty ograniczają częstotliwość sprawdzeń, ale pozwalają wykonać więcej obliczeń między punktami zatrzymania.

Załóżmy na potrzeby zgrubnego planowania, że fragment zawiera 4,096 składników, a obliczenie każdego z nich zajmuje przykładowe dwie mikrosekundy. Taki fragment oznacza około 8,2 milisekundy obliczeń. To wymyślone dane służące do pokazania rachunku, a nie pomiary Swifta ani konkretnego Maca. Planowanie zadań i aktualizacje interfejsu mogą dodatkowo zwiększyć opóźnienie.

Obserwacja Co sprawdzić lub zmienić
Interfejs przestaje reagować podczas obliczeń Sprawdź, czy pętla działa w odłączonym zadaniu roboczym i poza izolacją głównego aktora.
Interfejs reaguje, ale anulowanie trwa zbyt długo Zmniejsz checkEvery i zmierz czas najdłuższego fragmentu.
W jednym z fragmentów pojawia się kosztowna operacja Dodaj punkty zatrzymania wewnątrz tej operacji, jeśli jej implementacja na to pozwala.
Anulowane uruchomienie wyświetla wynik Sprawdź uchwyt zadania roboczego w chwili akceptacji i zachowaj zabezpieczenie oparte na identyfikatorze uruchomienia.

Nie dodawaj obsługi anulowania tylko po to, by ustawić kolejną flagę dla tej pętli. Zadanie już udostępnia stan anulowania, a przewodnik języka Swift wyjaśnia, że zainstalowanie obsługi nadal pozostawia zatrzymywanie procesu w gestii kodu. W przypadku tych lokalnych obliczeń wystarczą jawne sprawdzenia. Współbieżność.

Sprawdź ukończenie, anulowanie i ponowne uruchomienia

W środowisku, w którym prowadzono analizę, kompilator Swift i system macOS były niedostępne, dlatego przykład został przejrzany, ale nie skompilowany ani uruchomiony. Zbuduj oba pliki w Xcode, a następnie sprawdź osobno następujące zachowania:

  1. Mały, ukończony wynik: tymczasowo zmień terms na 4. Wyświetlony wynik powinien wynosić w przybliżeniu 6.146264369941973, z uwzględnieniem zaokrągleń zmiennoprzecinkowych.
  2. Puste dane wejściowe: zmień terms na 0. Nieanulowane uruchomienie powinno wyświetlić zero.
  3. Anulowanie w trakcie pracy: przywróć 500_000_000, naciśnij Start, a następnie Cancel, gdy obliczenia trwają. Oczekuj komunikatu Cancellation requested, a następnie Cancelled, bez wyniku częściowego.
  4. Kolejne uruchomienie: po zakończeniu anulowania naciśnij ponownie Start. Nowe uruchomienie powinno rozpocząć się normalnie.
  5. Zniknięcie widoku: zamknij okno podczas obliczeń. Obsługa zniknięcia powinna zażądać anulowania i unieważnić aktualizacje interfejsu dla tego uruchomienia.

Duża liczba składników ma zapewnić czas na interakcję, ale czas obliczeń zależy od Maca i konfiguracji kompilacji. Jeśli obliczenia zakończą się, zanim zdążysz je anulować, zwiększ liczbę w granicach obsługiwanego zakresu liczb całkowitych. Poprawność ukończonych obliczeń sprawdzaj na małych danych, zamiast czekać na koniec dużego przykładu.

Gotowe rozwiązanie zapewnia jasną ścieżkę zatrzymania i regułę akceptacji: przycisk anuluje zachowane zadanie robocze, granice fragmentów pozwalają obliczeniom zgłosić błąd, a główny aktor akceptuje wyłącznie wynik nieanulowanego zadania z bieżącego uruchomienia. Te trzy decyzje sprawiają, że anulowanie jest przydatne w zwykłych obliczeniach na Macu.

Źródła