Per eseguire calcoli Swift ad alta intensità di CPU in parallelo, assegna al worker un confine @concurrent esplicito e suddividi il lavoro indipendente tra task figlio. Questo esempio per Mac mantiene gli aggiornamenti dell’interfaccia su MainActor e combina risultati sintetici senza mutazioni condivise.

Perché aggiungere task può comunque serializzare il calcolo

Supponiamo che un’app per Mac abbia un pulsante Calculate che avvia un calcolo locale impegnativo. Racchiudere il calcolo in Task { ... } fornisce un punto di ingresso asincrono, ma un task creato in un contesto main actor può ereditare tale isolamento. Un lungo ciclo sincrono al suo interno continua a occupare il main actor. Apple illustra questa distinzione in Abbracciare la concorrenza in Swift.

Anche aggiungere un gruppo di task lascia senza risposta una domanda importante: dove viene eseguita la funzione che contiene il ciclo costoso? Se ogni figlio chiama lo stesso metodo di calcolo @MainActor, quell’actor serializza il calcolo. Gli actor consentono a un solo task alla volta di accedere al proprio stato isolato, come spiegato nel capitolo sulla concorrenza della guida al linguaggio Swift.

Per Swift 6.2 e versioni successive, contrassegna il punto di ingresso asincrono del calcolo con @concurrent. In questo modo si passa esplicitamente dall’actor del chiamante. Con la funzionalità NonisolatedNonsendingByDefault abilitata, invece, una normale funzione asincrona non isolata viene eseguita sull’actor del chiamante; il comportamento precedente era diverso. La proposta SE-0461 implementata documenta sia il confine della funzionalità sia l’attributo esplicito.

Due scelte distinte producono il comportamento desiderato: l’isolamento del worker consente l’esecuzione al di fuori di MainActor, mentre più task figlio forniscono lavoro pianificabile in modo indipendente. Il runtime decide quali thread disponibili eseguono questi task. Né il numero di task né @concurrent garantiscono un numero specifico di core in esecuzione simultanea. Apple descrive questo modello di pianificazione in Abbracciare la concorrenza in Swift.

Definire un calcolo suddivisibile correttamente

L’esempio seguente consente a un utente Mac di avviare o annullare un calcolo sintetico e visualizzarne il checksum finale. Tutti gli input sono costanti o numeri interi generati. Il calcolo ha questo contratto di valore preciso:

  1. Visitare ogni indice intero in 0..<count esattamente una volta.
  2. Convertire ogni indice in UInt64 e applicare ripetutamente x = (x &* 1_664_525) &+ 1_013_904_223 per il numero di iterazioni richiesto.
  3. Sommare i valori risultanti con l’addizione con wraparound.
  4. Restituire zero per un input vuoto, rifiutare conteggi o iterazioni negativi e rifiutare un limite di worker non positivo.
  5. Generare un errore quando viene rilevato un annullamento, anziché presentare un checksum incompleto come risultato completo.

L’aritmetica con wraparound è intenzionale. L’aritmetica intera ordinaria di Swift genera un errore in caso di overflow; gli operatori &+ e &* gestiscono esplicitamente l’overflow. Vedi Operatori avanzati.

Per questo contratto, ogni indice è indipendente e l’addizione con wraparound combina i totali parziali modulo 2 elevato alla potenza di 64. Pertanto, cambiare il raggruppamento o l’ordine di completamento preserva il valore finale esatto. Questa conclusione deriva dall’aritmetica scelta. Un calcolo in virgola mobile richiede una propria regola di correttezza, perché il raggruppamento delle addizioni può modificare gli arrotondamenti.

La partizione usa divisione e resto: ogni worker riceve lo stesso numero base di indici e i primi worker ricevono un indice aggiuntivo. Con un conteggio esemplificativo di 17 e tre worker, gli intervalli sono 0..<6, 6..<12 e 12..<17. Coprono l’intero input una sola volta, compreso il resto.

Creare l’esempio completo di calcolo per Mac

Usa una toolchain Swift 6.2 o successiva e un target di app SwiftUI per macOS con una versione minima di macOS 13 o successiva. Sostituisci il punto di ingresso dell’app generato e la vista del contenuto con i due blocchi seguenti, inserendoli insieme in un unico file Swift. Formano un programma completo; mantieni una sola dichiarazione dell’app @main nel target.

