Użyj jednej długowiecznej kolejki Foundation OperationQueue i ustaw jej maxConcurrentOperationCount przed przesłaniem obliczeń. W przypadku narzędzia na Maca, które otrzymuje jedno żądanie po każdym naciśnięciu przycisku, ogranicza to liczbę równoczesnych zadań, a kolejne żądania mogą czekać.

Ten samouczek tworzy silnik obliczeniowy do takiego zadania na komputerze oraz samodzielny program do sprawdzania jego działania. Każde żądanie zawiera syntetyczne liczby całkowite, a każdy wynik pozostaje w pamięci. Obliczenie przebiega synchronicznie wewnątrz operacji, więc czas życia operacji obejmuje rzeczywistą pracę.

Które narzędzie współbieżności nadaje się do pojedynczych przychodzących żądań?

OperationQueue przyjmuje operacje pojedynczo, a jej metoda addOperation(_:), oparta na blokach, opakowuje każde przesłane domknięcie w operację. Przechowuj kolejkę w obiekcie, który istnieje przez cały czas obsługi żądań, a następnie przesyłaj po jednym domknięciu dla każdego przychodzącego elementu. Dokumentacja metody addOperation(_:) firmy Apple opisuje tę metodę przesyłania.

Ustaw maxConcurrentOperationCount na dodatni limit, na przykład 2. Firma Apple definiuje tę właściwość jako maksymalną liczbę operacji danej kolejki, które mogą wykonywać się równocześnie. Limit dotyczy konkretnej kolejki; utworzenie osobnej kolejki dla każdego żądania zniweczyłoby działanie wspólnego limitu. Późniejsze zmniejszenie limitu nie zatrzymuje już wykonywanych operacji. Dokumentacja limitu współbieżności firmy Apple wyjaśnia te ograniczenia.

Grupa zadań w Swift ma inny czas życia. Należy do zakresu withTaskGroup, nie można jej używać spoza zadania, które ją utworzyło, i czeka ona na zakończenie zadań podrzędnych przed zwróceniem wyniku. W związku z tym przechowywanie grupy zadań jako właściwości przeznaczonej do obsługi niezależnych, przyszłych naciśnięć przycisku nie jest obsługiwane. W tym synchronicznym silniku obliczeniowym wymagany czas życia i jawne ustawienie współbieżności zapewnia zachowywana kolejka operacji. Dokumentacja grup zadań w bibliotece standardowej Swifta opisuje te ograniczenia.

Sposób organizacji pracy Odpowiednie podejście
Niezależne obliczenia synchroniczne napływają pojedynczo Zachowaj jedną OperationQueue i przesyłaj po jednej operacji dla każdego obliczenia.
Nadrzędna operacja asynchroniczna zarządza skończonym zbiorem zadań podrzędnych Użyj grupy zadań w zakresie tej operacji nadrzędnej; jeśli wymagany jest limit liczbowy, jawnie kontroluj przyjmowanie zadań.
Praca musi aktualizować interfejs komputera Wykonaj obliczenie osobno, a następnie przekaż wynik do kodu izolowanego do MainActor.

Ostatni wiersz dotyczy przekazywania wyników. Przewodnik języka Swift wskazuje aktora głównego jako miejsce dla stanu interfejsu i pokazuje domknięcia jawnie izolowane do aktora głównego. Kosztowne obliczenia arytmetyczne wykonuj poza etapem przekazywania wyniku. The Swift Programming Language: Concurrency wyjaśnia izolację do aktora głównego.

Zdefiniuj obliczenie i warunki zakończenia

Przykład przyjmuje identyfikator całkowity i liczbę iteracji z zakresu od 1 do 1_000_000. Dla liczby iteracji równej n dodaje kwadraty wszystkich liczb całkowitych od 1 do n. Zwraca identyfikator wraz z wynikiem typu UInt64. Identyfikator oznacza wynik; nie wpływa na obliczenia arytmetyczne.

To przykładowe limity dobrane na potrzeby tego programu. Dla maksymalnej akceptowanej wartości wejściowej wynik wynosi 333333833333500000, co mieści się w typie UInt64. Walidacja odbywa się przed przesłaniem, więc nieprawidłowe żądanie natychmiast zgłasza błąd i nie tworzy operacji w kolejce. Ograniczenie wejścia zapewnia też prawidłowy zakres pętli.

Każde zaakceptowane żądanie daje jeden zapisany wynik po zakończeniu jego operacji. Wyniki gromadzą się w tablicy, więc powtórzone identyfikatory nie nadpisują wcześniejszych wyników. W demonstracji użyto różnych, rosnących identyfikatorów, a wyniki posortowano według identyfikatorów, by ułatwić ich odczyt; ta kolejność wyświetlania nie oznacza kolejności wykonywania.

Ten kontrakt zakłada, że kolejka pozostaje dostępna, a jej operacje dobiegają końca. Przykład nie udostępnia anulowania. Późniejsze dodanie anulowania wymaga odrębnego wyniku dla niedokończonych żądań, ponieważ według dokumentacji firmy Apple anulowanie jest żądaniem przerwania pracy, na które wykonywany kod musi odpowiedzieć. Dokumentacja OperationQueue firmy Apple opisuje czas życia operacji i anulowanie.

