Stockez la tâche du calcul dans un acteur avant la première suspension, puis laissez les appels concurrents attendre cette même tâche. Ce tutoriel construit un exemple local de somme des carrés pour un utilitaire Mac, avec des règles explicites concernant l’annulation, les erreurs et le nettoyage.

Que devraient partager les appels concurrents ?

Supposons que deux composants d’un utilitaire de bureau Mac aient besoin du même résumé calculé : un aperçu et un panneau de détails. Chacun demande la somme des carrés du tableau synthétique [1, 2, 3, 4]. Les deux doivent recevoir 30, et les requêtes qui rejoignent la même exécution active doivent partager une seule exécution.

L’implémentation ci-dessous fournit à chaque instance du calculateur un tableau d’entrée immuable. Elle partage la tâche en cours tant que celle-ci occupe le créneau d’exécution de l’acteur, puis libère ce créneau lorsqu’un appelant constate que la tâche est terminée. Une requête ultérieure peut démarrer un autre calcul. Il s’agit de partager un travail en cours ; conserver indéfiniment les résultats terminés nécessiterait une stratégie de mise en cache distincte.

Le contrat de valeur est volontairement restreint :

  • Accepter au maximum 100 000 entiers, chacun compris entre 0 et 1,000.
  • Renvoyer la somme de leurs carrés ; un tableau vide renvoie 0.
  • Joindre un identifiant d’exécution afin que les appelants puissent distinguer une exécution partagée de totaux simplement égaux.
  • Propager les erreurs de calcul aux appelants qui attendent cette exécution.
  • Permettre à un appelant d’annuler son attente sans annuler le worker partagé.

Le total maximal autorisé est 100,000 × 1,000² = 100,000,000,000, ce qui tient dans un Int 64 bits sur Mac. Ces limites font partie du contrat de cet exemple. Les modifier implique de réexaminer les risques de débordement arithmétique et d’utilisation des ressources.

Pourquoi un acteur a-t-il encore besoin d’une tâche stockée ?

Un acteur protège son état mutable, mais une méthode asynchrone d’acteur peut être suspendue. Pendant cette suspension, un autre appel peut entrer dans l’acteur. Le guide du langage Swift explique l’isolation des acteurs et les points de suspension, tandis que la session d’Apple sur les acteurs montre comment des hypothèses formulées avant un await peuvent devenir obsolètes. Concurrency — The Swift Programming Language, Protect mutable state with Swift actors.

Par conséquent, vérifier s’il existe déjà un résultat puis attendre un nouveau calcul n’empêche pas le travail en double. Un autre appelant peut arriver avant que le résultat existe. Publiez plutôt le descripteur de tâche de façon synchrone : examinez le créneau d’exécution, créez un worker uniquement si le créneau est vide, stockez-le, puis attendez son résultat.

Apple précise qu’une tâche peut démarrer immédiatement après sa création. L’accès à sa propriété asynchrone value attend la fin de l’exécution et propage toute erreur levée. L’acteur coordonne donc la propriété d’un unique descripteur ; les appelants obtiennent leurs résultats par son intermédiaire. Task, value.

Créer l’exemple local complet

Utilisez un compilateur Swift 6 sous macOS 13 ou une version ultérieure. L’exigence liée à macOS vient ici de l’appel de démonstration Task.sleep(for:). Cet appel suspend l’exécution sans bloquer le thread sous-jacent ; cette courte pause facilite l’observation des requêtes qui se chevauchent. Elle n’effectue aucune partie du calcul et peut ensuite être supprimée. sleep(for:tolerance:clock:).

Enregistrez le programme complet suivant dans SharedCalculation.swift. Il utilise les réglages par défaut ordinaires non isolés pour la compilation en ligne de commande indiquée ci-dessous. Foundation fournit UUID, dont les conformances documentées incluent Sendable et Equatable ; l’identifiant étiquette chaque exécution et permet le nettoyage conditionnel. UUID.

import Foundation

struct CalculationResult: Sendable {
    let total: Int
    let runID: UUID
}

enum CalculationError: Error {
    case invalidInput
}

