1. Il problema del codice sincrono
Immaginiamo: avete un programma che deve scaricare dati da Internet o leggere un file grande. Scrivete qualcosa del genere:
String data = readFromFile("bigfile.txt");
System.out.println("Dati: " + data);
Tutto bene, ma se il file è grande o la rete è lenta, il programma semplicemente si blocca sulla riga di lettura. L’utente vede un’interfaccia “bloccata”, il server non può servire altre richieste e lo sviluppatore... si rattrista.
Questa situazione si chiama blocco: un thread (ad esempio, il thread principale della vostra applicazione) è costretto ad aspettare finché l’operazione non termina. E se tali operazioni sono molte — ecco che arrivano lag e prestazioni scarse.
È come andare al bar, fare l’ordine e... dover restare al bancone finché non vi preparano il caffè. Gli altri clienti restano in coda e aspettano anche loro finché il barista non finisce con voi. Inefficiente, vero?
Asincronia: come salva il mondo
La programmazione asincrona è un approccio in cui le operazioni lunghe (ad esempio, lettura di un file, richiesta a un server, accesso a un database) vengono eseguite in un thread in background, mentre il thread principale continua a lavorare: servire utenti, accettare nuove richieste, reagire agli eventi.
Cioè, fate l’ordine (avviate un’attività), andate a fare altro e, quando il caffè è pronto (l’attività è terminata), vi dicono semplicemente: “Pronto!”
In Java, prima dell’arrivo di CompletableFuture, non era molto comodo. Vediamo come si è evoluto.
2. Approcci storici: Future e i suoi limiti
In Java 5 è stata introdotta l’interfaccia Future — il primo tentativo di rendere il lavoro con attività asincrone almeno un po’ più comodo. Permetteva di affidare un’attività a un thread pool e ottenere prima o poi il risultato.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Attenzione: il thread si bloccherà finché l'attività non sarà completata!
L’idea sembra buona, ma in pratica Future è come una vecchia cassetta della posta: inviate la lettera, ma per sapere se è arrivata una risposta dovete continuare a guardare dentro.
Non sa notificare quando il risultato è pronto, non supporta catene di azioni come “fai questo e poi quello”, non consente di gestire gli errori in modo elegante. Tutto si riduce alla chiamata bloccante get(), a causa della quale l’asincronia torna a essere attesa.
3. La nascita di CompletableFuture: un nuovo stile di asincronia
In Java 8, al posto del vecchio Future, è arrivato il vero eroe dell’asincronia — CompletableFuture. Questa classe del package java.util.concurrent è diventata uno strumento universale per chi era stanco di aspettare i risultati “a mano” e voleva scrivere codice asincrono in modo elegante, compatto e chiaro.
CompletableFuture sa fare quasi tutto. Può avviare attività in altri thread, metterle in catena — per esempio, prima calcolare un risultato, poi elaborarlo e infine fare qualcos’altro. Combina facilmente più attività: si può attendere il completamento di tutte oppure solo della prima che finisce. Anche gli errori si gestiscono in modo elegante — senza troppi try-catch. E lo stile complessivo diventa più funzionale: al posto di chiamate noiose e attese compaiono metodi espressivi come thenApply, thenAccept e altri.
flowchart LR
A[Avvio dell'attività in modo asincrono] --> B[Elaborazione del risultato]
B --> C[Operazione successiva]
C --> D[Gestione degli errori]
Così CompletableFuture ha trasformato l’asincronia da mestiere pesante in uno strumento comodo e flessibile, con cui il codice finalmente respira.
4. Un esempio semplicissimo: il primo passo nel mondo di CompletableFuture
Vediamo un esempio minimo di attività asincrona:
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Avviamo l'attività in modo asincrono
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// Otteniamo il risultato (blocca il thread!)
try {
int result = future.get();
System.out.println("Risultato: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Questo codice esegue già il calcolo in un thread separato — il thread principale non si blocca al momento dell’avvio dell’attività. Ma la chiamata get() blocca comunque il thread finché il risultato non è pronto.
E come NON bloccare il thread?
È semplice: usate i metodi callback, che vengono chiamati quando l’attività termina:
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Risultato: " + result));
System.out.println("Non mi blocco e posso fare qualcos'altro!");
Conclusione:
- thenAccept — è “iscriviti al risultato”: quando l’attività è completata, esegui questo codice.
- Il thread principale non aspetta il completamento dell’attività e continua a lavorare.
Visualizzazione (pseudo-codice degli eventi)
[Thread principale] --> [Avvio dell'attività]
| |
v v
[Fa qualcos'altro] [Il thread in background calcola 2+2]
| |
v v
[Stampa "Non mi blocco..."]
| |
v v
[Quando è calcolato — viene chiamato thenAccept]
5. Come appare in un’applicazione?
Immaginiamo che stiate sviluppando un’app console, in cui l’utente può richiedere il caricamento dei dati (per esempio da un database o da un server) e, mentre i dati si caricano, il programma non si “blocca” ma continua ad accettare comandi.
Esempio: simulazione di un’operazione lunga
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Iniziamo a caricare i dati...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Simulazione di un caricamento lungo
try {
Thread.sleep(2000); // 2 secondi
} catch (InterruptedException e) {
return "Errore di caricamento";
}
return "Dati caricati con successo!";
});
// Ci registriamo al risultato
dataFuture.thenAccept(result -> System.out.println("Risultato: " + result));
// Il programma continua a lavorare
System.out.println("Mentre i dati si caricano, posso fare qualcos'altro!");
// Per evitare che il programma termini troppo presto (solo per la demo!)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Cosa vedrete in console:
Iniziamo a caricare i dati...
Mentre i dati si caricano, posso fare qualcos'altro!
[dopo 2 secondi]
Risultato: Dati caricati con successo!
6. Dettagli utili
Un po’ di thread sotto il cofano
Quando scrivete CompletableFuture.supplyAsync(...), l’attività per impostazione predefinita viene eseguita nel cosiddetto ForkJoinPool — uno speciale thread pool che Java usa per attività parallele. Se vi serve più controllo (ad esempio, un vostro ExecutorService), potete passarlo come secondo parametro:
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Ma per attività semplici basta il pool standard.
Ottenere il risultato: get(), join(), thenAccept
- get() — blocca il thread finché il risultato non è pronto (lancia eccezioni checked).
- join() — blocca anch’esso, ma lancia eccezioni unchecked (RuntimeException).
- thenAccept(), thenApply() e altri — NON bloccano, ma invocano la funzione passata quando il risultato è pronto.
Nelle applicazioni asincrone reali cercate di evitare get()/join() nel thread principale!
7. Errori tipici ai primi passi con CompletableFuture
Errore n. 1: uso di get() o join() nel thread principale.
Così bloccate di nuovo il programma e perdete tutti i vantaggi dell’asincronia. Invece, usate thenAccept, thenApply e altri metodi per l’elaborazione del risultato.
Errore n. 2: dimenticare di gestire l’errore.
Se in un’attività asincrona si verifica un’eccezione, non “salterà fuori” nel thread principale. Senza gestione tramite exceptionally o handle semplicemente non saprete che qualcosa è andato storto.
Errore n. 3: non avete atteso la fine del programma.
Nei demo spesso bisogna “rallentare” il thread main con Thread.sleep — altrimenti il programma termina prima che l’attività finisca. Nelle applicazioni reali (ad esempio nei server web) non è un problema, ma nelle demo da console tenetelo a mente.
Errore n. 4: confondere thenAccept e thenApply.
thenAccept è per “effetti collaterali” (non ritorna nulla), thenApply è per trasformare il risultato (ritorna un nuovo risultato).
Errore n. 5: mescolare codice asincrono e sincrono senza necessità.
Se avete iniziato a scrivere in modo asincrono, non riportate tutto nel sincrono con get()/join(), se non in casi estremi (ad esempio, nei test).
GO TO FULL VERSION