Aby zachować udane obliczenia w Swift, gdy zadania równoległe się nie powiodą, przechwyć błąd każdego obliczenia wewnątrz zadania potomnego i zwróć wartość opisującą wynik. Ten przykład dla wiersza poleceń na Macu zbiera wszystkie wyniki, zachowuje kolejność wejściową i udostępnia udane wartości.

Zadanie polega na niewielkim, syntetycznym obliczeniu wsadowym: podniesieniu do kwadratu listy nieujemnych liczb całkowitych i zgłoszeniu, które dane wejściowe zakończyły się powodzeniem. Ujemne dane wejściowe i przepełnienie liczby całkowitej powodują osobne błędy. Wszystkie dane obliczeniowe pozostają w pamięci; program wyświetla wyniki w Terminalu.

Dlaczego jeden błąd może przerwać zbieranie wyników?

Zgłoszenie błędu przez zadanie potomne nie powoduje natychmiastowego anulowania jego zadań równoległych. Istotną granicą jest ciało grupy zadań: gdy błąd wydostanie się poza to ciało, Swift anuluje pozostałe zadania potomne. Apple opisuje to rozróżnienie w dokumentacji referencyjnej funkcji withThrowingTaskGroup(of:returning:isolation:body:).

W rezultacie pętla iterująca z obsługą błędów i zewnętrznym blokiem catch może przerwać zbieranie przy pierwszym napotkanym błędzie. Wcześniejsze wartości mogą być już zapisane, ale pętla nie wznawia działania po tym bloku catch. Pozostałe zadania potomne otrzymują sygnał anulowania, jeśli błąd wydostanie się poza ciało grupy.

W przypadku niezależnych obliczeń określ jawnie sposób obsługi błędów. Poniższy przykład używa grupy zadań bez obsługi wyjątków, której zadania potomne zwracają wynik pomyślny, błąd lub anulowanie. Samo obliczenie nadal zgłasza błędy; jego opakowanie zamienia błąd na wartość, zanim dotrze on do grupy.

Jest to zgodne z udokumentowaną przez Swift granicą: zadania potomne w grupie zadań bez obsługi wyjątków nie mogą przekazywać zgłoszonych błędów, więc muszą obsłużyć je wewnętrznie albo przedstawić wynik jako zwracaną wartość. Zobacz dokumentację Apple dotyczącą TaskGroup.

Określ, co ma zwrócić każde dane wejściowe

Umowa dla przetwarzania wsadowego przewiduje jeden wynik dla każdej pozycji wejściowej. Udane obliczenie zwraca dokładny kwadrat liczby całkowitej. Odrzucone obliczenie zwraca opis błędu. Obliczenie pominięte po wykryciu anulowania zwraca wynik anulowania.

Sytuacja Wynik Zachowanie przetwarzania wsadowego
Nieujemne dane wejściowe, których kwadrat mieści się w zakresie Int64 Sukces zawierający kwadrat Zachowaj wartość
Ujemne dane wejściowe Błąd: negativeInput Kontynuuj zbieranie pozostałych wyników
Kwadrat przekracza zakres Int64 Błąd: overflow Kontynuuj zbieranie pozostałych wyników
Anulowanie wykryte przed obliczeniem Anulowano Zachowaj ukończone wyniki i pomiń oczekujące zadania

Odrzucanie ujemnych danych wejściowych to przykładowa reguła aplikacji; liczby ujemne można podnosić do kwadratu w sensie matematycznym. Oddzielenie tej reguły od przepełnienia pozwala pokazać w przykładzie dwa różne warunki błędu.

Do mnożenia użyj multipliedReportingOverflow(by:) i sprawdź flagę przepełnienia. Apple określa, że zwrócona wartość częściowa jest pełnym iloczynem tylko wtedy, gdy ta flaga jest fałszywa; w przeciwnym razie jest obcięta. Przykład odrzuca tę obciętą wartość i zgłasza błąd. Zobacz Int64.multipliedReportingOverflow(by:).

Każdy wynik zawiera też indeks danych wejściowych. Swift nie gwarantuje kolejności kończenia zadań potomnych, więc samo dopisywanie ukończonych wartości nie zachowuje pierwotnej kolejności. Ten sposób planowania opisano w przewodniku po języku w sekcji Współbieżność.

Uruchom kompletny przykład na Macu

Użyj Maca z łańcuchem narzędzi Swift 6 lub nowszym. W Terminalu uruchom swift --version, aby sprawdzić zainstalowany kompilator, zgodnie z zaleceniem zawartym w przewodniku Swift.org Pierwsze kroki. Jeśli Swift nie jest dostępny, przed dalszą pracą skorzystaj z odsyłacza do instalacji w tym przewodniku.

