Per riprendere una continuazione Swift esattamente una volta, fai competere il completamento e la cancellazione attraverso un’unica macchina a stati sincronizzata. Memorizza la cancellazione anche prima dell’installazione, seleziona un solo risultato terminale e rilascia il lock prima di riprendere il task in attesa.

Questo tutorial crea un adattatore locale da callback ad async per un programma a riga di comando per Mac. Il calcolo di esempio raddoppia un intero; l’esempio non usa chiamate di rete, record o persistenza. Il contratto centrale è esplicito: vince il primo evento terminale che modifica lo stato protetto.

Quali garanzie deve offrire l’adattatore?

Una continuazione verificata fa da ponte tra callback sincrone e codice Swift asincrono. Apple richiede che ogni percorso di esecuzione la riprenda esattamente una volta. Se non viene ripresa, il task resta sospeso; i controlli in fase di esecuzione aiutano a individuare gli usi impropri, ma non implementano il coordinamento al posto tuo. CheckedContinuation

La cancellazione introduce un altro possibile responsabile di quella ripresa. In Swift la cancellazione è cooperativa: cancellare un task non interrompe automaticamente un’operazione arbitraria basata su callback né fa sì che una continuazione generi un errore. L’adattatore deve convertire esplicitamente la cancellazione nel risultato scelto. Task.cancel()

Per questo esempio, il contratto dei valori è il seguente:

  • Gli input da -1000 a 1000 producono il doppio del proprio valore.
  • Gli altri input producono CalculationError.outOfRange.
  • La cancellazione produce CancellationError se vince la transizione di stato.
  • Una volta selezionato un risultato terminale, gli eventi successivi di completamento o cancellazione non possono sostituirlo.
  • Ogni invocazione possiede un nuovo gate e installa una continuazione.

Questo significa che il completamento può vincere anche se la cancellazione avviene prima che il chiamante riceva il valore. Il momento decisivo è la transizione dello stato protetto, non il momento in cui il chiamante riprende l’esecuzione. È una politica applicativa scelta per l’esempio, non una garanzia universale sull’ordine degli eventi fornita da Swift.

Perché la cancellazione può arrivare prima dell’installazione?

Apple documenta che, se il task è già stato cancellato, il gestore di cancellazione viene eseguito prima della closure dell’operazione. L’operazione viene comunque eseguita. La cancellazione può anche avvenire in parallelo a un’operazione già in corso. Di conseguenza, un gestore che si limita a riprendere una continuazione opzionale perde la cancellazione se quell’opzionale non è ancora stato valorizzato. withTaskCancellationHandler(operation:onCancel:isolation:)

Il gate ha quindi bisogno di più informazioni del semplice fatto che al momento esista una continuazione. L’assenza di una continuazione può significare che l’installazione non è ancora avvenuta oppure che è già stato selezionato un risultato terminale. Questi casi richiedono comportamenti diversi.

Stato protetto Installazione della continuazione Ricezione di completamento o cancellazione
Vuoto Memorizza la continuazione Memorizza il risultato terminale
In attesa Rifiuta una seconda installazione Rimuove la continuazione e memorizza il risultato terminale
Terminato Riprende la continuazione appena installata con il risultato memorizzato Ignora l’evento terminale successivo

La tabella è anche uno strumento diagnostico. Se un’implementazione non può rappresentare un risultato terminale prima dell’installazione, controlla il percorso in cui il task è già stato cancellato. Se due eventi terminali possono entrambi estrarre la continuazione, controlla se la verifica e la rimozione avvengono nella stessa sezione critica.

Crea un esempio locale sul tuo Mac

Usa una toolchain Swift 6 con l’SDK di macOS. Controlla la toolchain selezionata in Terminal con swift --version. Il programma seguente usa solo Foundation e Dispatch; non richiede dipendenze di pacchetti. La guida di Swift per la riga di comando spiega come verificare l’installazione e usare un punto di ingresso asincrono @main. Build a Command-line Tool

Salva i tre blocchi Swift seguenti, nell’ordine indicato, in un unico file chiamato ContinuationRace.swift. Insieme formano un programma completo. Il primo blocco implementa il gate, il secondo fornisce il calcolo e l’adattatore, il terzo contiene le verifiche eseguibili. Swift non è disponibile nell’ambiente di ricerca, quindi il programma è stato esaminato ma non è stato compilato né eseguito qui.

Implementa il gate sincronizzato

Apple documenta NSLock come strumento per proteggere una sezione critica. Il blocco e lo sblocco devono avvenire sullo stesso thread e il lock non è ricorsivo. Qui ogni sezione critica resta all’interno di un metodo sincrono, non contiene punti di sospensione e termina prima della ripresa della continuazione. 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()))
    }
}

Il gate usa @unchecked Sendable perché il suo stato mutabile è protetto manualmente. Questa annotazione trasferisce la responsabilità all’implementazione; non aggiunge sincronizzazione. Entrambe le proprietà mutabili restano private e ogni accesso avviene sotto lo stesso lock. Apple descrive esplicitamente lo stato protetto da lock come un modo per soddisfare i requisiti semantici di Sendable non verificabile dal compilatore. Sendable