Lo spazio dei nomi del calcolo è esplicitamente nonisolated, così i suoi membri non acquisiscono l’isolamento globale dell’actor per inferenza. Swift documenta questa dichiarazione a livello di tipo in SE-0449. Entrambi i metodi asincroni di calcolo usano @concurrent per dichiarare esplicitamente il proprio confine di esecuzione.

import Foundation
import SwiftUI

nonisolated enum CalculationError: Error {
    case invalidInput
}

nonisolated enum SyntheticCalculator {
    @concurrent
    static func run(
        count: Int,
        rounds: Int,
        workerLimit: Int
    ) async throws -> UInt64 {
        guard count >= 0, rounds >= 0, workerLimit > 0 else {
            throw CalculationError.invalidInput
        }
        try Task.checkCancellation()
        guard count > 0 else { return 0 }

        let workers = min(count, workerLimit)
        let baseSize = count / workers
        let remainder = count % workers

        return try await withThrowingTaskGroup(
            of: UInt64.self,
            returning: UInt64.self
        ) { group in
            var lower = 0

            for worker in 0..<workers {
                try Task.checkCancellation()
                let size = baseSize + (worker < remainder ? 1 : 0)
                let start = lower
                let end = start + size
                lower = end

                group.addTask {
                    try await calculateChunk(
                        lower: start,
                        upper: end,
                        rounds: rounds
                    )
                }
            }

            var total: UInt64 = 0
            for try await partial in group {
                total = total &+ partial
            }
            try Task.checkCancellation()
            return total
        }
    }

    @concurrent
    private static func calculateChunk(
        lower: Int,
        upper: Int,
        rounds: Int
    ) async throws -> UInt64 {
        try Task.checkCancellation()
        var total: UInt64 = 0

        for index in lower..<upper {
            if index % 256 == 0 {
                try Task.checkCancellation()
            }
            var value = UInt64(index)
            for round in 0..<rounds {
                if round % 4096 == 0 {
                    try Task.checkCancellation()
                }
                value = (value &* 1_664_525) &+ 1_013_904_223
            }
            total = total &+ value
        }

        try Task.checkCancellation()
        return total
    }
}

Ogni figlio acquisisce limiti immutabili e restituisce un intero. Solo il task padre aggiorna total; i figli non aggiungono elementi a una raccolta condivisa e non modificano lo stato dell’interfaccia. I gruppi di task supportano l’esecuzione concorrente dei task figlio, richiedono risultati trasferibili e mantengono la durata dei figli entro l’ambito del gruppo. Vedi la documentazione di TaskGroup di Apple.

Aggiungi questo codice dell’interfaccia allo stesso file:

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

@MainActor
struct CalculationView: View {
    @State private var status = "Ready"
    @State private var work: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Synthetic Calculation")
                .font(.title2)
            Text(status)
                .textSelection(.enabled)

            HStack {
                Button("Calculate") {
                    startCalculation()
                }
                .disabled(work != nil)

                Button("Cancel") {
                    work?.cancel()
                }
                .disabled(work == nil)
            }
        }
        .padding(24)
        .frame(minWidth: 440, minHeight: 180)
        .onDisappear {
            work?.cancel()
        }
    }

    private func startCalculation() {
        guard work == nil else { return }
        status = "Calculating…"
        let workerLimit = max(
            1,
            ProcessInfo.processInfo.activeProcessorCount
        )

        work = Task { @MainActor in
            defer { work = nil }
            do {
                let checksum = try await SyntheticCalculator.run(
                    count: 1_000_000,
                    rounds: 128,
                    workerLimit: workerLimit
                )
                try Task.checkCancellation()
                status = "Checksum: \(checksum)"
            } catch is CancellationError {
                status = "Cancelled"
            } catch {
                status = "Calculation failed: \(error)"
            }
        }
    }
}

L’interfaccia possiede l’handle del task esterno, così il pulsante Cancel può annullarlo. Il task resta isolato sul main actor, chiama il calcolo concorrente e aggiorna lo stato visualizzato dopo aver atteso il risultato. Conservare l’handle consente anche di annullare il task quando la vista scompare. La documentazione di Task di Apple spiega che eliminare un handle non annulla il task.

