Java Callable e Future
Restituisci valori dai task con Callable e consumali in modo asincrono con Future: attesa, timeout, cancellazione e gestione delle eccezioni.
Runnable permette a un thread di svolgere del lavoro, ma non consente di restituire un valore né di lanciare un'eccezione checked. La coppia che lo fa è Callable<V> (il produttore) e Future<V> (il consumatore). Quando si invia un Callable<V> a un ExecutorService, si ottiene in cambio un Future<V>, che rappresenta il riferimento per: attendere il risultato, leggere il valore, catturare l'eccezione del task o annullarlo.
Questa è l'API di più basso livello consapevole dei risultati nel toolkit concorrente di Java. Il capitolo successivo, CompletableFuture, aggiunge catene, combinatori e pipeline; tuttavia il contratto — "un risultato asincrono su cui è possibile attendere" — è stato definito da Future per primo, ed è ancora lo strumento giusto per un semplice "vai, fai questo e dimmi quando hai finito."
Callable<V> — Runnable con tipo di ritorno
L'interfaccia:
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}Le due differenze rispetto a Runnable:
- Restituisce
V(il parametro di tipo). - Può lanciare qualsiasi
Exception, incluse le eccezioni checked.
Come Runnable, è un'interfaccia funzionale — lambda e riferimenti a metodi funzionano:
Callable<Integer> compute = () -> {
Thread.sleep(100);
return 42;
};
Callable<String> read = () -> Files.readString(Path.of("config.txt")); // can throw IOException
Callable<List<Order>> query = () -> repo.findAll(); // can throw SQLExceptionCallable è la forma giusta per qualsiasi operazione del tipo "vai, fai questo e portami un valore". Runnable è la forma giusta solo quando non si ha bisogno di un risultato.
Future<V> — il riferimento a un risultato asincrono
Quando si esegue la submit di un Callable<V>, l'executor restituisce un Future<V>:
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}Cinque metodi. Tre che si usano spesso.
get()
Blocca il thread chiamante finché il task non è completato, poi restituisce il risultato:
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> { Thread.sleep(100); return 42; });
Integer value = f.get(); // blocks until done; returns 42get() lancia tre eccezioni che è necessario gestire:
InterruptedException— il chiamante è stato interrotto durante l'attesa. Trattamento standard: reimpostare il flag di interruzione e propagare.ExecutionException— il task stesso ha lanciato qualcosa. L'eccezione originale è incapsulata; vi si accede tramite.getCause().CancellationException— qualcuno ha chiamatocancel()sul future.
Una forma comune:
try {
Integer v = f.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // the real exception the task threw
// ... handle cause ...
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// ... bail out cooperatively ...
}get(timeout, unit)
Uguale a get() ma con una scadenza. Lancia TimeoutException se il task non termina in tempo:
try {
Integer v = f.get(500, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
f.cancel(true); // give up; ask the task to stop
throw new ServiceUnavailableException("timed out");
}Questa è la forma giusta per "sto chiamando un backend che dovrebbe rispondere in N ms; se non risponde, fallisco velocemente." Abbinare sempre il catch a un cancel(true) — altrimenti il task continua a girare in background, occupando un thread del cui risultato non ci interessa più.
cancel(boolean)
Chiede al task di fermarsi:
boolean cancelled = f.cancel(true); // true = interrupt the running threadL'argomento indica all'executor se interrompere il thread worker. Con true, il worker riceve un InterruptedException da qualsiasi chiamata bloccante (sleep, wait, I/O); con false, la cancellazione non ha effetto se il task è già avviato — solo i task non ancora avviati vengono rimossi dalla coda.
cancel è cooperativo. Un task che non controlla Thread.currentThread().isInterrupted() e non ha chiamate bloccanti continuerà a girare fino al termine. La cancellazione non è un kill switch — è una richiesta che il task deve rispettare.
Eccezioni: la regola dell'incapsulamento
Tutto ciò che Callable lancia viene incapsulato in ExecutionException quando si chiama get. La causa è il throwable originale:
Future<Integer> f = pool.submit(() -> { throw new IOException("nope"); });
try {
f.get();
} catch (ExecutionException e) {
e.getCause(); // IOException("nope")
e.getCause() instanceof IOException; // true
}Si noti l'asimmetria: il Callable potrebbe lanciare un'eccezione checked (il throws Exception nella sua firma), ma Future.get dichiara solo ExecutionException. L'incapsulamento è ciò che permette a una singola firma di trasportare qualsiasi possibile fallimento.
L'overload Runnable.submit — pool.submit(Runnable) — restituisce un Future<?> il cui get() restituisce null in caso di successo e incapsula comunque qualsiasi RuntimeException non catturata proveniente dal Runnable. È il modo standard per scoprire che un runnable "lancia e dimentica" è in realtà andato in crash.
I limiti di Future
Future è un canale unidirezionale: si invia, si attende, si ottiene il valore. Non si compone:
- Non si può dire "quando questo finisce, esegui quello sul risultato."
- Non si può dire "quando uno qualsiasi di questi N finisce, fai X."
- Non si può dire "combina i risultati di questi due future senza bloccare."
Per tutto questo è necessario CompletableFuture (capitolo successivo). Future è lo strumento giusto quando:
- Si vuole semplicemente un valore da un singolo task.
- Si consuma un'API che restituisce
Futuree non serve composizione. - Il contratto più semplice è sufficiente.
Per il codice moderno che fa molta composizione asincrona, si salterà Future e si arriverà direttamente a CompletableFuture — ma Future è il tipo che l'executor service restituisce ancora da submit, quindi li si vedrà entrambi.
FutureTask — l'implementazione dietro submit
La classe che alimenta submit. Si può usare direttamente:
FutureTask<Integer> task = new FutureTask<>(() -> compute());
new Thread(task).start(); // FutureTask is a Runnable
Integer v = task.get();La maggior parte del codice non costruisce FutureTask direttamente; lo fa l'executor framework. È utile però quando si ha bisogno di un Future e di un Runnable in un unico oggetto — ad esempio per pianificarlo su qualcosa di diverso da un ExecutorService.
Esempio pratico: submit, timeout, propagazione
Il programma qui sotto invia un task lento, uno veloce e uno che fallisce; dimostra get, get(timeout), l'incapsulamento delle eccezioni e la cancellazione.
Cosa osservare dall'esecuzione:
- La sezione 1 è la forma più semplice: si invia un
Callable, si chiamaget, si riceve il valore.getha bloccato il thread principale per i 50 ms impiegati dal task. È tutto ciò cheFuturefa nella sua forma base — un riferimento tipizzato e bloccante a un risultato che arriva in seguito. - La sezione 2 ha mostrato la forma con timeout. Il task lento avrebbe girato per 500 ms;
get(100, MS)ha rinunciato dopo 100 ms e ha lanciatoTimeoutException. Il successivocancel(true)ha interrotto il thread in esecuzione affinché potesse uscire prima. Senza la cancellazione, il task avrebbe continuato a girare per i restanti 400 ms — occupando un thread del cui risultato non si aveva più bisogno. - La sezione 3 ha mostrato l'incapsulamento delle eccezioni. Il
Callableha lanciatoIOException;get()l'ha rilanciata dentroExecutionException.e.getCause()ha restituito l'originale. Questo è il canale universale di fallimento dell'API — qualsiasi throw, checked o unchecked, proveniente dal corpo del task arriva qui. - La sezione 4 ha mostrato la cancellazione di un task non ancora avviato. Con entrambi i thread del pool occupati su
hog1ehog2, il taskqueuedera in attesa nella coda;cancel(false)lo ha rimosso senza mai eseguirlo. Chiamareget()sul future cancellato ha lanciatoCancellationException— una modalità di fallimento diversa da "il task ha lanciato" (che sarebbe stataExecutionException). - La sezione 5 ha mostrato
invokeAny. Il task più veloce (50 ms) ha vinto; gli altri due sono stati cancellati dall'executor.invokeAnyè lo strumento giusto per le query ridondanti — si chiamano più sorgenti, si usa il primo successo, si abbandonano le altre. È il mattone che sta alla base dei pattern di richieste hedged nei sistemi reali.
Cosa c'è dopo
Il capitolo successivo, Java CompletableFuture, introduce l'API asincrona componibile — thenApply, thenCompose, allOf, anyOf e i decine di combinatori che trasformano Future da un semplice riferimento a un risultato in una pipeline reattiva completa.