Pour exécuter des calculs Swift gourmands en CPU en parallèle, donnez au traitement une frontière @concurrent explicite et répartissez le travail indépendant entre des tâches enfants. Cet exemple pour Mac maintient les mises à jour de l’interface sur MainActor et combine des résultats synthétiques sans mutation partagée.
Pourquoi l’ajout de tâches peut tout de même sérialiser le calcul
Supposons qu’une app Mac dispose d’un bouton Calculate qui lance un calcul local conséquent. Envelopper le calcul dans Task { ... } fournit un point d’entrée asynchrone, mais une tâche créée dans un contexte d’acteur principal peut hériter de cette isolation. Une longue boucle synchrone à l’intérieur occupe toujours l’acteur principal. Apple illustre cette distinction dans Embracing Swift concurrency.
L’ajout d’un groupe de tâches laisse aussi une question importante sans réponse : où s’exécute la fonction qui contient la boucle coûteuse ? Si chaque tâche enfant appelle la même méthode de calcul @MainActor, cet acteur sérialise le calcul. Les acteurs n’autorisent qu’une tâche à la fois à accéder à leur état isolé, comme l’explique le chapitre Concurrency du guide du langage Swift.
À partir de Swift 6.2, marquez le point d’entrée asynchrone du calcul avec @concurrent. Cela bascule explicitement l’exécution hors de l’acteur de l’appelant. Lorsque la fonctionnalité NonisolatedNonsendingByDefault est activée, une fonction asynchrone non isolée ordinaire s’exécute plutôt sur l’acteur de l’appelant ; le comportement était différent dans les versions antérieures. La proposition SE-0461, qui a été mise en œuvre, documente à la fois la limite de la fonctionnalité et l’attribut explicite.
Deux choix distincts produisent le comportement souhaité : l’isolation du traitement permet une exécution hors de MainActor, et plusieurs tâches enfants fournissent du travail pouvant être planifié indépendamment. L’environnement d’exécution décide quels threads disponibles exécutent ces tâches. Ni le nombre de tâches ni @concurrent ne garantissent un nombre précis de cœurs exécutant simultanément du travail. Apple décrit ce modèle de planification dans Embracing Swift concurrency.
Définir un calcul qui peut être correctement réparti
L’exemple ci-dessous permet à l’utilisateur d’un Mac de lancer ou d’annuler un calcul synthétique et d’en afficher la somme de contrôle finale. Toutes les entrées sont des constantes ou des entiers générés. Le calcul respecte exactement le contrat de valeur suivant :
- Parcourir exactement une fois chaque indice entier de
0..<count. - Convertir chaque indice en
UInt64et appliquer de façon répétéex = (x &* 1_664_525) &+ 1_013_904_223pendant le nombre de tours demandé. - Additionner les valeurs obtenues avec une addition modulo.
- Renvoyer zéro pour une entrée vide, rejeter les nombres négatifs de comptage ou de tours, et rejeter une limite de workers non positive.
- Signaler l’annulation observée au lieu de présenter une somme de contrôle incomplète comme un résultat terminé.
L’arithmétique modulo est intentionnelle ici. Les opérations arithmétiques entières ordinaires de Swift déclenchent un piège en cas de dépassement ; les opérateurs &+ et &* prennent explicitement en charge ce comportement. Voir Advanced Operators.
Dans le cadre de ce contrat, chaque indice est indépendant, et l’addition modulo combine les totaux partiels modulo 2 puissance 64. Par conséquent, modifier le regroupement ou l’ordre d’achèvement préserve la valeur finale exacte. Cette conclusion découle de l’arithmétique choisie. Un calcul en virgule flottante nécessite sa propre règle de correction, car le regroupement des additions peut modifier l’arrondi.
La partition utilise la division et le reste : chaque worker reçoit un nombre de base égal d’indices, et les premiers workers reçoivent chacun un indice supplémentaire. Pour un exemple avec 17 indices et trois workers, les plages sont 0..<6, 6..<12 et 12..<17. Elles couvrent une fois toute l’entrée, reste compris.
Créer l’exemple complet de calcul pour Mac
Utilisez une chaîne d’outils Swift 6.2 ou ultérieure et une cible d’app SwiftUI pour macOS avec une version minimale de déploiement macOS 13 ou ultérieure. Remplacez le point d’entrée de l’app et la vue de contenu générés par les deux blocs suivants, placés ensemble dans un seul fichier Swift. Ils forment un programme complet ; ne conservez qu’une seule déclaration d’app @main dans la cible.
L’espace de noms du calcul est explicitement nonisolated, afin que ses membres n’acquièrent pas d’isolation globale implicite par acteur. Swift documente cette déclaration au niveau du type dans SE-0449. Les deux méthodes de calcul asynchrones utilisent @concurrent pour préciser explicitement leur frontière d’exécution.
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
}
}
Chaque tâche enfant capture des bornes immuables et renvoie un entier. Seule la tâche parente met à jour total ; les enfants n’ajoutent rien à une collection partagée et ne modifient pas l’état de l’interface. Les groupes de tâches prennent en charge l’exécution concurrente des tâches enfants, exigent des résultats envoyables et limitent la durée de vie des enfants à la portée du groupe. Voir la documentation de TaskGroup d’Apple.
Ajoutez ce code d’interface au même fichier :
@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’interface possède le descripteur de la tâche externe afin que le bouton Cancel puisse l’annuler. Sa tâche reste isolée sur l’acteur principal, appelle le calcul concurrent et met à jour l’état affiché après l’attente du résultat. Conserver le descripteur permet également l’annulation lorsque la vue disparaît. La documentation de Task d’Apple explique que supprimer un descripteur n’annule pas la tâche.
La limite de workers part de activeProcessorCount de Foundation, qui indique le nombre de cœurs de traitement actifs disponibles sur l’ordinateur. Considérez-le comme un choix initial à mesurer, et non comme un nombre optimal de tâches garanti. Sa signification documentée figure dans activeProcessorCount.
Vérifier la correction avant de comparer les performances
Commencez par le petit exemple : modifiez l’appel pour utiliser count: 17, rounds: 4 et workerLimit: 3. La somme de contrôle attendue est 18149427060254280572. Répétez avec workerLimit: 1 ; la valeur doit correspondre. Vérifiez également que count: 0 renvoie zéro et qu’un nombre positif inférieur à la limite de workers inclut toujours chaque indice.
La somme de contrôle de l’exemple et la couverture des plages ont été vérifiées à l’aide d’une petite reproduction Python de l’arithmétique modulo. Aucun compilateur Swift n’était disponible dans l’environnement de recherche ; le programme Swift n’a donc pas été compilé ni exécuté ici.
Rétablissez ensuite la grande entrée et comparez des compilations optimisées avec des limites de workers de un, deux et le nombre de processeurs actifs. Gardez count et rounds inchangés, conservez la somme de contrôle obtenue et mesurez plusieurs exécutions. Il s’agit d’une procédure de mesure proposée ; aucun temps ni gain de vitesse n’est affirmé.
Une exécution avec un seul worker utilise la même frontière concurrente et la même structure de groupe de tâches, ce qui en fait une comparaison utile pour cette implémentation. Les petites charges de travail ne justifient pas toujours le surcoût de partitionnement et de planification. Si les indices d’un calcul réaliste ont des coûts très différents, des plages de taille égale peuvent aussi laisser un worker terminer plus tard ; des blocs plus petits avec un nombre borné de tâches en cours constituent une expérience raisonnable à tenter ensuite.
Pour un calcul qui produit un tableau, associez un indice de bloc à chaque résultat et assemblez les blocs dans l’ordre des entrées. Les tâches enfants d’un groupe peuvent s’exécuter dans n’importe quel ordre, et le groupe expose les résultats terminés au fur et à mesure. La somme de contrôle évite les problèmes d’ordre uniquement parce que la réduction choisie est indépendante de l’ordre. Voir TaskGroup.
Rendre visibles les problèmes d’annulation et d’isolation
L’annulation est coopérative. Appeler cancel() marque la tâche ; le code de calcul doit observer cet état. Les vérifications dans les deux boucles donnent au traitement des occasions de signaler l’annulation, y compris lorsqu’un seul indice comporte de nombreux tours. Ces intervalles sont des choix d’implémentation, pas une garantie de délai d’annulation. Apple documente cette responsabilité dans Task.
La boucle qui traite les résultats avec propagation des erreurs est également importante. Si une tâche enfant échoue et que son erreur se propage hors du corps du groupe, les tâches restantes sont annulées. Le groupe attend tout de même qu’elles se terminent ; leurs vérifications d’annulation restent donc nécessaires. L’implémentation de la bibliothèque standard Swift documente ce comportement dans TaskGroup.swift.
Si l’interface reste bloquée, examinez tout le chemin d’appel coûteux. Vérifiez si un traitement appelle une méthode de l’acteur principal, accède à des propriétés isolées par un acteur ou effectue une préparation conséquente avant d’atteindre la frontière concurrente. Transmettez les valeurs entières nécessaires au traitement et gardez les longues boucles à l’intérieur de cette frontière.
Si le compilateur rejette @concurrent, vérifiez la version de la chaîne d’outils. L’attribut a été mis en œuvre dans Swift 6.2 et s’applique aux fonctions asynchrones. Pour une chaîne d’outils plus ancienne, le modèle antérieur d’exécution asynchrone non isolée est documenté dans SE-0461 ; consultez la documentation correspondant à ce compilateur au lieu de supposer que toutes les versions attribuent le même comportement à nonisolated async.
Pour cette tâche sur Mac, l’architecture utile est simple : l’acteur principal possède l’état de l’interface, les workers concurrents reçoivent des valeurs indépendantes et la tâche parente combine des résultats complets. Préservez le contrat de valeur lorsque vous ajustez la taille des blocs, et vérifiez à la fois l’égalité des sommes de contrôle et la réactivité de l’interface avant de conserver une modification des performances.
Sources
- 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.