Pour reprendre une continuation Swift exactement une fois, faites concourir l’achèvement et l’annulation au moyen d’une seule machine à états synchronisée. Mémorisez l’annulation même avant l’installation, choisissez un seul résultat terminal et libérez le verrou avant de reprendre la tâche en attente.

Ce tutoriel construit un adaptateur local de rappels vers l’asynchrone pour un programme en ligne de commande sur Mac. Son calcul synthétique double un entier ; l’exemple n’utilise ni appels réseau, ni enregistrements, ni persistance. Le contrat central est explicite : le premier événement terminal à modifier l’état protégé l’emporte.

Que doit garantir l’adaptateur ?

Une continuation vérifiée fait le lien entre les rappels synchrones et le code Swift asynchrone. Apple exige que chaque chemin d’exécution la reprenne exactement une fois. Si elle n’est pas reprise, la tâche reste suspendue ; la vérification à l’exécution aide à détecter les mauvais usages, mais ne met pas en œuvre la coordination à votre place. CheckedContinuation

L’annulation ajoute un autre acteur susceptible de reprendre la continuation. L’annulation en Swift est coopérative : annuler une tâche n’arrête pas automatiquement une opération de rappel arbitraire et ne fait pas lever une erreur à une continuation. L’adaptateur doit explicitement convertir l’annulation en résultat choisi. Task.cancel()

Pour cet exemple, le contrat des valeurs est le suivant :

  • Les entrées de -1000 à 1000 produisent le double de leur valeur.
  • Les autres entrées produisent CalculationError.outOfRange.
  • L’annulation produit CancellationError si elle l’emporte dans la transition d’état.
  • Une fois un résultat terminal choisi, les événements ultérieurs d’achèvement ou d’annulation ne peuvent pas le remplacer.
  • Chaque appel possède sa propre barrière et installe une continuation.

Cela signifie que l’achèvement peut l’emporter même si l’annulation survient avant que l’appelant reçoive la valeur. Le moment décisif est la transition d’état protégée, et non le moment où l’appelant reprend ensuite son exécution. Il s’agit d’une politique choisie pour cet exemple, et non d’une garantie universelle d’ordre fournie par Swift.

Pourquoi l’annulation peut-elle survenir avant l’installation ?

Apple indique que le gestionnaire d’annulation s’exécute avant la fermeture d’opération si la tâche est déjà annulée. L’opération s’exécute tout de même. L’annulation peut également se produire en parallèle d’une opération déjà en cours. Par conséquent, un gestionnaire qui ne fait que reprendre une continuation facultative perd l’annulation si cette valeur facultative n’a pas encore été renseignée. withTaskCancellationHandler(operation:onCancel:isolation:)

La barrière doit donc mémoriser davantage que la présence actuelle d’une continuation. L’absence de continuation peut signifier que l’installation n’a pas encore eu lieu ou qu’un résultat terminal a déjà été choisi. Ces cas exigent des comportements différents.

État protégé Installer la continuation Recevoir un achèvement ou une annulation
Vide Stocker la continuation Stocker le résultat terminal
En attente Refuser une seconde installation Retirer la continuation et stocker le résultat terminal
Terminé Reprendre la continuation nouvellement installée avec le résultat stocké Ignorer l’événement terminal ultérieur

Ce tableau sert aussi d’outil de diagnostic. Si une implémentation ne peut pas représenter un résultat terminal avant l’installation, examinez son chemin d’exécution lorsque la tâche est déjà annulée. Si deux événements terminaux peuvent tous deux récupérer la continuation, vérifiez si la vérification et le retrait ont lieu dans une même section critique.

Construire un exemple local sur votre Mac

Utilisez une chaîne d’outils Swift 6 avec le SDK macOS. Vérifiez la chaîne sélectionnée dans Terminal avec swift --version. Le programme ci-dessous utilise uniquement Foundation et Dispatch ; il ne nécessite aucune dépendance de paquet. Le guide de Swift sur la ligne de commande explique comment vérifier l’installation et utiliser un point d’entrée asynchrone @main. Build a Command-line Tool

Enregistrez les trois blocs Swift suivants, dans l’ordre, dans un seul fichier nommé ContinuationRace.swift. Ensemble, ils forment un programme complet. Le premier bloc implémente la barrière, le deuxième fournit le calcul et l’adaptateur, et le troisième fournit des vérifications exécutables. Swift n’est pas disponible dans l’environnement de recherche ; le programme a donc été inspecté, mais n’a pas été compilé ni exécuté ici.

Implémenter la barrière synchronisée

La documentation de Foundation décrit NSLock comme un moyen de protéger une section critique. Le verrouillage et le déverrouillage doivent avoir lieu sur le même thread, et le verrou n’est pas récursif. Ici, chaque section critique reste dans une méthode synchrone, ne contient aucun point de suspension et se termine avant la reprise de la continuation. NSLock