actor SharedCalculation {
    private struct Flight {
        let id: UUID
        let task: Task<CalculationResult, Error>
    }

    private let samples: [Int]
    private var flight: Flight?

    init(samples: [Int]) {
        self.samples = samples
    }

    func value() async throws -> CalculationResult {
        try Task.checkCancellation()

        let active: Flight
        if let existing = flight {
            active = existing
        } else {
            let id = UUID()
            let snapshot = samples
            let task = Task.detached(priority: .userInitiated) {
                [snapshot, id] () async throws -> CalculationResult in
                guard snapshot.count <= 100_000,
                      snapshot.allSatisfy({ (0...1_000).contains($0) })
                else {
                    throw CalculationError.invalidInput
                }

                // Demonstration only: allow overlap to be observed.
                try await Task.sleep(for: .milliseconds(100))
                let total = snapshot.reduce(0) { sum, sample in
                    sum + sample * sample
                }
                return CalculationResult(total: total, runID: id)
            }
            active = Flight(id: id, task: task)
            flight = active
        }

        defer {
            // An older waiter must not clear a newer flight.
            if flight?.id == active.id {
                flight = nil
            }
        }

        let result = try await active.task.value
        try Task.checkCancellation()
        return result
    }
}

@main
struct Demo {
    static func main() async throws {
        let calculation = SharedCalculation(samples: [1, 2, 3, 4])

        async let preview = calculation.value()
        async let details = calculation.value()
        async let inspector = calculation.value()

        let (a, b, c) = try await (preview, details, inspector)
        precondition(a.total == 30 && b.total == 30 && c.total == 30)

        let observedRuns = Set([a.runID, b.runID, c.runID]).count
        print("Total: \(a.total)")
        print("Runs observed across three requests: \(observedRuns)")

        let later = try await calculation.value()
        precondition(later.total == 30)
        print("Later request started another run: \(later.runID != c.runID)")
    }
}

Compilez et exécutez le programme depuis le répertoire contenant le fichier :

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

Les assertions arithmétiques doivent réussir. Lorsque les trois appels initiaux entrent avant la libération du créneau, le nombre d’exécutions observé est 1. La requête ultérieure arrive après le retour de ces appels et démarre donc un autre calcul. La pause favorise le chevauchement, mais ne garantit pas l’ordonnancement : une requête initiale retardée suffisamment longtemps pourrait légitimement démarrer une exécution ultérieure. Swift n’était pas disponible dans l’environnement de recherche ; ce programme a donc été examiné, mais n’a pas été compilé ni exécuté ici.

Le worker utilise une tâche détachée, car ce calcul relève du calculateur partagé plutôt que d’un appelant donné, et son calcul arithmétique doit s’exécuter indépendamment de l’acteur de coordination. Il ne capture que l’instantané d’entrée et l’identifiant. Apple recommande les tâches enfants structurées lorsqu’elles permettent d’exprimer la durée de vie requise ; les tâches détachées exigent des décisions explicites concernant leur durée de vie et leur annulation. L’exemple précise également sa priorité. detached(name:priority:operation:).

Comment le nettoyage évite-t-il d’effacer le calcul suivant ?

La séquence de vérification et de stockage ne contient aucun await, de sorte qu’un second appel à l’acteur ne peut pas s’intercaler entre la vérification du créneau vide et la publication de la nouvelle exécution. Dès que le premier appelant est suspendu sur active.task.value, un autre appelant peut entrer, trouver cette exécution et attendre le même descripteur.

Le nettoyage nécessite sa propre vérification d’identité, car plusieurs appelants peuvent reprendre après la fin d’une même tâche à des moments différents. Considérez cette séquence :

Événement Créneau d’exécution
Les appelants A et B rejoignent l’exécution X X
X se termine ; A reprend et libère X Vide
L’appelant C entre et démarre l’exécution Y Y
B reprend après X et effectue le nettoyage Y doit rester

Une affectation inconditionnelle de nil par B effacerait le descripteur de Y. Un quatrième appelant pourrait alors démarrer un travail en double alors que Y est encore en cours. Comparer flight?.id à active.id rend le nettoyage spécifique à l’exécution réellement rejointe par chaque appelant. Cette séquence découle de l’implémentation et du comportement de réentrance documenté. Protect mutable state with Swift actors.

