tva
← Approfondimenti

Concorrenza in Swift 6: guida pratica alla migrazione

Swift 5 era permissivo con le condizioni di competizione sui dati (data race). Il compilatore ne segnalava alcune e ne ignorava altre, così nei progetti si erano consolidate abitudini — chiamare DispatchQueue.main.async in alcuni punti, dimenticarsene in altri — che funzionavano finché non emergeva un problema. Swift 6 cambia le regole: tutti gli avvisi sull’isolamento degli actor e sulla conformità a Sendable diventano errori bloccanti e la compilazione non procede finché il rischio di una condizione di competizione sui dati non viene eliminato o esplicitamente riconosciuto.

In realtà, nella maggior parte delle app SwiftUI esistenti non si nascondono condizioni di competizione catastrofiche. Si trovano invece pattern sicuri nella pratica, ma tecnicamente non corretti secondo il modello di concorrenza di Swift. La migrazione serve meno a correggere bug reali che a rendere comprensibili al compilatore le garanzie di sicurezza del codice: un esercizio utile, che richiede però di capire cosa indicano davvero gli errori.

Attivare i controlli rigorosi sulla concorrenza

Prima di passare alla modalità del linguaggio Swift 6, è utile vedere in anticipo gli errori sotto forma di avvisi. In Xcode, imposta SWIFT_STRICT_CONCURRENCY su complete nelle impostazioni del compilatore Swift per il tuo target. Vedrai tutto ciò che Swift 6 rifiuterebbe, senza bloccare la compilazione. Affronta gli avvisi in modo sistematico prima di cambiare la versione del linguaggio.

Per attivare la modalità del linguaggio Swift 6 nelle impostazioni di compilazione del target, imposta SWIFT_VERSION su 6. A quel punto il controllo rigoroso della concorrenza complete è implicito e tutti gli avvisi diventano errori. In un progetto con più pacchetti conviene procedere un target alla volta, iniziando dai pacchetti con meno dipendenze dall’interfaccia: prima quelli dedicati alla logica e alla rete, poi quelli dell’interfaccia.

Il problema dei confini di isolamento di MainActor

La categoria di errori più comune nella migrazione di SwiftUI si presenta così:

// Error: Main actor-isolated property 'items' can not be
// mutated from a nonisolated context
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() {
        Task {
            let result = await API.load()
            self.items = result  // ← error
        }
    }
}

Le proprietà @Published di un ObservableObject non sono automaticamente isolate con @MainActor, ma SwiftUI le osserva sul thread principale. Quando un Task {} viene eseguito su un esecutore non specificato e tenta di scrivere in items, Swift 6 rileva la possibile condizione di competizione. Per la classe esattamente come è scritta, il compilatore Swift 6 segnala un rischio di data race nella closure di Task che cattura self; il messaggio sull’isolamento sul main actor riportato nel commento compare solo quando items stessa è contrassegnata con @MainActor.

La soluzione più pulita è annotare l’intera classe con @MainActor:

@MainActor
class DataStore: ObservableObject {
    @Published var items: [Item] = []

    func fetch() async {
        let result = await API.load()
        items = result  // ✓ — entire class is MainActor-isolated
    }
}

Questo rende esplicito l’isolamento della classe. Chi chiama fetch() da un contesto in background dovrà usare await: è corretto, perché per interagire con una classe che gestisce gli aggiornamenti dell’interfaccia occorre essere sul main actor. Se non puoi annotare l’intera classe, puoi passare esplicitamente al main actor:

func fetch() {
    Task {
        let result = await API.load()
        await MainActor.run {
            self.items = result
        }
    }
}

Entrambi gli approcci sono validi. L’annotazione @MainActor sull’intera classe è più pulita quando lo scopo principale della classe è gestire le viste SwiftUI, come accade quasi sempre per le classi ObservableObject in un’app ben strutturata.

PhotosPicker e isolamento dei delegate

PHPickerViewControllerDelegate è una fonte comune di errori in Swift 6. Negli SDK attuali, il metodo del delegate è isolato sul main actor, ma la callback di caricamento dell’item provider al suo interno arriva su una coda in background e il codice di quella callback deve in genere aggiornare l’interfaccia. Il classico pattern in Swift 5:

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
    dismiss(animated: true)
    guard let provider = results.first?.itemProvider else { return }

    provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
        if let image = object as? UIImage {
            self?.imageView.image = image  // ← Swift 6 error
        }
    }
}

In Swift 6, la closure passata a loadObject è @Sendable, quindi non può catturare self se self non è Sendable. Un UIViewController soddisfa questo requisito perché le classi isolate sul main actor sono implicitamente Sendable; il vero errore è l’accesso a imageView.image, una proprietà isolata con @MainActor, da un contesto in background.

La soluzione è passare al main actor all’interno della closure:

provider.loadObject(ofClass: UIImage.self) { [weak self] object, error in
    guard let image = object as? UIImage else { return }
    Task { @MainActor [weak self] in
        self?.imageView.image = image
    }
}

La sintassi Task { @MainActor in } crea un task esplicitamente isolato sul main actor. La cattura di image è consentita perché UIImage è conforme a Sendable. La cattura di self tramite [weak self] è consentita perché un view controller è isolato sul main actor e quindi è Sendable; weak evita solo un ciclo di ritenzione e non renderebbe sicura la cattura di un tipo non Sendable.

Con PhotosPicker di SwiftUI, l’API più recente, il pattern è più pulito perché usa Transferable:

// In a @MainActor view model:
func handlePickerSelection(_ item: PhotosPickerItem?) async {
    guard let item else { return }
    if let data = try? await item.loadTransferable(type: Data.self) {
        selectedImage = UIImage(data: data)
    }
}

Poiché la chiamata a loadTransferable è async, si usa await e l’assegnazione avviene sull’actor su cui è isolata la funzione: @MainActor, se il view model ha questa annotazione. Non è necessario passare manualmente da un actor all’altro.

Tipi Sendable e non Sendable

Un tipo è Sendable se può essere trasferito in sicurezza oltre i confini di isolamento degli actor: in linea generale, se è un tipo valore oppure una classe che protegge il proprio stato tramite sincronizzazione. In Swift 6 il compilatore impone questo requisito ogni volta che un valore attraversa un confine di isolamento.

Il pattern di errore più frequente:

// Error: Capture of 'config' with non-sendable type 'RequestConfig'
// in a '@Sendable' closure
struct RequestConfig {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Task {
    let result = await API.fetch(config)  // config captured here
}

La soluzione di solito è semplice: aggiungere la conformità a Sendable al tipo valore:

struct RequestConfig: Sendable {
    var endpoint: String
    var headers: [String: String]
    var body: Data?
}

Per le struct i cui membri sono tutti Sendable, inclusi String, Data e tutti i tipi valore standard, questa conformità è soddisfatta senza ulteriori interventi. Il compilatore la deduce implicitamente per le struct non pubbliche, quindi in pratica questo errore si presenta per i tipi public usati da un altro modulo; una dichiarazione esplicita : Sendable viene verificata dal compilatore. Per le classi la situazione è più complessa: il compilatore può verificare la conformità a Sendable solo per una classe final le cui proprietà memorizzate siano tutte costanti let di tipi Sendable, oppure per una classe isolata su un attore globale come @MainActor. Una classe che protegge lo stato mutabile tramite una propria sincronizzazione interna richiede @unchecked Sendable, perché il compilatore non può verificare i meccanismi di blocco. Per un ObservableObject a cui si accede sempre sul main actor, preferisci @MainActor, che lo rende automaticamente Sendable; considera @unchecked Sendable come ultima risorsa, non come prima soluzione.

SharedDataProvider e stato condiviso tra view model

Un pattern comune nelle app SwiftUI più grandi è un provider di dati condiviso, iniettato in più viste tramite l’ambiente. In Swift 6 questo oggetto richiede un isolamento esplicito su un actor perché vi si accede dai corpi delle viste, che vengono eseguiti sul main actor:

@MainActor
class SharedDataProvider: ObservableObject {
    static let shared = SharedDataProvider()

    @Published var currentUser: User?
    @Published var sessionToken: String?

    func refreshSession() async {
        let token = await AuthClient.refresh()
        sessionToken = token
    }
}

Qui l’annotazione @MainActor ha una funzione concreta: garantisce che tutti gli accessi a currentUser e sessionToken siano serializzati attraverso il main actor, l’isolamento corretto per le proprietà che determinano gli aggiornamenti delle viste SwiftUI. I task in background che devono scrivere in questa classe devono usare await: devono attendere che il main actor sia disponibile anziché scrivere contemporaneamente.

Il dettaglio da considerare è che static let shared su una classe @MainActor è accessibile solo dal main actor. Swift 6 rifiuta qualsiasi percorso di codice che tenti di accedere a SharedDataProvider.shared da un contesto in background. La soluzione è passare esplicitamente l’istanza lungo la catena di funzioni (l’accesso alle sue proprietà dall’esterno del main actor richiede comunque await) oppure racchiudere l’accesso in await MainActor.run { ... }.

La via d’uscita di @unchecked Sendable

In pratica, alcune classi non possono essere rese completamente Sendable: possono incapsulare librerie C, interfacciarsi con tipi Objective-C o avere uno stato interno gestito in sicurezza ma in modo opaco. In questi casi, @unchecked Sendable dice al compilatore «fidati, ho gestito la sincronizzazione»:

final class AudioPlayer: @unchecked Sendable {
    private let queue = DispatchQueue(label: "audio.player")
    private var _isPlaying = false

    var isPlaying: Bool {
        queue.sync { _isPlaying }
    }

    func play() {
        queue.async { self._isPlaying = true }
    }
}

Non è una scorciatoia per evitare il lavoro di migrazione. È una dichiarazione esplicita: hai valutato la semantica della concorrenza e te ne assumi la responsabilità. Usala per le classi che richiedono davvero una sincronizzazione interna e aggiungi un commento che spieghi quale meccanismo garantisce la sicurezza.

Un ordine pratico per la migrazione

Attiva prima SWIFT_STRICT_CONCURRENCY = complete e risolvi tutti gli avvisi prima di passare alla modalità del linguaggio Swift 6. Inizia dai pacchetti foglia del grafo delle dipendenze, quelli che non dipendono da altri moduli sviluppati internamente. Correggili, aggiornali alla modalità Swift 6 e passa al livello successivo. Così eviti che la correzione di un pacchetto dipendente richieda modifiche simultanee a un pacchetto da cui dipende, ancora in modalità avvisi.

In un tipico progetto SwiftUI, la maggior parte del lavoro di migrazione consiste nell’aggiungere annotazioni @MainActor alle classi ObservableObject, la conformità a Sendable ai tipi valore passati tra task e passaggi espliciti tra actor nelle callback dei delegate. Nel diff le modifiche sembrano molte, ma ciascuna è meccanica. Il compilatore indica ogni punto che richiede attenzione: è proprio lo scopo dei controlli rigorosi sulla concorrenza.

Approfondimenti correlati

Per approfondire