Zapisz poniższy kompletny program w folderze roboczym pod nazwą Calculations.swift. Importuje on Foundation, aby wybrać początkowy limit współbieżności na podstawie liczby aktywnych procesorów.

import Foundation

enum CalculationError: Error {
    case negativeInput
    case overflow
}

enum Outcome: Sendable {
    case success(Int64)
    case failure(String)
    case cancelled
}

struct Row: Sendable {
    let index: Int
    let outcome: Outcome
}

func square(_ input: Int64) throws -> Int64 {
    guard input >= 0 else {
        throw CalculationError.negativeInput
    }
    let product = input.multipliedReportingOverflow(by: input)
    guard !product.overflow else {
        throw CalculationError.overflow
    }
    return product.partialValue
}

func evaluate(index: Int, input: Int64) -> Row {
    do {
        try Task.checkCancellation()
        let value = try square(input)
        return Row(index: index, outcome: .success(value))
    } catch is CancellationError {
        return Row(index: index, outcome: .cancelled)
    } catch {
        return Row(
            index: index,
            outcome: .failure(String(describing: error))
        )
    }
}

func collect(
    _ inputs: [Int64],
    maxConcurrent: Int
) async -> [Row] {
    guard !inputs.isEmpty else { return [] }
    let width = min(inputs.count, max(1, maxConcurrent))

    return await withTaskGroup(of: Row.self) { group in
        var rows: [Row] = []
        rows.reserveCapacity(inputs.count)

        for index in 0..<width {
            let input = inputs[index]
            group.addTask {
                evaluate(index: index, input: input)
            }
        }
        var nextIndex = width

        while let row = await group.next() {
            rows.append(row)

            if Task.isCancelled {
                while nextIndex < inputs.count {
                    rows.append(Row(
                        index: nextIndex,
                        outcome: .cancelled
                    ))
                    nextIndex += 1
                }
            } else if nextIndex < inputs.count {
                let index = nextIndex
                let input = inputs[index]
                nextIndex += 1
                group.addTask {
                    evaluate(index: index, input: input)
                }
            }
        }

        return rows.sorted { $0.index < $1.index }
    }
}

@main
struct CalculationDemo {
    static func main() async {
        let inputs: [Int64] = [3, -2, 4, Int64.max, 0]
        let width = min(
            4,
            max(1, ProcessInfo.processInfo.activeProcessorCount)
        )
        let rows = await collect(inputs, maxConcurrent: width)
        var successes: [Int64] = []

        for row in rows {
            let input = inputs[row.index]
            switch row.outcome {
            case .success(let value):
                successes.append(value)
                print("[\(row.index)] \(input): success \(value)")
            case .failure(let message):
                print("[\(row.index)] \(input): failure \(message)")
            case .cancelled:
                print("[\(row.index)] \(input): cancelled")
            }
        }
        print("Successful values: \(successes)")
    }
}

W tym folderze skompiluj i uruchom program:

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

Opcja -parse-as-library pozwala użyć w tym pojedynczym pliku źródłowym jawnego punktu wejścia @main. Program wykonuje obliczenia bez odczytywania ani zapisywania danych aplikacji. Swift nie był dostępny w środowisku badawczym, dlatego program sprawdzono, ale nie skompilowano ani nie uruchomiono.

Dla podanych danych wejściowych, bez anulowania, oczekiwany wynik to:

[0] 3: success 9
[1] -2: failure negativeInput
[2] 4: success 16
[3] 9223372036854775807: failure overflow
[4] 0: success 0
Successful values: [9, 16, 0]

Wydrukowana kolejność wynika z posortowania wyników według indeksu danych wejściowych po ich zebraniu. Kolejność wykonywania może się zmieniać bez wpływu na sposób prezentacji. Tablica udanych wartości jest tworzona później, dzięki czemu pełny raport wyników pozostaje dostępny do analizy.

Jak kolektor zachowuje każdy udany wynik?

Zadanie nadrzędne jest właścicielem tablicy wyników. Każde zadanie potomne pobiera jeden indeks i jedno niezmienne dane wejściowe, oblicza wynik i zwraca wiersz. Zadania potomne nie dopisują bezpośrednio do tablicy zadania nadrzędnego. Odpowiada to podejściu z przewodnika po języku, w którym wyniki zadań potomnych gromadzi się wewnątrz ciała grupy zadań. Zobacz Współbieżność.

Kolektor początkowo uruchamia ograniczoną liczbę zadań potomnych. Każdy odebrany wiersz zwalnia miejsce na kolejne dane wejściowe. Nieudane obliczenie przesuwa więc przetwarzanie wsadowe naprzód tak samo jak udane: oba zwracają zwykły wiersz, a zbieranie wyników jest kontynuowane.