Zbuduj kompletny program ze wspólnym limitem

Użyj komputera Mac z kompilatorem Swift 6.2 lub nowszym oraz SDK macOS dostępnym przez Xcode albo jego narzędzia wiersza poleceń. Zapisz poniższy kompletny program jako main.swift. Polecenia poniżej jawnie wybierają domyślną izolację nieizolowaną, dzięki czemu pomocnicze funkcje obliczeniowe nie zależą od ustawień izolacji celu aplikacji. Swift opisuje to ustawienie kompilatora w dokumencie SE-0466: Control default actor isolation inference.

Dziennik wyników używa NSLock do ochrony zarówno tablicy, jak i liczników aktywności. Zgodność z @unchecked Sendable przenosi odpowiedzialność za synchronizację na implementację: każdy dostęp do zmiennego stanu korzysta z tej samej blokady. Firma Apple opisuje tę odpowiedzialność w dokumentacji Sendable, a ochronę sekcji krytycznych w dokumentacji NSLock.

import Foundation

enum InputError: Error {
    case invalidIterations
}

struct Calculation: Sendable {
    let id: Int
    let iterations: Int
}

func sumOfSquares(_ job: Calculation) -> UInt64 {
    var sum: UInt64 = 0
    for integer in 1...job.iterations {
        let value = UInt64(integer)
        sum += value * value
    }
    return sum
}

final class ExecutionLog: @unchecked Sendable {
    private let lock = NSLock()
    private var active = 0
    private var peak = 0
    private var rows: [(id: Int, value: UInt64)] = []

    func begin() {
        lock.lock()
        defer { lock.unlock() }
        active += 1
        peak = max(peak, active)
    }

    func finish(id: Int, value: UInt64) {
        lock.lock()
        defer { lock.unlock() }
        rows.append((id: id, value: value))
        active -= 1
    }

    func snapshot() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        lock.lock()
        defer { lock.unlock() }
        return (rows, peak)
    }
}

final class CalculationPool {
    private let queue = OperationQueue()
    private let log = ExecutionLog()

    init(limit: Int) {
        precondition(limit > 0)
        queue.name = "Synthetic calculations"
        queue.maxConcurrentOperationCount = limit
    }

    func submit(id: Int, iterations: Int) throws {
        guard (1...1_000_000).contains(iterations) else {
            throw InputError.invalidIterations
        }
        let job = Calculation(id: id, iterations: iterations)
        queue.addOperation { [log] in
            log.begin()
            let value = sumOfSquares(job)
            log.finish(id: job.id, value: value)
        }
    }

    // Call only after the producer has stopped submitting work.
    // This blocking method is for the command-line demonstration.
    func finishForDemo() -> (
        rows: [(id: Int, value: UInt64)], peak: Int
    ) {
        queue.waitUntilAllOperationsAreFinished()
        return log.snapshot()
    }
}

@main
struct Demo {
    static func main() throws {
        let limit = 2
        let pool = CalculationPool(limit: limit)

        try pool.submit(id: 1, iterations: 100_000)
        try pool.submit(id: 2, iterations: 200_000)
        try pool.submit(id: 3, iterations: 300_000)

        let report = pool.finishForDemo()
        let rows = report.rows.sorted { $0.id < $1.id }
        precondition(rows.map { $0.id } == [1, 2, 3])
        precondition(rows.map { $0.value } == [
            UInt64(333338333350000),
            UInt64(2666686666700000),
            UInt64(9000045000050000)
        ])
        precondition(report.peak >= 1 && report.peak <= limit)

        for row in rows {
            print("Job \(row.id): \(row.value)")
        }
        print("Observed peak: \(report.peak)")
    }
}

Skompiluj i uruchom program w katalogu zawierającym main.swift:

swiftc -swift-version 6 -default-isolation nonisolated \
  -parse-as-library main.swift -o calculation-demo
./calculation-demo

Powinny pojawić się trzy wiersze wyników:

Job 1: 333338333350000
Job 2: 2666686666700000
Job 3: 9000045000050000

Ostatni wiersz zgłasza zaobserwowane maksimum wynoszące 1 albo 2. Limit dopuszcza nakładanie się zadań, ale go nie wymusza. Oczekiwane sumy zweryfikowano niezależnie przy użyciu arytmetyki całkowitoliczbowej i wzoru n * (n + 1) * (2 * n + 1) / 6. Kod programu w Swift sprawdzono, ale nie można było go skompilować w środowisku badawczym, ponieważ nie zainstalowano w nim kompilatora Swift.

Dlaczego ten limit obejmuje rzeczywiste obliczenie?

Każda operacja uruchamia licznik aktywności, wykonuje całe obliczenie synchronicznie, zapisuje wynik, a następnie zwraca sterowanie. Z tego domknięcia nie uruchamia się żadne dodatkowe obliczenie asynchroniczne. Zatem ograniczenie liczby wykonywanych operacji ogranicza również liczbę tych obliczeń.

