1. Multithreading vs parallelismo
Multithreading: molti thread, ma non per forza in contemporanea
Il multithreading è quando nel tuo programma ci sono più thread di esecuzione. Ogni thread è come una linea d’azione indipendente: uno elabora qualcosa, un altro attende l’input dell’utente, un terzo salva i dati su file. In Java crei i thread tramite la classe Thread, implementi l’interfaccia Runnable oppure usi strumenti di livello più alto come ExecutorService (ne parleremo nella prossima lezione).
MA! Il multithreading non garantisce che i tuoi task vengano davvero eseguiti contemporaneamente. Tutto dipende da quanti core ha il tuo processore. Se il core è uno, i thread semplicemente si «commutano» rapidamente tra loro — così rapidamente che all’utente può sembrare che tutto accada nello stesso momento. In realtà la CPU esegue un solo thread in ogni istante, mentre gli altri attendono il proprio turno.
Parallelismo: quando i task vanno davvero in parallelo
Il parallelismo è quando il tuo codice viene realmente eseguito contemporaneamente su più core della CPU. Se hai un computer moderno con 4, 8, 16 core — puoi accelerare davvero l’elaborazione di grandi task, suddividendoli in parti indipendenti e distribuendoli tra i core.
Se facciamo un’analogia, il multithreading è quando hai un solo cuoco che si commuta rapidamente tra la preparazione del borsch, la frittura delle cotolette e il taglio dell’insalata. Il parallelismo è quando hai più cuochi contemporaneamente e ognuno si occupa del proprio piatto.
Qual è la differenza nella pratica?
Multithreading — riguarda comodità e reattività. Usi più thread affinché il programma non «si blocchi»: un thread attende la rete, un altro disegna l’interfaccia, un terzo elabora qualcosa. Tutto sembra andare in parallelo, ma non necessariamente allo stesso tempo.
Parallelismo — riguarda la velocità. Qui più core della CPU eseguono davvero parti diverse del task contemporaneamente, per ottenere il risultato più rapidamente.
In altre parole: il multithreading aiuta a organizzare il lavoro, il parallelismo ad accelerarlo.
Importante:
Il multithreading serve ogni volta che ci sono task che si possono svolgere indipendentemente.
Il parallelismo serve quando vuoi accelerare i calcoli grazie alla reale distribuzione del lavoro tra i core.
Esempio: elaborazione di un grande array
Immaginiamo di avere un array di 10 milioni di numeri e di voler calcolare la somma di tutti gli elementi.
Sequenziale:
Un solo thread percorre l’intero array e calcola la somma. Semplice e affidabile, ma lento.
Multithread (ma su un solo core):
Dividi l’array in 4 parti, crei 4 thread e ognuno calcola la propria parte. Ma se hai un solo core, i thread lavoreranno a turno — non ci sarà alcuna accelerazione e l’overhead del context switch può persino rallentare il programma.
In parallelo (su più core):
Dividi l’array in 4 parti, avvii 4 thread e ogni thread lavora realmente sul proprio core. La somma finale si ottiene combinando 4 parti. Questo è davvero più veloce — soprattutto su grandi quantità di dati.
Implementare però l’elaborazione sequenziale di un array è molto semplice; hai già scritto più volte programmi di questo tipo:
// Esempio: elaborazione sequenziale di un array
int[] arr = new int[10_000_000];
// ... riempimento dell'array ...
long sum = 0;
for (int x : arr) {
sum += x;
}
System.out.println(sum);
Le versioni multithread e parallele sono un po’ più complesse; le analizzeremo nelle prossime lezioni con strumenti moderni.
2. Perché serve il parallelismo
I processori moderni hanno superato da tempo il limite del singolo core. Anche il tuo smartphone, molto probabilmente, ha almeno quattro core, mentre i desktop e i server ne hanno otto, sedici, trentadue o più. Se un’applicazione sa usare tutti questi core, può lavorare molto più velocemente.
Un tempo le prestazioni dei processori crescevano grazie all’aumento della frequenza di clock — fino alla metà degli anni 2000 circa questo ha funzionato davvero. Ma l’aumento delle frequenze si è scontrato con limiti fisici, ed è iniziata una nuova era — sistemi multiprocessore e multi-core. Ora vincono i programmi che sanno distribuire efficacemente il lavoro tra i core.
Dove il parallelismo accelera davvero?
- Elaborazione di grandi volumi di dati: analisi dei log, statistiche, aggregazioni — tutto ciò che si può dividere in parti indipendenti.
- Rendering, elaborazione di immagini e video: ogni pixel o frammento può essere elaborato separatamente.
- Calcolo scientifico, modellazione: problemi matematici, simulazioni, training di modelli.
- Applicazioni server: gestione simultanea di molti client.
- Applicazioni reattive: quando bisogna reagire rapidamente a molti eventi senza bloccare il thread principale.
Quando il parallelismo non aiuta?
- Se il task è piccolo, l’overhead dell’avvio del parallelismo può superare il guadagno.
- Se il task non può essere suddiviso in parti indipendenti (per esempio, quando ogni passo dipende dal precedente).
- Quando ci sono molte risorse condivise (per esempio, lo stesso file) e i thread finiscono per ostacolarsi a vicenda.
3. Attività tipiche per il parallelismo
Vediamo quali attività si distribuiscono più spesso sui core.
Calcoli massivi
- Somma, ricerca di massimo/minimo, calcolo di statistiche su un grande array.
- Esempio: calcolare il valore medio della temperatura su un milione di sensori.
Elaborazione di collezioni
- Filtraggio, ordinamento, trasformazione di liste grandi (per esempio, l’elaborazione degli ordini di un e-commerce).
- Esempio: selezionare tutti gli ordini superiori a 10 000 rubli e ordinarli per data.
Rendering ed elaborazione della grafica
- Applicare un filtro a tutti i pixel di un’immagine (per esempio, convertirla in bianco e nero).
- Ogni pixel può essere elaborato in modo indipendente — caso ideale per il parallelismo.
Analisi dei dati, big data
- MapReduce, aggregazioni, calcolo di statistiche su enormi volumi di dati.
- Esempio: elaborazione dei log di un anno per cercare anomalie.
Esempio: somma calcolata in parallelo
Supponiamo di avere un array da 1 milione di numeri. Si può dividerlo in 4 parti e calcolare la somma di ciascuna parte in un thread separato, per poi sommare i risultati.
4. Problemi e sfide del parallelismo
Difficoltà di debug
Quando il codice gira su più thread, i bug possono manifestarsi solo in casi rari, quando i thread si «incrociano» in modo particolare. A volte l’errore appare una volta ogni 1000 esecuzioni — ed è molto difficile da catturare.
Race condition sui dati
Se più thread modificano contemporaneamente la stessa variabile o lo stesso oggetto — i risultati possono essere incorretti. Per esempio, due thread incrementano nello stesso momento un contatore e il valore finale risulta inferiore a quello atteso.
Sincronizzazione
Per evitare le race condition, bisogna sincronizzare l’accesso ai dati condivisi — tramite la parola chiave synchronized, lock, variabili atomiche e altri strumenti. Questo complica il codice e può portare ad altri problemi (per esempio, deadlock — blocco reciproco dei thread).
Bilanciamento del carico
Se hai suddiviso il task in 4 parti e una di esse è molto più pesante delle altre — tre thread avranno già finito e staranno in attesa, mentre il quarto lavorerà ancora. Alla fine non ottieni accelerazione.
Overhead
Avvio dei thread, commutazione tra di essi, sincronizzazione — tutto questo richiede tempo. Se il task è piccolo, il parallelismo può solo rallentare l’esecuzione.
Tabella: confronto degli approcci
| Approccio | Quando è veloce | Quando rallenta | Esempio d’uso |
|---|---|---|---|
| Sequenziale (1 thread) | Task piccoli, logica semplice | Grandi volumi di dati | Elaborazione di 10 righe |
| Multithreading (su 1 core) | Attività asincrone (attesa IO) | Attività CPU-bound (limitate dalla capacità di calcolo della CPU) su 1 core | Download simultaneo di file |
| Parallelismo (più core) | Task grandi e indipendenti | Task piccoli, forte dipendenza | Elaborazione di un grande array |
Visualizzazione: come appare
// Elaborazione sequenziale (1 thread)
[Attività 1][Attività 2][Attività 3][Attività 4]
// Multithreading su un solo core (logica di commutazione)
[Attività 1] [Attività 2] [Attività 3] [Attività 4]
(ma in realtà ne lavora solo una alla volta, le altre attendono)
// Parallelismo su quattro core
[Attività 1] [Attività 2] [Attività 3] [Attività 4]
(tutte vengono eseguite contemporaneamente)
5. Errori tipici nei tentativi di parallelismo
Errore n. 1: Parallelizzare tutto a tappeto. Molti principianti pensano: «Più thread — più veloce!». In realtà non è così. Se i task sono pochi o troppo semplici — non c’è guadagno e talvolta il programma funziona persino più lentamente.
Errore n. 2: Ignorare la sincronizzazione. Se più thread lavorano sugli stessi dati senza sincronizzazione — avrai race condition, logica compromessa e bug difficili da catturare.
Errore n. 3: Parallelismo per il parallelismo. Il parallelismo non è un fine in sé. Serve quando ci sono task reali che si possono dividere efficacemente in parti indipendenti.
Errore n. 4: Non considerare le caratteristiche del task. Alcuni task non si possono parallelizzare (per esempio, quando il passo N+1 dipende dal risultato del passo N). In questi casi il parallelismo non dà vantaggi.
Errore n. 5: Ignorare l’overhead. Avvio dei thread, commutazione, raccolta dei risultati — tutto questo richiede tempo. Per i task piccoli, questo tempo può superare quello del lavoro stesso.
GO TO FULL VERSION