Foundation udostępnia activeProcessorCount, które zwraca liczbę aktywnych rdzeni przetwarzających dostępnych na komputerze. Przykład ogranicza tę liczbę do czterech jako ilustracyjną decyzję dotyczącą planowania. Nie jest to zmierzona wartość optymalna ani gwarancja, że cztery obliczenia będą wykonywane jednocześnie. Zobacz dokumentację Apple dotyczącą activeProcessorCount.

Limit współbieżności ogranicza liczbę nierozliczonych zadań potomnych, natomiast tablica wyników nadal rośnie wraz z liczbą danych wejściowych. To rozwiązanie sprawdza się w przypadku niewielkiego przetwarzania wsadowego na komputerze. Dla tych prostych obliczeń kwadratów przykład pokazuje obsługę wyników; nie twierdzi, że jest szybszy od pętli sekwencyjnej.

Obsługa pustego wejścia zwraca pustą tablicę. Limit współbieżności równy zero lub mniejszy zostaje ograniczony do jednego. Powtarzające się wartości wejściowe pozostają odrębne, ponieważ ich tożsamość wynika z pozycji, więc dwa identyczne dane wejściowe nadal dają dwa wiersze.

Co się dzieje po anulowaniu zadania nadrzędnego?

Anulowanie w Swift jest kooperacyjne. Anulowanie zadania nadrzędnego propaguje sygnał anulowania do jego ustrukturyzowanych zadań potomnych, ale każde zadanie potomne musi odebrać ten sygnał i zareagować. Dokumentacja Apple dotycząca TaskGroup opisuje zarówno propagację, jak i wymóg kooperacyjnej obsługi anulowania.

W tym przykładzie evaluate wywołuje Task.checkCancellation() przed obliczeniem. Apple dokumentuje, że ta metoda zgłasza CancellationError, gdy wykryje anulowanie; opakowanie przechwytuje ten błąd osobno i zwraca wiersz z wynikiem anulowania. Zobacz checkCancellation().

Gdy kolektor wykryje anulowanie, oznacza jako anulowane dane wejściowe, dla których nie wysłano jeszcze zadań. Oczekuje też na zakończenie zadań potomnych, które już wysłano. Ich udane wyniki pozostają w raporcie, a zadania potomne wykrywające anulowanie zwracają wiersze z wynikiem anulowania.

Anulowanie może nastąpić po sprawdzeniu przez zadanie potomne. Takie zadanie może mimo to zakończyć się powodzeniem, co jest dopuszczalne w ramach tej umowy. Jeśli obliczenie stanie się długą pętlą, dodaj w niej kontrole anulowania w odpowiednich odstępach. Grupa nadal czeka na zakończenie wszystkich wysłanych zadań potomnych, zanim zwróci wynik, zgodnie z dokumentacją dotyczącą TaskGroup.

Kiedy zamiast tego użyć grupy zadań obsługującej błędy?

Jeśli istniejące zadania potomne już zgłaszają błędy i chcesz zachować ten interfejs, grupa zadań obsługująca błędy również pozwala zbierać poszczególne wyniki. Wielokrotnie wywołuj nextResult() i wybieraj odpowiedni przypadek dla każdego zwróconego sukcesu lub błędu, aż opcjonalny wynik będzie pusty. W odróżnieniu od next(), ta metoda zwraca wartość Result, zamiast zgłaszać błąd zadania potomnego do wywołującego. Apple opisuje zachowanie dotyczące kolejności ukończenia w dokumentacji nextResult().

W tym projekcie uwzględnij tożsamość danych wejściowych w udanych wartościach oraz w tych błędach, które trzeba przypisać do konkretnego wejścia. Sam błąd arytmetyczny nie wskazuje, które z kilku danych wejściowych go spowodowały. Wybierz też jawną regułę obsługi anulowania zamiast traktować anulowanie jak zwykły błąd obliczenia.

Użyj podejścia zwracającego wyniki jako wartości, gdy głównym wymaganiem jest kompletny raport dla każdego wejścia. Użyj grupy obsługującej błędy, gdy pożądanym zachowaniem przetwarzania wsadowego jest przekazanie błędu poza grupę albo gdy wygodniej jest korzystać z istniejących operacji zgłaszających błędy przez nextResult().

W przypadku tego lokalnego zadania obliczeniowego podstawowa umowa jest prosta: zachowaj każdą udaną wartość, zapisz każde nieudane wejście i kontynuuj zbieranie wyników. Dołączenie indeksu wejścia do każdego wyniku sprawia, że raport końcowy pozostaje przydatny nawet wtedy, gdy obliczenia kończą się w nieprzewidywalnej kolejności.

Źródła