La limite exacte du partage est le créneau d’exécution stocké. Un worker peut déjà avoir terminé alors que ses appelants en attente n’ont pas encore repris pour libérer le créneau. Une requête qui entre pendant cet intervalle peut encore recevoir le résultat de cette tâche terminée. Une fois le créneau libéré, une nouvelle requête démarre une nouvelle exécution. Ce bref intervalle après la fin fait partie du contrat.

Que se passe-t-il si un appelant annule ou si le calcul échoue ?

Dans Swift, l’annulation est coopérative : le code doit décider où la vérifier et quoi faire. Cet exemple vérifie la tâche appelante avant de rejoindre l’exécution, puis à nouveau après avoir reçu avec succès le résultat partagé. Task — Task Cancellation.

Un appelant déjà annulé lors de la première vérification lève une erreur sans démarrer ni rejoindre de travail. Un appelant annulé pendant l’attente continue d’attendre le worker partagé ; après réception d’un résultat réussi, sa seconde vérification lève CancellationError. Les autres appelants peuvent toujours recevoir le résultat, car cette implémentation ne transmet jamais au worker l’annulation individuelle d’un appelant.

Cette stratégie ne permet pas d’interrompre immédiatement l’attente. Si tous les appelants annulent, le worker termine tout de même son exécution. C’est un choix délibéré pour ce calcul local limité. Une conception exigeant un départ individuel rapide nécessiterait un suivi supplémentaire des appelants et une règle clairement définie pour déterminer quand le worker partagé doit s’arrêter.

Une entrée non valide amène le worker à lever CalculationError.invalidInput. Les appelants qui partagent déjà cette tâche reçoivent son erreur via value, et leur nettoyage différé libère le créneau correspondant. Une requête ultérieure réessaie avec la même entrée immuable et échoue à nouveau. Aucun total n’est substitué en silence et aucun élément non valide n’est ignoré. value.

Il existe également un détail explicite concernant l’ordre des erreurs : si un appelant est annulé alors que le worker échoue, l’erreur du worker est propagée avant la seconde vérification d’annulation. Si le worker réussit, la vérification d’annulation de l’appelant détermine si celui-ci renvoie le résultat.

Comment vérifier et utiliser cette approche dans un utilitaire Mac ?

Des totaux égaux ne prouvent pas que l’exécution est partagée : trois calculs distincts de cette entrée produiraient aussi 30. Comparez les identifiants d’exécution pour vérifier le chevauchement, et vérifiez séparément le résultat numérique.

Pour un test de concurrence déterministe, remplacez la pause de démonstration par une barrière asynchrone contrôlable et instrumentez l’admission à l’exécution. Maintenez le worker en attente jusqu’à ce que tous les appelants visés l’aient rejoint, puis libérez-le. Vérifiez qu’un seul worker démarre, que les identifiants correspondent, que les totaux sont corrects, que les erreurs sont propagées et qu’une nouvelle exécution démarre après le nettoyage. Testez également la séquence de nettoyage du tableau. Augmenter la durée de la pause ne permet pas d’établir ces conditions de manière fiable.

Dans une application de bureau, gardez une instance du calculateur pour les composants qui utilisent la même entrée. Créer une nouvelle instance pour chaque panneau donne à chacun un créneau d’exécution indépendant. Lorsque l’entrée synthétique change, créez un nouveau calculateur pour cet instantané ; cet exemple ne définit ni les entrées mutables ni le partage par clé.

Appliquez les valeurs renvoyées à l’état de l’interface sur l’acteur principal. Le guide du langage Swift documente @MainActor pour les opérations d’interface ; le calculateur partagé peut continuer à coordonner son propre état séparément. Concurrency — The Swift Programming Language.

Pour une petite somme, la création d’une tâche constitue une surcharge inutile ; l’exemple rend visibles les règles de propriété afin qu’elles puissent être appliquées à un calcul local plus conséquent. Lors de l’adaptation, conservez la séquence essentielle : publier une tâche avant toute suspension, attendre ce descripteur et ne libérer que l’exécution terminée.

Sources