Introduzione
Ammettiamolo: la concorrenza in Swift è stata un incubo per molti di noi. Con Swift 6.0, Apple ha introdotto lo strict concurrency checking e il risultato? Un diluvio di errori del compilatore su Sendable, isolation e data race che ha fatto desistere parecchi sviluppatori. Codice che funzionava perfettamente da anni, di colpo, non compilava più. La frustrazione era palpabile.
Con Swift 6.2 e Xcode 26, Apple ha cambiato radicalmente approccio. L'idea di fondo si chiama Approachable Concurrency — concorrenza accessibile — e il principio è disarmante nella sua semplicità: il codice dovrebbe essere single-threaded per impostazione predefinita, e la concorrenza va introdotta solo quando serve davvero. Niente più annotazioni sparse ovunque, niente più battaglie con il compilatore per far compilare un'app che gira tutta sul main thread.
In questa guida vediamo nel dettaglio come funziona: dalla configurazione in Xcode alla migrazione di progetti esistenti, passando per @concurrent, nonisolated(nonsending) e tutte le trappole da evitare. Con esempi di codice pronti da copiare e incollare.
Il Problema: Perché Swift 6.0 Era Troppo Rigido
Per capire l'Approachable Concurrency, bisogna partire dal problema che risolve.
In Swift 6.0, la modalità di concorrenza rigorosa trattava tutto il codice non annotato come potenzialmente concorrente. Anche un'app banale con una singola schermata SwiftUI poteva generare decine di warning o errori. Onestamente, era frustrante.
Considerate questo scenario tipico:
class DataManager {
var items: [String] = []
func loadItems() async {
let fetched = await fetchFromNetwork()
items = fetched // Warning: potenziale data race!
}
func fetchFromNetwork() async -> [String] {
// ...
return ["Elemento 1", "Elemento 2"]
}
}
Con Swift 6.0 rigoroso, questo codice generava warning perché il compilatore non sapeva su quale thread sarebbe stato eseguito. La soluzione? Aggiungere @MainActor ovunque, rendere tutto Sendable, oppure convertire la classe in un actor. Per un'app semplice era un costo francamente sproporzionato.
Cos'è l'Approachable Concurrency
L'Approachable Concurrency non è solo un bel concetto filosofico: è un'impostazione concreta del compilatore in Xcode 26. Si basa su un documento di visione del team Swift e raggruppa diverse proposte — SE-0461, SE-0466, SE-0470 — sotto un unico ombrello.
Il principio guida è la progressive disclosure (divulgazione progressiva): Swift dovrebbe chiedervi di capire la concorrenza solo nella misura in cui la usate effettivamente. L'approccio si articola in tre fasi:
- Fase 1: Tutto sul main thread — Scrivete codice sincrono che gira sul
MainActor. Nessuna annotazione necessaria. Punto. - Fase 2: async/await — Quando vi servono operazioni che sospendono (chiamate di rete, I/O su disco), usate
async/await. Le funzioni async girano nel contesto del chiamante. - Fase 3: Concorrenza vera — Solo quando avete realmente bisogno di parallelismo (elaborazione pesante, task multipli), tirate fuori
@concurrente iniziate a ragionare su actor eSendable.
Il bello è che la maggior parte delle app non ha bisogno di andare oltre la fase 2. E Swift 6.2 finalmente lo riconosce.
Configurare l'Approachable Concurrency in Xcode 26
I nuovi progetti creati con Xcode 26 hanno già le impostazioni giuste. Ma se lavorate su un progetto esistente (e immagino che la maggior parte di voi sia in questa situazione), dovete abilitarle manualmente.
Impostazioni del Build Settings
Aprite il vostro progetto in Xcode 26 e cercate queste due impostazioni nei Build Settings del target:
- Approachable Concurrency (
SWIFT_APPROACHABLE_CONCURRENCY): impostate su Yes - Default Actor Isolation: impostate su MainActor
Attenzione: sono due impostazioni separate ed è importante capire cosa fa ciascuna:
// Con Default Actor Isolation = MainActor
// Questo codice è IMPLICITAMENTE isolato sul MainActor:
class ViewModel: ObservableObject {
@Published var titolo = "Ciao"
// Non serve @MainActor qui - è già il default!
func aggiornaTitolo(_ nuovo: String) {
titolo = nuovo
}
}
Configurazione nei Swift Package
Per i package Swift, la configurazione avviene nel file Package.swift. Aggiornate prima la versione degli strumenti a 6.2:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "MioPackage",
platforms: [.iOS(.v26)],
targets: [
.target(
name: "MioPackage",
swiftSettings: [
.defaultIsolation(MainActor.self),
.enableUpcomingFeature("NonisolatedNonsendingByDefault"),
.enableUpcomingFeature("InferIsolatedConformances")
]
)
]
)
Un consiglio pratico: per i package di rete o utilità generica, probabilmente non volete il MainActor come isolamento di default. Ha senso per i target dell'app (che contengono codice UI), ma decisamente meno per librerie pensate per essere usate da qualsiasi contesto.
Default Actor Isolation: MainActor per Tutto
Il cuore dell'Approachable Concurrency è la proposta SE-0466: l'isolamento dell'actor di default. Vediamo cosa cambia nella pratica.
Come funziona
Con Default Actor Isolation = MainActor, il compilatore tratta tutto il codice senza annotazione esplicita come se fosse marcato con @MainActor. Classi, struct, enum, funzioni libere, proprietà — tutto quanto.
// PRIMA (Swift 6.0/6.1): serviva annotazione esplicita
@MainActor
class GestoreUtente {
var nome: String = ""
func aggiornaUI() {
// ...
}
}
// DOPO (Swift 6.2 con Default Isolation = MainActor):
// Nessuna annotazione necessaria - è già MainActor!
class GestoreUtente {
var nome: String = ""
func aggiornaUI() {
// Gira automaticamente sul MainActor
}
}
La differenza è enorme in termini di codice scritto. Per un'app con decine di ViewModel, View e Manager, eliminare centinaia di @MainActor rende il codice molto più pulito. Personalmente, è la feature che mi ha fatto tirare un sospiro di sollievo.
Sendable viene inferito automaticamente
Un effetto collaterale molto gradito: quando un tipo è isolato sul MainActor (implicitamente o esplicitamente), il protocollo Sendable viene inferito automaticamente. Addio a una delle fonti principali di errori del compilatore in Swift 6.0.
// Con Default Isolation = MainActor
struct Configurazione {
var tema: String = "chiaro"
var lingua: String = "it"
}
// Configurazione è automaticamente Sendable
// perché è isolata sul MainActor
nonisolated(nonsending): Il Nuovo Default per le Funzioni Async
La proposta SE-0461 introduce un cambiamento fondamentale nel modo in cui le funzioni async non isolate vengono eseguite. È forse la modifica più sottile dell'Approachable Concurrency, ma anche quella con l'impatto maggiore.
Il vecchio comportamento (Swift 6.1)
In Swift 6.1 e precedenti, una funzione marcata nonisolated e async veniva eseguita sul global executor — cioè su un thread in background. Questo creava implicitamente un nuovo dominio di isolamento e richiedeva che tutti i dati passati fossero Sendable.
// Comportamento Swift 6.1:
nonisolated func elaboraDati() async {
// Gira sul global executor (thread in background)
// Crea un nuovo dominio di isolamento
// Richiede che i parametri siano Sendable
}
Il nuovo comportamento (Swift 6.2)
Con Swift 6.2, il default diventa nonisolated(nonsending): la funzione gira sull'executor del chiamante. Se viene chiamata dal MainActor, gira sul MainActor. Se viene chiamata da un altro actor, gira su quell'actor. Semplice, no?
// Comportamento Swift 6.2:
// nonisolated(nonsending) è il default implicito
nonisolated func elaboraDati() async {
// Gira sull'executor del CHIAMANTE
// Non crea un nuovo dominio di isolamento
// Non richiede Sendable per i parametri
}
// Equivale a scrivere esplicitamente:
nonisolated(nonsending) func elaboraDati() async {
// Stesso comportamento
}
Perché è importante? Perché unifica il comportamento tra funzioni sincrone e asincrone. Le funzioni sincrone hanno sempre girato nel contesto del chiamante. Ora anche quelle asincrone lo fanno, a meno che non si specifichi diversamente. Una di quelle cose che ti fanno dire "ma perché non l'hanno fatto subito così?".
@concurrent: Quando Serve il Thread in Background
Ok, se con nonisolated(nonsending) tutto gira nel contesto del chiamante, come si fa a spostare lavoro pesante su un thread in background? È qui che entra in gioco @concurrent.
Quando usare @concurrent
Usate @concurrent quando una funzione esegue lavoro intensivo sulla CPU che bloccherebbe il main thread: decodifica di JSON corposi, elaborazione di immagini, calcoli complessi, parsing di file pesanti.
// Funzione che esegue lavoro pesante sulla CPU
@concurrent
func decodificaImmagine(dati: Data) async throws -> UIImage {
// Gira SEMPRE sul global executor (thread in background)
// Richiede che i parametri siano Sendable
guard let immagine = UIImage(data: dati) else {
throw ErroreImmagine.decodificaFallita
}
// Elaborazione costosa...
return immagine.applicaFiltri()
}
// Chiamata dal MainActor - l'elaborazione avviene in background
@MainActor
func caricaFotoProfilo() async {
let dati = await scaricaDatiDaRete()
let immagine = try? await decodificaImmagine(dati: dati)
// Torna automaticamente sul MainActor
fotoProfilo = immagine
}
Tabella riassuntiva
Ecco un confronto rapido per orientarsi tra le varie opzioni:
| Annotazione | Dove gira | Quando usarla |
|---|---|---|
nonisolated(nonsending) | Executor del chiamante | Default in Swift 6.2; per funzioni che non toccano stato di actor |
@concurrent | Global executor (background) | Lavoro CPU-intensive che non deve bloccare il chiamante |
@MainActor | Main actor | Codice UI; implicito con Default Isolation |
nonisolated (pre-6.2) | Global executor (background) | Comportamento legacy — da evitare |
Esempio Pratico: App SwiftUI con Approachable Concurrency
Basta teoria. Vediamo un esempio completo di come strutturare un'app SwiftUI sfruttando l'Approachable Concurrency. Useremo un'app per la gestione di appunti come caso d'uso.
import SwiftUI
// Con Default Isolation = MainActor, non serve annotare nulla
// Tutto il codice qui è implicitamente @MainActor
struct Appunto: Identifiable {
let id = UUID()
var titolo: String
var contenuto: String
var dataCreazione: Date
}
@Observable
class AppuntiViewModel {
var appunti: [Appunto] = []
var staCaricando = false
var errore: String?
func caricaAppunti() async {
staCaricando = true
defer { staCaricando = false }
do {
// Questa chiamata sospende ma torna sul MainActor
let dati = try await ServizioRete.scaricaAppunti()
// Decodifica pesante in background con @concurrent
appunti = try await decodificaAppunti(dati: dati)
} catch {
self.errore = error.localizedDescription
}
}
}
// Funzione marcata @concurrent per elaborazione in background
@concurrent
func decodificaAppunti(dati: Data) async throws -> [Appunto] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode([Appunto].self, from: dati)
}
struct AppuntiView: View {
@State private var viewModel = AppuntiViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.staCaricando {
ProgressView("Caricamento...")
} else {
List(viewModel.appunti) { appunto in
VStack(alignment: .leading) {
Text(appunto.titolo)
.font(.headline)
Text(appunto.contenuto)
.lineLimit(2)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("I Miei Appunti")
.task {
await viewModel.caricaAppunti()
}
}
}
}
Notate l'assenza totale di annotazioni @MainActor. Tutto il codice gira sul main thread per default, tranne decodificaAppunti che abbiamo esplicitamente marcato con @concurrent perché esegue un'elaborazione potenzialmente pesante. Meno boilerplate, stessa sicurezza.
Migrazione di Progetti Esistenti
Se avete un progetto già in produzione, la migrazione all'Approachable Concurrency richiede un po' di attenzione. Non basta attivare un interruttore e sperare per il meglio: alcuni comportamenti cambiano in modo sottile e potenzialmente problematico.
Strategia raccomandata: un passo alla volta
Apple consiglia di abilitare le funzionalità individualmente, non tutte insieme. Fidatevi, è un buon consiglio. Ecco l'ordine suggerito:
1. Abilitate Default Actor Isolation = MainActor
Questo è il cambiamento con meno effetti collaterali. Il vostro codice che già girava sul MainActor continuerà a farlo, e le annotazioni @MainActor esplicite diventeranno ridondanti (ma non causano errori).
2. Verificate i vostri actor personalizzati
Se avete tipi che girano su actor diversi dal MainActor, dovrete marcarli esplicitamente come nonisolated:
// Questo actor ha il suo dominio di isolamento
actor CacheManager {
var cache: [String: Data] = [:]
func salva(_ dati: Data, chiave: String) {
cache[chiave] = dati
}
}
// Tipo usato da CacheManager - deve essere nonisolated
// altrimenti il compilatore lo isola su MainActor
nonisolated struct ChiaveCache: Hashable {
let nome: String
let versione: Int
}
3. Abilitate NonisolatedNonsendingByDefault
Questo è il passo più delicato. Le funzioni async nonisolated che prima giravano in background ora gireranno nel contesto del chiamante. Se avete funzioni che fanno lavoro pesante senza annotazione, dovete aggiungervi @concurrent prima di abilitare questa impostazione. Altrimenti rischiate di bloccare la UI senza capire perché.
4. Abilitate InferIsolatedConformances (SE-0470)
Permette al compilatore di inferire le conformance isolate, riducendo ulteriormente il codice boilerplate.
Attenzione ai Task
Questo è un punto su cui mi sento di insistere, perché è fonte di bug subdoli. Con il MainActor di default, i Task ereditano l'isolamento dal contesto circostante:
// ATTENZIONE: questo codice NON gira in background!
func avviaElaborazione() {
Task {
// Con MainActor di default, questo Task eredita MainActor
// Il lavoro pesante blocca la UI!
let risultato = elaborazionePesante()
aggiorna(risultato)
}
}
// Soluzione: usare @concurrent per il lavoro pesante
func avviaElaborazione() {
Task {
let risultato = await elaboraInBackground()
aggiorna(risultato)
}
}
@concurrent
func elaboraInBackground() async -> Risultato {
// Questo gira effettivamente in background
return elaborazionePesante()
}
Molti sviluppatori (me compreso, le prime volte) pensano che Task { } crei automaticamente un thread in background. Con il MainActor di default, non è così. Tenetelo a mente.
Problemi Noti e Soluzioni
L'Approachable Concurrency è una grande innovazione, ma non è priva di spigoli. Ecco i problemi più comuni che potreste incontrare e come risolverli.
Conflitti con CodingKey
Un problema abbastanza fastidioso riguarda i protocolli come CodingKey che si aspettano di essere nonisolated. Con il MainActor di default, l'enum CodingKeys generato dal compilatore può causare warning:
// Possibile warning: conformance to CodingKey may cross
// into main actor-isolated code
// Soluzione: marcare esplicitamente come nonisolated
struct Utente: Codable {
var nome: String
var email: String
nonisolated enum CodingKeys: String, CodingKey {
case nome, email
}
}
Non è elegantissimo, ma funziona. È uno di quei casi in cui il compilatore ha ragione in teoria ma nella pratica ci costringe a scrivere codice che vorremmo evitare.
Codice generato da terze parti
Se usate librerie che generano codice (Protobuf, GraphQL, GRDB e simili), quel codice potrebbe non compilare con Default Actor Isolation = MainActor. La soluzione è configurare i target generati con Default Actor Isolation = nonisolated, mantenendo MainActor solo per il codice della vostra app.
SwiftData e actor personalizzati
Se usate SwiftData con un ModelActor personalizzato per operazioni in background, i tipi condivisi devono essere marcati come nonisolated:
// Il ModelActor ha il suo dominio di isolamento
@ModelActor
actor GestoreDati {
func importaDati(_ elementi: [ElementoImport]) throws {
for elemento in elementi {
let modello = ElementoModello(da: elemento)
modelContext.insert(modello)
}
try modelContext.save()
}
}
// Questo tipo viene usato sia dal MainActor che dal ModelActor
// Deve essere nonisolated per evitare conflitti
nonisolated struct ElementoImport: Sendable {
let titolo: String
let descrizione: String
}
Best Practice per il 2026
Dopo mesi di utilizzo dell'Approachable Concurrency da parte della community, ecco le best practice che si sono consolidate:
- App target: usate sempre
Default Actor Isolation = MainActor. Il codice UI è la maggior parte del codice di un'app, e questa impostazione lo semplifica enormemente. - Package di libreria: valutate caso per caso. Un package di networking probabilmente vuole restare
nonisolated, mentre un package di UI può beneficiare delMainActordi default. - Usate
@concurrentcon parsimonia: profilate prima con Instruments. Non tutto il lavoro che sembra pesante lo è davvero — a volte ci sorprendiamo. Spostate in background solo ciò che effettivamente causa problemi di performance. - Evitate l'opt-out a tappeto: non marcate tutto come
nonisolatedper far tacere il compilatore. Se vi segnala un problema, probabilmente c'è un motivo. Resistete alla tentazione. - Migrate un feature flag alla volta: non attivate tutto insieme. Procedete in modo incrementale e testate ad ogni passo. Il vostro sé futuro vi ringrazierà.
Domande Frequenti (FAQ)
Devo riscrivere tutto il mio codice per usare l'Approachable Concurrency?
Assolutamente no. L'Approachable Concurrency è retrocompatibile. Il codice esistente con annotazioni @MainActor esplicite continuerà a funzionare esattamente come prima. Le annotazioni diventeranno semplicemente ridondanti, ma non causano errori. Potete rimuoverle gradualmente per pulire il codice, senza fretta.
Qual è la differenza tra nonisolated e nonisolated(nonsending)?
In Swift 6.1, nonisolated significava "esegui sul global executor" (thread in background). In Swift 6.2, nonisolated per le funzioni async diventa implicitamente nonisolated(nonsending), che significa "esegui sull'executor del chiamante". Se volete il vecchio comportamento (esecuzione in background), usate @concurrent.
L'Approachable Concurrency rende il mio codice più lento?
No, anzi. Il codice che gira sul MainActor non è intrinsecamente più lento — semplicemente gira sul thread principale. Il rischio è bloccare la UI con elaborazioni pesanti, ma questo si risolve usando @concurrent per le funzioni che richiedono lavoro intensivo. In molti casi l'app potrebbe essere addirittura più veloce perché si evita l'overhead del cambio di contesto tra thread.
Posso usare l'Approachable Concurrency con versioni precedenti di iOS?
Sì, ed è una buona notizia. Le impostazioni del compilatore (Default Actor Isolation, NonisolatedNonsendingByDefault) sono feature del compilatore Swift 6.2, non del runtime iOS. Questo significa che potete usarle anche con deployment target inferiori a iOS 26, purché compiliate con Xcode 26 e Swift 6.2. Le annotazioni come @concurrent richiedono Swift 6.2 come versione minima del linguaggio.
Come faccio a sapere se un Task gira sul MainActor o in background?
In Swift 6.2, i Task ereditano l'isolamento dal contesto in cui vengono creati. Se il contesto è isolato sul MainActor (che è il default con l'Approachable Concurrency), il Task girerà sul MainActor. Per eseguire lavoro in background, usate una funzione marcata @concurrent all'interno del Task, oppure usate Task.detached (che non eredita l'isolamento).