Nota che finish modifica lo stato prima di sbloccare. Un evento terminale concorrente può quindi osservare soltanto finished, anche se il thread vincitore non ha ancora chiamato resume. Il vincitore conserva localmente la continuazione estratta; nessuna chiamata concorrente può estrarla di nuovo.

Collega il completamento e la cancellazione allo stesso gate

Il calcolo tramite callback usa una coda di dispatch globale. Apple documenta che il lavoro inviato in modo asincrono procede separatamente dal codice che lo invia e che il lavoro di dispatch in background non ha un’identità di thread garantita. Per questo è opportuno usare qui un coordinamento indipendente dal thread. 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()
    }
}

Crea il gate prima di entrare nell’ambito del gestore di cancellazione. Un task già cancellato può così registrare subito la cancellazione. Quando la continuazione viene installata in seguito, l’installazione la riprende con l’errore memorizzato e restituisce false, quindi l’adattatore evita di avviare il calcolo.

La cancellazione può comunque avvenire dopo che l’installazione restituisce true e prima della chiamata a startDoubling. In quest’ordine, il calcolo inizia e termina, ma il suo risultato viene ignorato perché la cancellazione ha già vinto. Il wrapper annulla l’attesa; non promette di interrompere questo piccolo calcolo di durata limitata.

Il ponte supportato per le continuazioni è withCheckedThrowingContinuation; la sua closure riceve la continuazione usata per restituire un valore o generare un errore. L’esempio specifica i tipi del risultato intero e dell’errore della continuazione per evitare ambiguità nell’inferenza. withCheckedThrowingContinuation(isolation:function:_:)

Verifica l’ordine degli eventi senza affidarti alle attese temporizzate

Un ritardo non stabilisce quale evento abbia vinto una race. Le verifiche seguenti esercitano invece direttamente sul gate tutti e sei gli ordini possibili tra installazione, completamento e cancellazione. Ogni sequenza riceve inoltre altri eventi terminali per verificare che il risultato selezionato resti invariato.

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.")
    }
}

Compila ed esegui dalla directory che contiene il file:

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

L’output previsto in caso di successo è Event-order checks and integration checks passed. Le verifiche esplicite degli ordini convalidano le transizioni di stato. Il ciclo d’integrazione esercita l’adattatore reale, ma non garantisce che si verifichi una specifica intercalazione degli eventi né dimostra che sia stata testata ogni esecuzione concorrente.

Perché l’implementazione riprende esattamente una volta?

Per garantire che la ripresa avvenga al massimo una volta, considera la prima chiamata riuscita a finish. Sostituisce in modo atomico lo stato corrente con finished. Se c’è una continuazione in attesa, solo quella chiamata la estrae. Ogni chiamata successiva a finish ritorna senza riprendere nulla. Se non c’è ancora una continuazione, il risultato resta in attesa dell’unica installazione.

Per garantire che la ripresa avvenga, ci sono due percorsi. L’installazione avviene per prima e il primo evento terminale riprende la continuazione in attesa; oppure avviene prima un evento terminale e l’installazione riprende la continuazione con il risultato memorizzato. Questo ragionamento presuppone che l’installazione avvenga una sola volta e che si verifichi almeno un evento terminale. Una sorgente generica di callback che non completa mai, associata a un task che non viene mai cancellato, lascerebbe comunque il chiamante in attesa.

La callback dell’esempio esegue un calcolo di durata limitata e chiama il completamento sia in caso di successo sia in caso di input non valido. Le callback terminali duplicate vengono intenzionalmente ignorate dopo la vittoria del primo evento. Questo comportamento è adatto al contratto specificato, che prevede un singolo risultato; uno stream di valori significativi richiederebbe un’interfaccia diversa.

Quali scorciatoie violano il contratto?

Verificare una continuazione opzionale e poi impostarla a nil non basta se le due operazioni non sono sincronizzate. Due chiamanti concorrenti possono leggere entrambi la stessa continuazione non-nil prima che uno dei due la rimuova. La verifica, l’estrazione e la modifica dello stato terminale devono costituire un’unica operazione protetta.

Anche controllare la cancellazione prima di creare la continuazione lascia una finestra: la cancellazione può arrivare subito dopo il controllo. Il gestore e lo stato terminale memorizzato chiudono questa finestra. La guida del linguaggio Swift descrive la cancellazione come cooperativa e indica i gestori di cancellazione come meccanismo per ricevere una notifica immediata. Concurrency — The Swift Programming Language

Riprendere la continuazione mantenendo bloccato il lock condiviso introduce un altro problema. Apple avverte esplicitamente che i gestori di cancellazione possono essere eseguiti mentre sono acquisiti lock interni del task e che la ripresa della continuazione può acquisire quegli stessi lock. Per questo l’esempio sblocca prima di ogni chiamata a resume. Cancellation handlers and locks

Infine, aggiungere Task.checkCancellation() dopo il ritorno della continuazione cambierebbe il contratto esterno di questo esempio: la cancellazione potrebbe sostituire un valore anche dopo che il completamento ha vinto il gate. Scegli deliberatamente questa politica. Nel contratto implementato qui, installazione, completamento e cancellazione usano tutti la stessa macchina a stati, e la prima transizione terminale determina il risultato del chiamante.

Fonti