1. Non tutto ciò che si può parallelizzare va parallelizzato
In Java ci sono molti modi per parallelizzare i compiti. Ma «parallelismo = sempre più veloce» — è come pensare che aggiungendo altro sale alla zuppa diventi più buona: fino a un certo punto — sì, poi è meglio non provarci.
ExecutorService è perfetto quando avete compiti espliciti da avviare e controllare: gestione di richieste, caricamento asincrono di dati, calcoli indipendenti. Decidete voi quanti thread ci sono nel pool e gestite il ciclo di vita dei task.
parallelStream — un modo rapido per parallelizzare l’elaborazione delle collezioni quando le operazioni sono indipendenti e senza effetti collaterali. Conviene per collezioni «pesanti» (decine di migliaia di elementi e oltre).
ForkJoinPool — la scelta per compiti che si dividono bene in sotto-compiti (divide & conquer): ordinamento, ricerca, aggregazione di grandi array. È usato internamente da parallelStream, ma lo si può gestire anche direttamente.
Non usate il parallelismo «per ogni evenienza». Se il compito è piccolo, i costi di scheduling, context switching e sincronizzazione possono annullare tutto il guadagno.
Esempio: quando il parallelismo non serve
List<Integer> smallList = List.of(1, 2, 3, 4, 5);
int sum = smallList.parallelStream()
.mapToInt(x -> x)
.sum(); // Parallelizzare per 5 numeri — overkill!
2. Thread-safety: evitare dati condivisi e mutabili
Nel mondo parallelo la minaccia principale sono le race condition. Se più thread modificano la stessa variabile, il risultato può essere imprevedibile.
- Evitate variabili condivise e mutabili. Anche un’espressione come counter++ non è atomica.
- Usate collezioni thread-safe e operazioni atomiche. Per esempio, ConcurrentHashMap, CopyOnWriteArrayList, AtomicInteger, AtomicLong.
- Niente effetti collaterali negli stream paralleli. Non modificate strutture esterne da parallelStream.
Esempio di cattivo codice
List<Integer> numbers = Arrays.asList(1,2,3,4,5);
List<Integer> result = new ArrayList<>();
numbers.parallelStream().forEach(n -> result.add(n * 2)); // PERICOLOSO!
Qui result.add() non è thread-safe. Il risultato — elementi persi o eccezioni.
Come correttamente?
List<Integer> result = numbers.parallelStream()
.map(n -> n * 2)
.collect(Collectors.toList());
3. Prestazioni: più thread non è sempre meglio
Parallelizzare compiti piccoli non conviene. Se il lavoro dura millisecondi, l’avvio in parallelo spesso rallenta l’esecuzione a causa dell’overhead.
Misurate le prestazioni. Per misure rapide va bene System.nanoTime():
long start = System.nanoTime();
// ... il vostro codice ...
long end = System.nanoTime();
System.out.println("Tempo di esecuzione: " + (end - start) + " ns");
Per microbenchmark seri usate JMH (Java Microbenchmark Harness).
Esempio: confronto tra stream sequenziale e parallelo
List<Integer> bigList = IntStream.range(0, 1_000_000)
.boxed().collect(Collectors.toList());
long t1 = System.nanoTime();
long sum1 = bigList.stream().mapToLong(x -> x).sum();
long t2 = System.nanoTime();
long sum2 = bigList.parallelStream().mapToLong(x -> x).sum();
long t3 = System.nanoTime();
System.out.println("Sequenziale: " + (t2 - t1) / 1_000_000 + " ms");
System.out.println("Parallelo: " + (t3 - t2) / 1_000_000 + " ms");
Provate sul vostro computer — il vantaggio si nota con collezioni davvero grandi e operazioni pesanti.
4. Gestione degli errori: non ignorate le eccezioni nei thread
Future e gestione delle eccezioni
Se avete lanciato un task tramite ExecutorService.submit(), le eccezioni non verranno «propagate» automaticamente — vanno gestite tramite Future.get():
Future<Integer> future = executor.submit(() -> {
if (Math.random() > 0.5) throw new RuntimeException("Ops!");
return 42;
});
try {
Integer result = future.get(); // può lanciare ExecutionException
} catch (ExecutionException e) {
System.err.println("Errore nel task: " + e.getCause());
}
ForkJoin e gestione degli errori
In ForkJoinPool le eccezioni vengono «impacchettate» nel task. Alla chiamata di join()/get() riemergeranno:
ForkJoinPool pool = new ForkJoinPool();
RecursiveTask<Integer> task = new MyTask();
try {
int result = pool.invoke(task);
} catch (Exception e) {
System.err.println("Errore in ForkJoin: " + e);
}
Non dimenticate di gestire InterruptedException
Molti metodi (per esempio, Future.get(), Thread.sleep()) possono lanciare InterruptedException. Non «inghiottitela» — reagite correttamente: ripristinate il flag di interruzione o terminate il task.
5. Debug e test del codice parallelo
Logging e debug
I bug concorrenti sono insidiosi e spesso si manifestano in modo instabile. Fate logging indicando il thread: Thread.currentThread().getName(). Aiuta a capire chi e quando esegue il codice.
Nei casi complessi usate un debugger con supporto multithreading (ad esempio, IntelliJ IDEA). Dei Thread.sleep() temporanei a volte aiutano a «catturare» una race condition rara.
Test di scenari multithread
Dedicate test separati alle operazioni parallele e usate utility di attesa delle condizioni, ad esempio Awaitility. Eseguite tali test molte volte: alcuni problemi emergono solo alla centesima o millesima esecuzione.
6. Suggerimenti e dettagli utili
Leggibilità e manutenibilità: scrivete codice parallelo comprensibile
- Documentate. Commentate le parti complesse e la scelta degli strumenti.
- Usate astrazioni di alto livello. Preferite ExecutorService, parallelStream, ForkJoinPool alla gestione manuale dei thread.
- Evitate la «magia». Non complicate la sincronizzazione se si può fare più semplice.
Tabella: quando usare quale strumento
| Scenario | Strumento consigliato |
|---|---|
| Molti task indipendenti | |
| Elaborazione di una grande collezione | parallelStream o ForkJoin |
| Compito «divide et impera» | |
| Task asincrono semplice | |
| Molti task piccoli | Stream sequenziale |
| Task con effetti collaterali | Solo collezioni thread-safe! |
I «comandamenti» del programmatore parallelo
- Non create variabili condivise e mutabili — a meno che non siate certi che siano thread-safe.
- Non parallelizzate per il gusto di farlo: valutate il potenziale guadagno.
- Non dimenticate di chiudere i pool: shutdown()/shutdownNow().
- Non usate parallelStream per operazioni con effetti collaterali.
- Non dimenticate di gestire le eccezioni da Future e ForkJoinTask.
- Non «inghiottite» InterruptedException — terminate correttamente il task.
7. Errori tipici nella programmazione parallela
Errore n. 1: parallelizzazione di compiti piccoli. Spesso i principianti parallelizzano tutto, anche quando il lavoro dura microsecondi. Risultato — più lento a causa dell’overhead.
Errore n. 2: effetti collaterali negli stream. In parallelStream non si possono modificare variabili o collezioni esterne — incapperete in race condition e bug imprevedibili.
Errore n. 3: ignorare le eccezioni. Se non gestite gli errori da Future.get() o ForkJoinTask, non saprete perché il task non è stato eseguito.
Errore n. 4: shutdown() dimenticato su ExecutorService. Senza una chiusura esplicita, l’applicazione può «bloccarsi» in uscita.
Errore n. 5: uso di collezioni non thread-safe. Scrivere da più thread in un semplice ArrayList — strada sicura verso gli errori.
Errore n. 6: «inghiottire» InterruptedException. Se un thread è stato interrotto, rispettatelo e terminate il lavoro correttamente.
Errore n. 7: logica di sincronizzazione troppo complessa. Blocchi synchronized eccessivi portano a deadlock/livelock. Preferite astrazioni di alto livello.
GO TO FULL VERSION