Zachowaj tę zależność podczas dostosowywania programu. Jeśli operacja jedynie tworzy osobne Task i natychmiast kończy działanie, kolejka uznaje operację za zakończoną, choć zadanie może nadal wykonywać obliczenia. Limit operacji regulowałby wtedy krótkie domknięcia uruchamiające zadania. Wynika to z udokumentowanego czasu życia operacji i interfejsu API do przesyłania bloków. Omówienie OperationQueue firmy Apple oraz addOperation(_:) opisują te elementy.

Blokada dziennika obejmuje tylko krótkie sekcje służące do ewidencji. Jej utrzymywanie podczas sumOfSquares(_:) serializowałoby dostęp do obliczenia i uniemożliwiłoby zamierzone nakładanie się zadań. Każde zablokowanie i odblokowanie odbywa się w obrębie jednej metody synchronicznej, zgodnie z wymogiem firmy Apple, aby odblokować NSLock na tym samym wątku, który go zablokował. Dokumentacja NSLock firmy Apple wyjaśnia ten wymóg.

Aby sprawdzić dostosowany program, uruchom te same trzy żądania z limit ustawionym na 1, a następnie przywróć 2. W obu przebiegach muszą zostać zachowane wszystkie trzy dokładne wyniki. Przebieg sekwencyjny powinien zgłosić maksimum 1; w drugim przebiegu wartość nie może przekroczyć 2. Te kontrole sprawdzają zachowanie wyników i obserwowany limit obliczeń bez zakładania konkretnej kolejności planowania zadań.

Połącz działania na komputerze z obsługą, która nie blokuje interfejsu

Zachowaj jedną instancję CalculationPool w komponencie zarządzającym funkcją obliczeniową na komputerze. Każde przychodzące działanie powinno raz wywołać submit(id:iterations:). Nie twórz puli ponownie w procedurze obsługi działania, ponieważ każda nowa instancja ma niezależną kolejkę i limit.

Demonstracja wywołuje finishForDemo() dopiero po przesłaniu ostatniego żądania, aby proces wiersza poleceń mógł zebrać wyniki przed zakończeniem. Ta metoda blokuje wątek, który ją wywołuje. W aplikacji komputerowej użyj dostarczania poszczególnych wyników zamiast wywoływać ją z obsługi przycisku lub z aktora głównego. Firma Apple jawnie opisuje blokujące działanie waitUntilAllOperationsAreFinished().

Aby zintegrować kod z interfejsem, rozszerz API przesyłania o wywołanie zwrotne wyniku izolowane do MainActor i oznaczone @Sendable. Po obliczeniu wyniku wewnątrz operacji zaplanuj zadanie Task z jawnym domknięciem @MainActor, które wywoła to wywołanie zwrotne. Przechwytuj niezmienną wartość wyniku i wywołanie zwrotne. W tym domknięciu umieść wyłącznie aktualizację widoku; obliczenia arytmetyczne pozostaw w operacji. Swift opisuje zarówno składnię domknięć aktora głównego, jak i przechwytywanie wartości przez domknięcia Sendable.

Wersja obsługująca interfejs wymaga dodatkowej definicji zakończenia: koniec obliczenia i wyświetlenie jego wyniku to dwa osobne zdarzenia. Program wiersza poleceń czeka na obliczenia i synchroniczny zapis wyników. Osobno zaplanowane wywołanie zwrotne interfejsu może zostać wykonane później.

Co się stanie, gdy żądania napłyną szybciej, niż są wykonywane obliczenia?

Ustawienie współbieżności ogranicza liczbę wykonywanych operacji, a dodatkowe przesłane zadania mogą pozostać w kolejce. Bez ograniczeń może też rosnąć tablica ukończonych wyników z przykładu. W efekcie przykład ogranicza współbieżność obliczeń, ale nie ogranicza całkowitego zużycia pamięci ani długości kolejki. Firma Apple dokumentuje, że kolejka zachowuje operacje aż do ich zakończenia. Omówienie OperationQueue wyjaśnia ten czas przechowywania.

Jeśli funkcja komputerowa może generować długotrwałe serie żądań, przed jej rozszerzeniem wybierz zasady ich przyjmowania: akceptuj tylko ustaloną liczbę oczekujących żądań i zgłaszaj odrzucenie, wstrzymuj dalsze przesyłanie albo zastępuj oczekujący podgląd syntetyczny nowszym żądaniem, jeśli takie działanie jest zamierzone. Liczniki przyjętych żądań i aktualizacje po zakończeniu koordynuj za pomocą jednego zsynchronizowanego obiektu zarządzającego. Sam limit wykonywanej pracy nie wystarczy do wdrożenia tych zasad.

W przypadku pojedynczych żądań synchronicznych podstawowy projekt jest prosty: zachowaj jedną kolejkę, skonfiguruj jej limit raz, umieść każde obliczenie wewnątrz jego operacji i synchronizuj współdzielone wyniki. Zachowaj te zasady przy podłączaniu działań na komputerze, a jawne zasady obsługi kolejki dodaj dopiero wtedy, gdy wymaga tego charakter napływających żądań.

Źródła