import Foundation
import Dispatch

final class IntContinuationGate: @unchecked Sendable {
    private enum State {
        case empty
        case waiting(CheckedContinuation<Int, any Error>)
        case finished(Result<Int, any Error>)
    }

    private let lock = NSLock()
    private var state: State = .empty
    private var didInstall = false

    // Returns false if a terminal event already won.
    func install(
        _ continuation: CheckedContinuation<Int, any Error>
    ) -> Bool {
        lock.lock()
        precondition(!didInstall, "Install exactly once per gate")
        didInstall = true

        switch state {
        case .empty:
            state = .waiting(continuation)
            lock.unlock()
            return true

        case .waiting:
            preconditionFailure("Unexpected second installation")

        case .finished(let result):
            lock.unlock()
            continuation.resume(with: result)
            return false
        }
    }

    func finish(_ result: Result<Int, any Error>) {
        let continuation: CheckedContinuation<Int, any Error>?

        lock.lock()
        switch state {
        case .empty:
            continuation = nil
            state = .finished(result)

        case .waiting(let waiting):
            continuation = waiting
            state = .finished(result)

        case .finished:
            lock.unlock()
            return
        }
        lock.unlock()

        continuation?.resume(with: result)
    }

    func cancel() {
        finish(.failure(CancellationError()))
    }
}

La barrière utilise @unchecked Sendable, car son stockage mutable est protégé manuellement. Cette annotation transfère la responsabilité à l’implémentation ; elle n’ajoute aucune synchronisation. Les deux propriétés mutables restent privées, et chaque accès a lieu sous le même verrou. Apple décrit explicitement l’état protégé par verrou comme un moyen de satisfaire aux exigences sémantiques de l’envoi non vérifié. Sendable

Notez que finish modifie l’état avant le déverrouillage. Un événement terminal concurrent ne peut alors observer que finished, même si le thread gagnant n’a pas encore appelé resume. Le gagnant conserve localement la continuation extraite ; aucun appel concurrent ne peut l’extraire à nouveau.

Relier l’achèvement et l’annulation à la même barrière

Le calcul de rappel utilise une file de dispatch globale. Apple indique que le travail soumis de façon asynchrone se poursuit indépendamment du code qui l’a soumis, et que le travail de dispatch en arrière-plan n’a aucune identité de thread garantie. Une coordination indépendante des threads est donc adaptée ici. DispatchQueue

enum CalculationError: Error {
    case outOfRange
}

func startDoubling(
    _ value: Int,
    completion: @escaping @Sendable (Result<Int, any Error>) -> Void
) {
    DispatchQueue.global().async {
        guard (-1000...1000).contains(value) else {
            completion(.failure(CalculationError.outOfRange))
            return
        }
        completion(.success(value * 2))
    }
}

func doubled(_ value: Int) async throws -> Int {
    let gate = IntContinuationGate()

    return try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            guard gate.install(continuation) else { return }

            startDoubling(value) { result in
                gate.finish(result)
            }
        }
    } onCancel: {
        gate.cancel()
    }
}

Créez la barrière avant d’entrer dans la portée du gestionnaire d’annulation. Une tâche déjà annulée peut ainsi enregistrer immédiatement l’annulation. Lorsque la continuation est installée ensuite, l’installation la reprend avec cette erreur mémorisée et renvoie false, ce qui fait que l’adaptateur ne démarre pas le calcul.

L’annulation peut tout de même survenir après que l’installation a renvoyé true et avant l’appel à startDoubling. Dans cet ordre, le calcul démarre et se termine, mais son résultat est ignoré parce que l’annulation l’a déjà emporté. L’enveloppe annule l’attente ; elle ne garantit pas l’arrêt de ce petit calcul à durée limitée.

Le pont de continuation pris en charge est withCheckedThrowingContinuation ; sa fermeture reçoit la continuation utilisée pour renvoyer une valeur ou lever une erreur. L’exemple indique explicitement les types de résultat entier et d’erreur de la continuation afin que l’inférence soit sans ambiguïté. withCheckedThrowingContinuation(isolation:function:_:)

Vérifier l’ordre des événements sans dépendre de délais

Un délai ne permet pas d’établir quel événement a remporté une course. Les vérifications suivantes font plutôt passer directement par la barrière les six ordres possibles d’installation, d’achèvement et d’annulation. Chaque séquence reçoit également des événements terminaux supplémentaires pour vérifier que le résultat choisi reste inchangé.

enum GateEvent {
    case install
    case complete
    case cancel
}

