Ogni applicazione mobile esegue molte attività simultaneamente: caricare dati dalla rete, elaborare i tocchi dell'utente, animare l'interfaccia e salvare file. Se tutto questo codice gira in un unico thread, l'applicazione si blocca a ogni ritardo di rete. Il multithreading e la concorrenza sono concetti chiave che consentono all'applicazione di rimanere reattiva ed efficiente. In questo articolo esamineremo tutti gli strumenti principali: da Main Thread e RunLoop alle coroutine Kotlin e Combine su iOS. Il materiale si basa sulla documentazione ufficiale Apple GCD.
Punti chiave
Il multithreading è la capacità di un'applicazione di eseguire più frammenti di codice simultaneamente. Ogni frammento viene eseguito in un thread separato — un processo leggero con il proprio stack di chiamate. Nello sviluppo mobile, i thread si dividono in due categorie: Main Thread (thread dell'interfaccia utente) e Background Threads (thread di background).
Il sistema operativo stesso gestisce la distribuzione dei thread tra i core del processore. I dispositivi moderni hanno 6–8 core, quindi l'esecuzione parallela può effettivamente accelerare il lavoro. Tuttavia, creare thread è un'operazione costosa, quindi non è consigliato lavorare direttamente con Thread. Invece, si utilizzano astrazioni di livello superiore: DispatchQueue, OperationQueue, CoroutineDispatcher.
La concorrenza è un concetto più ampio del multithreading. La concorrenza significa che le attività possono essere eseguite "simultaneamente" anche su un singolo core tramite il cambio di contesto. L'asincronia (Async/Await) è un modello di programmazione in cui un'attività non blocca un thread, ma restituisce il controllo mentre attende un risultato. I linguaggi moderni (Kotlin, Swift, Dart) hanno il supporto integrato per Async/Await.
In IT Sectr, prestiamo particolare attenzione alla corretta architettura di multithreading all'inizio del progetto. Gli errori commessi in una fase precoce portano a bug difficili da individuare: race condition, deadlock e instabilità dell'applicazione sotto carico. Ogni nostro progetto viene sottoposto a una revisione dell'architettura di concorrenza in fase di pianificazione.
Main Thread (thread principale) — l'unico thread in un'applicazione mobile che ha accesso all'interfaccia utente. Su Android si chiama UI Thread, su iOS — Main Thread. Tutte le operazioni dell'interfaccia — modifica del testo, animazioni, elaborazione dei tocchi — vengono eseguite solo sul Main Thread. Se un'operazione pesante (caricamento file, parsing JSON) viene eseguita sul thread principale, l'interfaccia smette di rispondere. Su Android questo porta ad ANR (Application Not Responding), su iOS — a uno schermo "congelato".
Background Threads (thread di background) sono destinati a tutto ciò che non riguarda l'interfaccia utente: richieste di rete, operazioni sul database, elaborazione di immagini, crittografia. Una volta completato, il risultato viene passato al Main Thread per la visualizzazione. Ogni piattaforma fornisce i propri strumenti per passare da un thread all'altro: DispatchQueue.main.async su iOS, runOnUiThread o withContext(Dispatchers.Main) su Android.
RunLoop — il ciclo di elaborazione degli eventi sul thread principale di iOS. RunLoop attende eventi (tocchi, timer, notifiche) e li distribuisce agli handler appropriati. Su Android l'equivalente è Looper, associato a ogni Main Thread. Main Looper estrae infinitamente messaggi dalla coda e li passa a Handler per l'elaborazione. Comprendere RunLoop e Looper aiuta a evitare perdite di memoria e "balbettii" dell'interfaccia.
Grand Central Dispatch (GCD) — una libreria Apple per gestire il multithreading a livello di linguaggio C. GCD lavora con DispatchQueue — code di attività. Lo sviluppatore non crea thread manualmente; GCD gestisce un pool di thread, distribuendo le attività tra i core disponibili del processore. Le DispatchQueue sono di due tipi: Serial Queue (coda seriale — le attività vengono eseguite una dopo l'altra) e Concurrent Queue (coda concorrente — le attività possono essere eseguite simultaneamente).
Main DispatchQueue — una coda seriale legata al thread principale. Global Queues — code concorrenti con diverse priorità (QoS — Quality of Service): userInteractive, userInitiated, utility, background. Scegliere il QoS corretto è fondamentale per le prestazioni: .userInteractive — per le attività che influenzano l'interfaccia utente (animazioni, rendering); .background — per attività non critiche in termini di tempo (sincronizzazione, pulizia cache).
OperationQueue — un'astrazione su GCD con capacità aggiuntive: cancellazione di attività, impostazione di dipendenze tra operazioni, controllo del numero massimo di operazioni concorrenti. Le operazioni sono oggetti della classe Operation (o BlockOperation). Esempio: se è necessario caricare un'immagine, quindi applicare un filtro e solo dopo visualizzarla — OperationQueue con dipendenze gestisce perfettamente. In GCD dovresti sincronizzare manualmente questi passaggi usando DispatchGroup o semaforo.
Async/Await in Swift 5.5+ — un'alternativa moderna a GCD. Le parole chiave async e await rendono il codice asincrono lineare e leggibile. Le funzioni sono contrassegnate come async e le chiamate vengono attese con await. Il sistema stesso gestisce il cambio di contesto: per impostazione predefinita, una funzione async viene eseguita su un thread di background, mentre l'aggiornamento dell'interfaccia utente viene eseguito su MainActor. @MainActor — un attributo che garantisce l'esecuzione del codice sul thread principale.
Le coroutine sono thread leggeri per Kotlin sviluppati da JetBrains. A differenza dei thread normali, le coroutine non sono legate a un Thread specifico. Migliaia di coroutine possono essere eseguite su più thread senza un overhead significativo. CoroutineScope gestisce il ciclo di vita delle coroutine: viewModelScope è legato a ViewModel, lifecycleScope — ad Activity/Fragment. Quando lo scope viene distrutto, tutte le coroutine figlie vengono automaticamente cancellate.
I Dispatchers determinano su quale pool di thread viene eseguita la coroutine: Dispatchers.Main — thread dell'interfaccia utente; Dispatchers.IO — per richieste di rete e operazioni su disco; Dispatchers.Default — per calcoli intensivi di CPU. Per cambiare dispatcher si usa withContext. Le coroutine supportano la concorrenza strutturata: ogni coroutine ha un genitore e quando il genitore viene cancellato, tutte le coroutine figlie vengono cancellate. Questo previene perdite di memoria e attività sospese.
Flow — un flusso di dati asincrono freddo della libreria di coroutine. Flow emette valori sequenzialmente: (1) il produttore genera dati, (2) gli operatori trasformano il flusso, (3) il collettore consuma il risultato. A differenza di LiveData, Flow supporta catene complesse di operatori (map, filter, flatMapConcat, catch) ed è completamente thread-safe. StateFlow e SharedFlow — varianti calde di Flow, ideali per lo stato dell'interfaccia utente ed eventi una tantum (Snackbar, navigazione).
Channel — un'altra astrazione di coroutine per passare dati tra coroutine. Channel funziona come una coda: un mittente (send) e uno o più destinatari (receive). I canali bufferizzati (Channel(UNLIMITED), Channel(BUFFERED)) consentono di configurare il comportamento in caso di overflow. Channel viene spesso usato con Flow per collegare API basate su callback alle coroutine: callbackFlow { … }.
In IT Sectr utilizziamo attivamente coroutine e Flow in tutti i progetti Android. Questo consente di scrivere codice asincrono che sembra sincrono, è facile da testare (runTest, TestDispatcher) e non richiede gestione manuale dei thread. Esempio di una semplice coroutine con caricamento dati:
class UserRepository(
private val api: UserApi,
private val dao: UserDao
) {
suspend fun getUsers(): List<User> = withContext(Dispatchers.IO) {
return@withContext try {
val users = api.fetchUsers()
dao.insertAll(users)
users
} catch (e: Exception) {
dao.getAll()
}
}
}
La programmazione reattiva — un paradigma in cui i dati si propagano come flussi asincroni (Observable, Publisher). RxJava/RxKotlin — l'implementazione più popolare per Android, portata da .NET Rx. RxSwift — una libreria simile per iOS. Componenti principali: Observable (fonte di eventi), Observer (sottoscrittore), Scheduler (gestione dei thread), Operators (trasformazione del flusso).
Combine — un framework Apple per la programmazione reattiva introdotto in iOS 13. Combine utilizza i protocolli Publisher (editore) e Subscriber (sottoscrittore). A differenza di RxSwift, Combine è integrato nell'SDK e strettamente integrato con SwiftUI. Operatori in Combine: map, filter, combineLatest, zip, debounce, throttle — coprono la maggior parte degli scenari: dal data binding all'interfaccia utente al debounce delle query di ricerca.
Future e Promise — pattern per lavorare con un singolo risultato asincrono. Future rappresenta un valore che sarà disponibile in seguito. Promise è una promessa di fornire un valore. In Rx questo è Single (una risposta positiva o un errore), in Combine — Future Publisher. In pratica, Future/Promise sono convenienti per richieste API singole, mentre Observable/Publisher — per flussi continui (geolocalizzazione, input di testo).
Callback e Delegate — pattern classici per operazioni asincrone. Callback — una funzione passata come argomento e chiamata al completamento dell'operazione. Delegate — un oggetto che implementa un protocollo con metodi di gestione degli eventi. Svantaggio: "callback hell" (callback annidati) e complessità della gestione degli errori. NotificationCenter (iOS) e EventBus (Android) — meccanismi di trasmissione di eventi, utili per comunicazione debolmente accoppiata ma che portano a dipendenze implicite.
Il multithreading apre la porta a prestazioni elevate, ma allo stesso tempo crea il rischio di errori difficili da individuare. I più comuni: Race Condition (condizione di gara), Deadlock (blocco reciproco), Livelock (blocco attivo) e Starvation (starvation del thread). Comprendere questi problemi è un'abilità essenziale per qualsiasi sviluppatore mobile.
La Race Condition si verifica quando due o più thread leggono e scrivono simultaneamente gli stessi dati senza sincronizzazione. Il risultato dipende da quale thread viene eseguito per primo. Esempio classico: due thread incrementano un contatore. L'operazione "leggi → incrementa → scrivi" non è atomica, quindi quando viene eseguita simultaneamente, un incremento viene "perso". La soluzione — utilizzare operazioni atomiche (AtomicInteger, AtomicReference) o blocchi (Mutex, Semaphore, synchronized).
Deadlock — una situazione in cui ogni thread detiene una risorsa e attende una risorsa detenuta da un altro thread. Nessun thread può procedere. Condizioni di occorrenza: mutua esclusione, detenzione e attesa, nessuna prelazione, attesa circolare. Prevenzione: stabilire un ordine unico di acquisizione dei blocchi, usare tryLock con timeout, applicare algoritmi Lock-Free (ConcurrentHashMap, CopyOnWriteArrayList).
Livelock — i thread non sono bloccati ma si "passano" continuamente risorse senza svolgere lavoro utile. Esempio: due persone si incontrano in un corridoio ed entrambe si spostano, muovendosi nella stessa direzione. Starvation — un thread non ottiene accesso a una risorsa perché altri thread la intercettano continuamente. Soluzione: blocchi equi (fair locks), priorità dei thread con cautela.
Per prevenire problemi di multithreading si utilizzano primitive di sincronizzazione: Mutex (mutua esclusione), Semaphore (limitazione del numero di accessi simultanei), Lock (interfaccia con tryLock), Synchronized (blocco a livello JVM), @MainActor (Swift — garanzia di esecuzione sul thread principale). Su Android è disponibile anche ThreadPool (pool di thread) tramite Executors.newFixedThreadPool, newCachedThreadPool. Tuttavia, la gestione manuale dei pool è prerogativa di progetti legacy; nei nuovi progetti è meglio usare le coroutine.
| Strumento | Piattaforma | Tipo | Caratteristiche |
|---|---|---|---|
| DispatchQueue (GCD) | iOS | Coda di attività | Seriale/Concorrente, priorità QoS, Thread Pool gestito dal sistema |
| OperationQueue | iOS | Coda di operazioni | Dipendenze, cancellazione, maxConcurrentOperationCount |
| Coroutines + Flow | Android | Coroutine | Leggere, concorrenza strutturata, StateFlow, Channel |
| RxJava / RxKotlin | Android | Flusso reattivo | Observable, Schedulers, ricco set di operatori |
| Combine | iOS | Flusso reattivo | Publisher/Subscriber, integrazione SwiftUI |
| Async/Await + Task | iOS / Android | Modello asincrono | Codice lineare, @MainActor, concorrenza strutturata |
Domande frequenti
Main Thread (thread dell'interfaccia utente) è responsabile del rendering dell'interfaccia e dell'elaborazione dei tocchi. Background Thread esegue attività in background — caricamento dati, calcoli, lavoro di rete. Bloccare il Main Thread causa il congelamento dell'interfaccia (ANR su Android, frozen UI su iOS).
Race Condition — una condizione di gara quando due thread accedono simultaneamente a dati condivisi e il risultato dipende dall'ordine di esecuzione. Si evita con la sincronizzazione: Mutex, Semaphore, Lock, Synchronized, @MainActor o operazioni atomiche.
Le coroutine sono lo standard moderno per Android (JetBrains, supportato da Google). RxJava/RxKotlin è un approccio reattivo con un ricco set di operatori. Le coroutine sono più semplici per chiamate asincrone, RxJava è più potente per flussi di dati complessi. In IT Sectr usiamo Coroutines + Flow per i nuovi progetti.
Deadlock — un blocco reciproco dove due thread attendono le risorse l'uno dell'altro. Livelock — i thread non sono bloccati ma si passano risorse continuamente senza fare lavoro utile. Entrambi i problemi si risolvono con un ordine di blocco appropriato e timeout.
DispatchQueue è un'astrazione di Grand Central Dispatch (GCD) per la gestione dei thread. Main Queue esegue attività sul thread principale, Global Queues — su thread di background. Serial Queue garantisce l'esecuzione sequenziale, Concurrent Queue — parallela. Nei progetti moderni, GCD è spesso sostituito da Async/Await e Task.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.