Um CPU-intensive Swift-Berechnungen parallel auszuführen, gib dem Worker eine explizite @concurrent-Grenze und verteile unabhängige Arbeit auf untergeordnete Tasks. Dieses Mac-Beispiel hält Interface-Aktualisierungen auf dem MainActor und kombiniert synthetische Ergebnisse ohne gemeinsam genutzte veränderliche Daten.
Warum zusätzliche Tasks die Berechnung dennoch serialisieren können
Angenommen, eine Mac-App hat eine Schaltfläche Calculate, die eine umfangreiche lokale Berechnung startet. Task { ... } um die Berechnung zu legen, bietet einen asynchronen Einstiegspunkt, aber ein in einem Main-Actor-Kontext erstellter Task kann diese Isolation übernehmen. Eine lange synchrone Schleife darin belegt weiterhin den Main Actor. Apple veranschaulicht diesen Unterschied in Embracing Swift concurrency.
Auch eine Task-Gruppe lässt eine wichtige Frage offen: Wo wird die Funktion ausgeführt, die die aufwendige Schleife enthält? Wenn jedes untergeordnete Element dieselbe @MainActor-Berechnungsmethode aufruft, serialisiert dieser Actor die Berechnung. Actors lassen jeweils nur einen Task gleichzeitig auf ihren isolierten Zustand zugreifen, wie im Concurrency-Kapitel des Swift-Sprachhandbuchs erläutert.
Für Swift 6.2 und neuer markierst du den asynchronen Einstiegspunkt der Berechnung mit @concurrent. Dadurch wird die Ausführung ausdrücklich vom Actor des Aufrufers weg verlagert. Ist das Feature NonisolatedNonsendingByDefault aktiviert, läuft eine gewöhnliche nicht isolierte asynchrone Funktion stattdessen auf dem Actor des Aufrufers; das frühere Verhalten war anders. Der umgesetzte Vorschlag SE-0461 dokumentiert sowohl die Feature-Grenze als auch das explizite Attribut.
Zwei getrennte Entscheidungen sorgen für das gewünschte Verhalten: Die Isolation des Workers ermöglicht die Ausführung außerhalb des MainActor, und mehrere untergeordnete Tasks stellen unabhängig planbare Arbeit bereit. Die Laufzeit entscheidet, welche verfügbaren Threads diese Tasks ausführen. Weder die Anzahl der Tasks noch @concurrent versprechen eine bestimmte Zahl gleichzeitig arbeitender Prozessorkerne. Apple beschreibt dieses Planungsmodell in Embracing Swift concurrency.
Eine korrekt aufteilbare Berechnung definieren
Das folgende Beispiel ermöglicht es Mac-Nutzern, eine synthetische Berechnung zu starten oder abzubrechen und ihre abschließende Prüfsumme anzuzeigen. Alle Eingaben sind Konstanten oder generierte Ganzzahlen. Für die Berechnung gilt genau dieser Wertevertrag:
- Jeden ganzzahligen Index in
0..<countgenau einmal durchlaufen. - Jeden Index in
UInt64umwandeln und wiederholtx = (x &* 1_664_525) &+ 1_013_904_223so oft anwenden, wie Runden angefordert wurden. - Die resultierenden Werte mit überlaufender Addition addieren.
- Für eine leere Eingabe null zurückgeben, negative Anzahlen oder Rundenzahlen ablehnen und ein nicht positives Worker-Limit ablehnen.
- Bei erkannter Stornierung einen Fehler auslösen, statt eine unvollständige Prüfsumme als abgeschlossenes Ergebnis auszugeben.
Überlaufende Arithmetik ist hier beabsichtigt. Bei der gewöhnlichen Ganzzahlarithmetik von Swift führt ein Überlauf zu einem Trap; die Operatoren &+ und &* unterstützen Überläufe ausdrücklich. Siehe Advanced Operators.
Bei diesem Vertrag ist jeder Index unabhängig, und die überlaufende Addition kombiniert Teilsummen modulo 2 hoch 64. Deshalb bleibt der exakte Endwert erhalten, wenn die Gruppierung oder die Reihenfolge des Abschlusses geändert wird. Diese Schlussfolgerung ergibt sich aus der gewählten Arithmetik. Eine Gleitkommaberechnung braucht eine eigene Korrektheitsregel, weil eine andere Gruppierung von Additionen das Rundungsergebnis ändern kann.
Die Aufteilung verwendet Division und Rest: Jeder Worker erhält zunächst gleich viele Indizes, und die ersten Worker erhalten jeweils einen zusätzlichen Index. Bei einer Beispielanzahl von 17 und drei Workern lauten die Bereiche 0..<6, 6..<12 und 12..<17. Sie decken einschließlich des Rests die Eingabe genau einmal ab.
Das vollständige Mac-Berechnungsbeispiel erstellen
Verwende eine Toolchain ab Swift 6.2 und ein macOS-SwiftUI-App-Target mit einem Deployment-Target ab macOS 13. Ersetze den generierten App-Einstiegspunkt und die Inhaltsansicht durch die folgenden beiden Blöcke, die gemeinsam in eine Swift-Datei gehören. Sie bilden ein vollständiges Programm; behalte im Target nur eine @main-App-Deklaration.
Der Berechnungs-Namespace ist ausdrücklich nonisolated, sodass seine Elemente keine inferierte globale Actor-Isolation erhalten. Swift dokumentiert diese Deklaration auf Typebene in SE-0449. Beide asynchronen Berechnungsmethoden verwenden @concurrent, um ihre Ausführungsgrenze ausdrücklich anzugeben.
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
}
}
Jedes untergeordnete Element erfasst unveränderliche Grenzen und gibt eine Ganzzahl zurück. Nur der übergeordnete Task aktualisiert total; untergeordnete Elemente hängen nichts an eine gemeinsam genutzte Sammlung an und ändern keinen Interface-Zustand. Task-Gruppen unterstützen die nebenläufige Ausführung untergeordneter Tasks, erfordern sendbare Ergebnisse und begrenzen deren Lebensdauer auf den Gültigkeitsbereich der Gruppe. Siehe Apples TaskGroup-Dokumentation.
Füge diesen Interface-Code derselben Datei hinzu:
@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)"
}
}
}
}
Das Interface verwaltet das Handle des äußeren Tasks, damit die Schaltfläche Cancel ihn abbrechen kann. Der Task bleibt auf dem Main Actor isoliert, ruft die nebenläufige Berechnung auf und aktualisiert den angezeigten Status, sobald das Ergebnis vorliegt. Das gespeicherte Handle ermöglicht außerdem den Abbruch, wenn die Ansicht verschwindet. Apples Task-Dokumentation erklärt, dass das Verwerfen eines Handles den Task nicht abbricht.
Das Worker-Limit basiert zunächst auf Foundation-activeProcessorCount, das die Anzahl der aktiven Prozessorkerne meldet, die auf dem Computer verfügbar sind. Betrachte dies als erste Größenwahl, die gemessen werden sollte, nicht als garantiert optimale Task-Anzahl. Die dokumentierte Bedeutung findest du unter activeProcessorCount.
Die Korrektheit prüfen, bevor du die Leistung vergleichst
Beginne mit der kleinen Beispiel-Eingabe: Ändere den Aufruf zu count: 17, rounds: 4 und workerLimit: 3. Die erwartete Prüfsumme lautet 18149427060254280572. Wiederhole den Aufruf mit workerLimit: 1; der Wert muss übereinstimmen. Prüfe außerdem count: 0, was null zurückgeben muss, sowie eine positive Anzahl, die kleiner als das Worker-Limit ist; auch dann muss jeder Index berücksichtigt werden.
Die Beispiel-Prüfsumme und die Abdeckung der Bereiche wurden mit einer kleinen Python-Nachbildung der überlaufenden Arithmetik überprüft. In der Forschungsumgebung war kein Swift-Compiler verfügbar, daher wurde das Swift-Programm hier weder kompiliert noch ausgeführt.
Stelle anschließend die größere Eingabe wieder her und vergleiche optimierte Builds mit Worker-Limits von eins, zwei und der aktiven Prozessoranzahl. Lass Anzahl und Rundenzahl unverändert, notiere die resultierende Prüfsumme und miss mehrere Durchläufe. Dies ist ein vorgeschlagenes Messverfahren; es werden keine Laufzeiten oder Leistungssteigerungen behauptet.
Bei einem Durchlauf mit einem Worker kommen dieselbe @concurrent-Grenze und dieselbe Task-Gruppen-Struktur zum Einsatz. Damit eignet er sich als Vergleich für diese Implementierung. Kleine Arbeitslasten rechtfertigen den Aufwand für Aufteilung und Planung möglicherweise nicht. Wenn die Indizes einer realistischen Berechnung sehr unterschiedliche Kosten verursachen, können gleich große Bereiche außerdem dazu führen, dass ein Worker deutlich später fertig wird. Kleinere Blöcke mit einer begrenzten Anzahl gleichzeitig aktiver Tasks sind dann ein sinnvoller nächster Versuch.
Füge bei einer Berechnung, die ein Array erzeugt, jedem Ergebnis einen Blockindex hinzu und setze die Blöcke in Eingabereihenfolge zusammen. Untergeordnete Tasks einer Task-Gruppe können in beliebiger Reihenfolge ausgeführt werden, und die Gruppe stellt abgeschlossene Ergebnisse bereit, sobald sie verfügbar sind. Die Prüfsumme vermeidet Fragen zur Reihenfolge nur deshalb, weil die gewählte Reduktion unabhängig von der Reihenfolge ist. Siehe TaskGroup.
Abbruch und Isolationsfehler sichtbar machen
Der Abbruch erfolgt kooperativ. Der Aufruf von cancel() markiert den Task; der Berechnungscode muss diesen Zustand erkennen. Die Prüfungen in beiden Schleifen geben dem Worker Gelegenheit, einen Fehler auszulösen, auch wenn ein einzelner Index viele Runden umfasst. Diese Intervalle sind Implementierungsentscheidungen und garantieren keine bestimmte Abbruchlatenz. Apple erläutert diese Verantwortung unter Task.
Auch die Schleife, die fehlerauslösende Ergebnisse verarbeitet, ist wichtig. Wenn ein untergeordneter Task einen Fehler auslöst und dieser aus dem Gruppenrumpf weitergegeben wird, werden die übrigen untergeordneten Tasks abgebrochen. Die Gruppe wartet dennoch darauf, dass sie fertig werden, daher bleiben ihre Abbruchprüfungen notwendig. Die Implementierung der Swift-Standardbibliothek dokumentiert dieses Verhalten in TaskGroup.swift.
Wenn das Interface weiterhin stockt, verfolge den gesamten Pfad des aufwendigen Aufrufs. Achte darauf, ob ein Worker eine Main-Actor-Methode aufruft, auf Actor-isolierte Eigenschaften zugreift oder umfangreiche Vorbereitungen trifft, bevor er die nebenläufige Grenze erreicht. Übergib dem Worker die benötigten Ganzzahlwerte und halte die langen Schleifen innerhalb dieser Grenze.
Wenn der Compiler @concurrent ablehnt, prüfe die Toolchain-Version. Das Attribut wurde in Swift 6.2 implementiert und gilt für asynchrone Funktionen. Für ältere Toolchains ist das frühere Ausführungsmodell für nicht isolierte asynchrone Funktionen in SE-0461 dokumentiert. Verwende Dokumentation, die zu deinem Compiler passt, statt anzunehmen, dass sich nonisolated async in allen Versionen identisch verhält.
Für diese Mac-Aufgabe ist die passende Architektur überschaubar: Der Main Actor verwaltet den Interface-Zustand, nebenläufige Worker erhalten unabhängige Werte, und der übergeordnete Task kombiniert vollständige Ergebnisse. Halte beim Anpassen der Blockgrößen den Wertevertrag ein und prüfe sowohl die Gleichheit der Prüfsumme als auch die Reaktionsfähigkeit des Interfaces, bevor du eine Leistungsänderung beibehältst.
Quellen
- Embracing Swift concurrency — Apple.
- Run nonisolated async functions on the caller’s actor by default — Swift.org, Swift Evolution.
- Allow nonisolated to prevent global actor inference — Swift.org, Swift Evolution.
- Concurrency — Swift.org, The Swift Programming Language.
- Advanced Operators — Swift.org, The Swift Programming Language.
- Task — Apple Developer Documentation.
- TaskGroup — Apple Developer Documentation.
- activeProcessorCount — Apple Developer Documentation.
- TaskGroup.swift — Swift.org, Swift standard library.