func checkOrder(
    _ events: [GateEvent],
    expected: Int?
) async throws {
    let gate = IntContinuationGate()

    do {
        let value: Int = try await withCheckedThrowingContinuation {
            (continuation: CheckedContinuation<Int, any Error>) in
            for event in events {
                switch event {
                case .install:
                    _ = gate.install(continuation)
                case .complete:
                    gate.finish(.success(42))
                case .cancel:
                    gate.cancel()
                }
            }

            // Neither of these may replace the selected result.
            gate.finish(.success(99))
            gate.cancel()
        }

        guard let expected else {
            preconditionFailure("Expected cancellation")
        }
        precondition(value == expected, "Unexpected value")
    } catch is CancellationError {
        precondition(expected == nil, "Unexpected cancellation")
    }
}

@main
struct ContinuationRaceDemo {
    static func main() async throws {
        try await checkOrder(
            [.install, .complete, .cancel], expected: 42
        )
        try await checkOrder(
            [.install, .cancel, .complete], expected: nil
        )
        try await checkOrder(
            [.complete, .install, .cancel], expected: 42
        )
        try await checkOrder(
            [.complete, .cancel, .install], expected: 42
        )
        try await checkOrder(
            [.cancel, .install, .complete], expected: nil
        )
        try await checkOrder(
            [.cancel, .complete, .install], expected: nil
        )

        let value = try await doubled(21)
        precondition(value == 42)

        do {
            _ = try await doubled(1001)
            preconditionFailure("Expected outOfRange")
        } catch CalculationError.outOfRange {
            // Expected input-domain error.
        }

        // Integration smoke check: either terminal outcome is valid.
        for _ in 0..<100 {
            let task = Task { try await doubled(21) }
            task.cancel()
            do {
                let result = try await task.value
                precondition(result == 42)
            } catch is CancellationError {
                // Cancellation won.
            }
        }

        print("Event-order checks and integration checks passed.")
    }
}

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

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

La sortie attendue en cas de réussite est Event-order checks and integration checks passed. Les vérifications d’ordre explicites valident les transitions d’état. La boucle d’intégration sollicite l’adaptateur réel, mais elle ne garantit pas qu’un entrelacement d’exécution particulier se produise et ne prouve pas que toutes les exécutions concurrentes ont été testées.

Pourquoi l’implémentation reprend-elle exactement une fois ?

Pour garantir une reprise au plus une fois, considérez le premier appel réussi à finish. Il remplace atomiquement l’état courant par finished. Si une continuation est en attente, cet appel est le seul à l’extraire. Chaque appel ultérieur à finish revient sans rien reprendre. Si aucune continuation n’existe encore, le résultat attend l’unique installation.

Pour garantir une reprise à terme, deux chemins sont possibles. L’installation a lieu en premier et le premier événement terminal reprend la continuation en attente ; ou un événement terminal survient en premier et l’installation reprend la continuation avec le résultat mémorisé. Ce raisonnement suppose que l’installation a lieu une fois et qu’au moins un événement terminal se produit. Une source de rappels générale qui ne se termine jamais, associée à une tâche qui n’est jamais annulée, laisserait malgré tout l’appelant en attente.

Le rappel de cet exemple effectue un calcul à durée limitée et appelle la fonction d’achèvement aussi bien en cas de réussite qu’en cas d’erreur d’entrée. Les rappels terminaux en double sont volontairement ignorés après le premier. Cela convient au contrat spécifié, qui ne prévoit qu’un seul résultat ; un flux de valeurs significatives nécessiterait une autre interface.

Quels raccourcis rompent le contrat ?

Vérifier si une continuation facultative existe puis lui affecter nil ne suffit pas lorsque ces opérations ne sont pas synchronisées. Deux appels concurrents peuvent tous deux lire la même continuation non nil avant que l’un ou l’autre ne la retire. La vérification, l’extraction et la modification de l’état terminal doivent former une seule opération protégée.

Vérifier l’annulation avant de créer la continuation laisse également une faille : l’annulation peut survenir juste après cette vérification. Le gestionnaire et l’état terminal mémorisé comblent cette faille. Le guide de Swift décrit l’annulation comme coopérative et présente les gestionnaires d’annulation comme le mécanisme de notification immédiate. Concurrency — The Swift Programming Language

Reprendre la continuation en détenant le verrou partagé crée un autre problème. Apple avertit expressément que les gestionnaires d’annulation peuvent s’exécuter alors que des verrous internes de tâche sont détenus, et que la reprise d’une continuation peut acquérir ces verrous. L’exemple déverrouille donc avant chaque appel à resume. Cancellation handlers and locks

Enfin, ajouter Task.checkCancellation() après le retour de la continuation modifierait le contrat externe de cet exemple : l’annulation pourrait remplacer une valeur même après que l’achèvement a remporté la barrière. Choisissez délibérément cette politique. Pour le contrat implémenté ici, l’installation, l’achèvement et l’annulation utilisent tous une même machine à états, et sa première transition terminale détermine le résultat de l’appelant.

Sources