Java Callable et Future
Retournez des valeurs depuis des tâches avec Callable et consommez-les de manière asynchrone avec Future — attente, timeout, annulation, exceptions.
Runnable permet à un thread d'exécuter du travail, mais ne permet pas de retourner une valeur ni de lever une exception vérifiée. La paire qui le permet est Callable<V> (le producteur) et Future<V> (le consommateur). Vous soumettez un Callable<V> à un ExecutorService et récupérez un Future<V>, qui est votre handle pour : attendre le résultat, lire la valeur, capturer l'exception de la tâche ou l'annuler.
Il s'agit de l'API de plus bas niveau sensible aux résultats dans la boîte à outils concurrente de Java. Le prochain chapitre, CompletableFuture, ajoute des chaînes, des combinateurs et des pipelines ; mais le contrat — « un résultat asynchrone sur lequel on peut attendre » — est ce que Future a défini en premier, et c'est encore le bon outil pour un simple « va faire ça et dis-moi quand c'est terminé ».
Callable<V> — Runnable avec un type de retour
L'interface :
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}Les deux différences par rapport à Runnable :
- Retourne
V(le paramètre de type). - Peut lever n'importe quelle
Exception— y compris les exceptions vérifiées.
Comme Runnable, c'est une interface fonctionnelle — les lambdas et les références de méthodes fonctionnent :
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 est la bonne forme pour tout travail du type « va faire ça et rapporte-moi une valeur ». Runnable ne convient que si vous n'avez vraiment pas besoin d'un résultat.
Future<V> — le handle vers un résultat asynchrone
Quand vous submittez un Callable<V>, l'executor retourne 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;
}Cinq méthodes. Trois que vous utiliserez souvent.
get()
Bloque le thread appelant jusqu'à ce que la tâche se termine, puis retourne le résultat :
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> { Thread.sleep(100); return 42; });
Integer value = f.get(); // blocks until done; returns 42get() lève trois choses que vous devez gérer :
InterruptedException— l'appelant a été interrompu pendant l'attente. Traitement standard : réinitialiser le drapeau d'interruption et propager.ExecutionException— la tâche elle-même a levé une exception. L'exception d'origine est encapsulée ; accédez-y via.getCause().CancellationException— quelqu'un a appelécancel()sur le future.
Une forme courante :
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)
Identique à get() mais avec une échéance. Lève TimeoutException si la tâche ne se termine pas à temps :
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");
}C'est la bonne forme pour « j'appelle un backend qui devrait répondre en N ms ; sinon, échouer rapidement ». Associez toujours le catch à un cancel(true) — sinon la tâche continue de s'exécuter en arrière-plan, utilisant un thread dont vous ne vous souciez plus du résultat.
cancel(boolean)
Demande à la tâche de s'arrêter :
boolean cancelled = f.cancel(true); // true = interrupt the running threadL'argument indique à l'executor s'il doit interrompre le thread de travail. Avec true, le worker reçoit une InterruptedException depuis tout appel bloquant (sleep, wait, I/O) ; avec false, l'annulation est sans effet si la tâche a déjà démarré — seules les tâches non démarrées sont retirées de la file d'attente.
cancel est coopératif. Une tâche qui ne vérifie pas Thread.currentThread().isInterrupted() et n'a pas d'appels bloquants continuera à s'exécuter jusqu'à ce qu'elle se termine. L'annulation n'est pas un interrupteur d'arrêt — c'est une requête que la tâche doit honorer.
Exceptions : la règle d'encapsulation
Tout ce que le Callable lève est encapsulé dans ExecutionException lorsque vous appelez get. La cause est le throwable d'origine :
Future<Integer> f = pool.submit(() -> { throw new IOException("nope"); });
try {
f.get();
} catch (ExecutionException e) {
e.getCause(); // IOException("nope")
e.getCause() instanceof IOException; // true
}Notez l'asymétrie : le Callable peut lever une exception vérifiée (la clause throws Exception dans sa signature), mais Future.get ne déclare que ExecutionException. L'encapsulation est ce qui permet à une seule signature de transporter chaque échec possible.
La surcharge Runnable.submit — pool.submit(Runnable) — retourne un Future<?> dont get() retourne null en cas de succès et encapsule tout RuntimeException non capturé du Runnable. C'est la façon standard de découvrir qu'un runnable « fire and forget » a en fait planté.
Les limites de Future
Future est un canal unidirectionnel : vous soumettez, vous attendez, vous obtenez la valeur. Il ne se compose pas :
- Vous ne pouvez pas dire « quand ça se termine, exécute ça sur le résultat ».
- Vous ne pouvez pas dire « quand l'un de ces N se termine, fais X ».
- Vous ne pouvez pas dire « combine les résultats de ces deux futures sans bloquer ».
Pour tout cela, vous avez besoin de CompletableFuture (prochain chapitre). Future est le bon outil quand :
- Vous voulez simplement une valeur de retour depuis une seule tâche.
- Vous consommez une API qui retourne des
Futures et n'avez pas besoin de les composer. - Le contrat le plus simple suffit.
Pour du code moderne qui fait beaucoup de composition asynchrone, vous passerez généralement directement à CompletableFuture — mais Future est le type que l'executor service retourne toujours depuis submit, donc vous verrez les deux.
FutureTask — l'implémentation derrière submit
La classe qui propulse submit. Vous pouvez l'utiliser directement :
FutureTask<Integer> task = new FutureTask<>(() -> compute());
new Thread(task).start(); // FutureTask is a Runnable
Integer v = task.get();La plupart du code ne construit pas FutureTask directement ; le framework executor s'en charge. Mais c'est utile quand vous avez besoin d'un Future et d'un Runnable dans un seul objet — par exemple pour le planifier sur autre chose qu'un ExecutorService.
Un exemple concret : soumettre, timeout, propagation
Le programme ci-dessous soumet une tâche lente, une tâche rapide et une tâche qui échoue ; il démontre get, get(timeout), le dépaquetage des exceptions et l'annulation.
Ce qu'il faut retenir de l'exécution :
- La section 1 est la forme la plus simple : soumettre un
Callable, appelerget, recevoir la valeur.geta bloqué le thread principal pendant les 50 ms que la tâche a pris. C'est tout ce queFuturefait dans sa forme basique — un handle typé et bloquant vers un résultat qui arrive plus tard. - La section 2 a montré la forme avec timeout. La tâche lente aurait tourné 500 ms ;
get(100, MS)a abandonné après 100 ms et a levéTimeoutException. Lecancel(true)suivant a interrompu le thread en cours d'exécution pour qu'il puisse terminer plus tôt. Sans l'annulation, la tâche aurait continué à tourner pendant les 400 ms restantes — utilisant un thread dont vous ne vous souciez plus du résultat. - La section 3 a montré l'encapsulation des exceptions. Le
Callablea levéIOException;get()l'a re-levée dansExecutionException.e.getCause()a redonné l'original. C'est le canal universel d'échec de l'API — chaque throw vérifié ou non vérifié du corps atterrit ici. - La section 4 a montré l'annulation d'une tâche non démarrée. Les deux threads du pool étant occupés sur
hog1ethog2, la tâchequeuedétait dans la file de travail ;cancel(false)l'a retirée sans jamais l'exécuter. Appelerget()sur le future annulé a levéCancellationException— un mode d'échec différent de « la tâche a levé » (qui aurait étéExecutionException). - La section 5 a montré
invokeAny. La tâche la plus rapide (50 ms) a gagné ; les deux autres ont été annulées par l'executor.invokeAnyest le bon outil pour les requêtes redondantes — appeler plusieurs sources, utiliser le premier succès, abandonner le reste. C'est le bloc de base derrière les patterns de requêtes hedgées dans les systèmes réels.
Et ensuite
Le prochain chapitre, Java CompletableFuture, présente l'API asynchrone composable — thenApply, thenCompose, allOf, anyOf, et les dizaines de combinateurs qui transforment Future d'un simple handle à un résultat en un pipeline réactif complet.