Il limite dei worker parte da activeProcessorCount di Foundation, che riporta il numero di core di elaborazione attivi disponibili sul computer. Consideralo una scelta iniziale da misurare, non un numero di task garantito come ottimale. Il significato documentato è descritto in activeProcessorCount.

Verificare la correttezza prima di confrontare le prestazioni

Inizia con il piccolo input esemplificativo: modifica la chiamata impostando count: 17, rounds: 4 e workerLimit: 3. Il checksum atteso è 18149427060254280572. Ripeti con workerLimit: 1; il valore deve coincidere. Verifica anche count: 0, che deve restituire zero, e un conteggio positivo inferiore al limite dei worker, che deve comunque includere ogni indice.

Il checksum esemplificativo e la copertura degli intervalli sono stati verificati con una piccola riproduzione in Python dell’aritmetica con wraparound. Nell’ambiente di ricerca non era disponibile un compilatore Swift, quindi il programma Swift non è stato compilato né eseguito.

Quindi ripristina l’input più grande e confronta build ottimizzate con limiti dei worker pari a uno, due e al numero di processori attivi. Mantieni invariati il conteggio e le iterazioni, conserva il checksum risultante e misura più esecuzioni. Questa è una procedura di misurazione proposta; non vengono dichiarati tempi o aumenti di velocità.

Un’esecuzione con un solo worker usa lo stesso confine concorrente e la stessa struttura del gruppo di task, perciò è un confronto utile per questa implementazione. I carichi di lavoro ridotti potrebbero non giustificare i costi di partizionamento e pianificazione. Se in un calcolo realistico gli indici hanno costi molto diversi, anche gli intervalli di uguale dimensione possono lasciare un worker impegnato più a lungo; una possibile prova successiva consiste nell’usare blocchi più piccoli con un numero limitato di task simultanei.

Per un calcolo che produce un array, associa un indice del blocco a ogni risultato e ricomponi i blocchi nell’ordine dell’input. I task figlio di un gruppo possono essere eseguiti in qualsiasi ordine e il gruppo espone i risultati completati man mano che diventano disponibili. Il checksum evita i problemi di ordinamento solo perché la riduzione scelta non dipende dall’ordine. Vedi TaskGroup.

Rendere visibili gli errori di annullamento e isolamento

L’annullamento è cooperativo. Chiamare cancel() contrassegna il task; il codice di calcolo deve rilevare questo stato. I controlli in entrambi i cicli offrono al worker l’opportunità di generare un errore, anche quando un singolo indice richiede molte iterazioni. Questi intervalli sono scelte implementative, non una latenza di annullamento garantita. Apple documenta questa responsabilità in Task.

È importante anche il ciclo dei risultati che genera errori. Se un figlio genera un errore e questo si propaga fuori dal corpo del gruppo, i figli rimanenti vengono annullati. Il gruppo attende comunque che terminino, perciò i relativi controlli di annullamento restano necessari. L’implementazione della libreria standard di Swift documenta questo comportamento in TaskGroup.swift.

Se l’interfaccia continua a bloccarsi, traccia l’intero percorso della chiamata costosa. Verifica se un worker chiama un metodo del main actor, accede a proprietà isolate da un actor o svolge una preparazione considerevole prima di raggiungere il confine concorrente. Passa al worker i valori interi necessari e mantieni i cicli lunghi all’interno di quel confine.

Se il compilatore rifiuta @concurrent, controlla la versione della toolchain. L’attributo è stato introdotto in Swift 6.2 e si applica alle funzioni asincrone. Per una toolchain precedente, il modello precedente di esecuzione asincrona non isolata è documentato in SE-0461; usa la documentazione corrispondente a quel compilatore, senza presumere che tutte le versioni attribuiscano a nonisolated async lo stesso comportamento.

Per questo task su Mac, l’architettura utile è semplice: il main actor gestisce lo stato dell’interfaccia, i worker concorrenti ricevono valori indipendenti e il task padre combina i risultati completi. Mantieni il contratto di valore mentre regoli le dimensioni dei blocchi e verifica l’uguaglianza dei checksum e la reattività dell’interfaccia prima di conservare una modifica alle prestazioni.

Fonti