Guide sur java.util.concurrent.Future

Guide de java.util.concurrent.Future

1. Vue d'ensemble

Dans cet article, nous allons en apprendre davantage surFuture. Une interface qui existe depuis Java 1.5 et qui peut être très utile lorsque vous travaillez avec des appels asynchrones et des traitements simultanés.

2. Création deFutures

En termes simples, la classeFuture représente un résultat futur d'un calcul asynchrone - un résultat qui apparaîtra éventuellement dans lesFuture une fois le traitement terminé.

Voyons comment écrire des méthodes qui créent et renvoient une instanceFuture.

Les méthodes de longue durée sont de bons candidats pour le traitement asynchrone et l'interfaceFuture. Cela nous permet d'exécuter un autre processus pendant que nous attendons la fin de la tâche encapsulée dansFuture.

Voici quelques exemples d'opérations qui tireraient parti de la nature asynchrone deFuture:

  • processus informatiques intensifs (calculs mathématiques et scientifiques)

  • manipuler de grandes structures de données (big data)

  • appels de méthodes distantes (téléchargement de fichiers, suppression de HTML, services Web).

2.1. Implémentation deFutures avecFutureTask

Pour notre exemple, nous allons créer une classe très simple qui calcule le carré d'unInteger. Cela ne correspond certainement pas à la catégorie des méthodes "de longue durée", mais nous allons lui mettre un appelThread.sleep() pour que cela dure 1 seconde:

public class SquareCalculator {

    private ExecutorService executor
      = Executors.newSingleThreadExecutor();

    public Future calculate(Integer input) {
        return executor.submit(() -> {
            Thread.sleep(1000);
            return input * input;
        });
    }
}

Le bit de code qui effectue réellement le calcul est contenu dans la méthodecall(), fournie sous forme d'expression lambda. Comme vous pouvez le voir, il n'y a rien de spécial à ce sujet, à l'exception de l'appelsleep() mentionné plus tôt.

Cela devient plus intéressant lorsque nous portons notre attention sur l'utilisation deCallable etExecutorService.

Callable est une interface représentant une tâche qui renvoie un résultat et possède une seule méthodecall(). Ici, nous en avons créé une instance à l'aide d'une expression lambda.

Créer une instance deCallable ne nous emmène nulle part, nous devons quand même passer cette instance à un exécuteur qui se chargera de démarrer cette tâche dans un nouveau thread et nous rendra le précieux objetFuture. C’est là queExecutorService entre en jeu.

Il y a plusieurs façons d'obtenir une instance deExecutorService, la plupart d'entre elles sont fournies par les méthodes de fabrique statique de la classe utilitaireExecutors. Dans cet exemple, nous avons utilisé lesnewSingleThreadExecutor() de base, ce qui nous donne unExecutorService capable de gérer un seul thread à la fois.

Une fois que nous avons un objetExecutorService, il nous suffit d'appelersubmit() en passant nosCallable comme argument. submit() s'occupera du démarrage de la tâche et retournera un objetFutureTask, qui est une implémentation de l'interfaceFuture.

3. Consommation deFutures

Jusqu'à présent, nous avons appris à créer une instance deFuture.

Dans cette section, nous allons apprendre à utiliser cette instance en explorant toutes les méthodes qui font partie de l'API deFuture.

3.1. Utilisation deisDone() etget() pour obtenir des résultats

Nous devons maintenant appelercalculate() et utiliser lesFuture renvoyés pour obtenir lesInteger résultants. Deux méthodes de l'APIFuture nous aideront dans cette tâche.

Future.isDone() nous indique si l'exécuteur a fini de traiter la tâche. Si la tâche est terminée, elle retourneratrue sinon, elle retournerafalse.

La méthode qui renvoie le résultat réel du calcul estFuture.get(). Notez que cette méthode bloque l'exécution jusqu'à ce que la tâche soit terminée, mais dans notre exemple, cela ne posera pas de problème puisque nous vérifierons d'abord si la tâche est terminée en appelantisDone().

En utilisant ces deux méthodes, nous pouvons exécuter un autre code en attendant la fin de la tâche principale:

Future future = new SquareCalculator().calculate(10);

while(!future.isDone()) {
    System.out.println("Calculating...");
    Thread.sleep(300);
}

Integer result = future.get();

Dans cet exemple, nous écrivons un simple message sur la sortie pour informer l'utilisateur que le programme effectue le calcul.

La méthodeget() bloquera l'exécution jusqu'à ce que la tâche soit terminée. Mais nous n'avons pas à nous en soucier puisque notre exemple n'arrive qu'au point oùget() est appelé après s'être assuré que la tâche est terminée. Ainsi, dans ce scénario,future.get() reviendra toujours immédiatement.

Il est à noter queget() a une version surchargée qui prend un timeout et unTimeUnit comme arguments:

Integer result = future.get(500, TimeUnit.MILLISECONDS);

La différence entreget(long, TimeUnit) etget(), est que le premier lancera unTimeoutException si la tâche ne revient pas avant le délai d'expiration spécifié.

3.2. Annulation d'unFuture aveccancel()

Supposons que nous ayons déclenché une tâche mais, pour une raison quelconque, nous ne nous soucions plus du résultat. Nous pouvons utiliserFuture.cancel(boolean) pour dire à l'exécuteur d'arrêter l'opération et d'interrompre son thread sous-jacent:

Future future = new SquareCalculator().calculate(4);

boolean canceled = future.cancel(true);

Notre instance deFuture du code ci-dessus ne terminerait jamais son opération. En fait, si nous essayons d'appelerget() à partir de cette instance, après l'appel àcancel(), le résultat serait unCancellationException. Future.isCancelled() nous dira si unFuture a déjà été annulé. Cela peut être très utile pour éviter d'obtenir unCancellationException.

Il est possible qu'un appel àcancel() échoue. Dans ce cas, sa valeur renvoyée serafalse. Notez quecancel() prend une valeurboolean comme argument - cela contrôle si le thread exécutant cette tâche doit être interrompu ou non.

4. Plus de multithreading avec les poolsThread

NotreExecutorService actuel est mono-thread puisqu'il a été obtenu avec lesExecutors.newSingleThreadExecutor. Pour mettre en évidence cette "threadness unique", déclenchons deux calculs simultanément:

SquareCalculator squareCalculator = new SquareCalculator();

Future future1 = squareCalculator.calculate(10);
Future future2 = squareCalculator.calculate(100);

while (!(future1.isDone() && future2.isDone())) {
    System.out.println(
      String.format(
        "future1 is %s and future2 is %s",
        future1.isDone() ? "done" : "not done",
        future2.isDone() ? "done" : "not done"
      )
    );
    Thread.sleep(300);
}

Integer result1 = future1.get();
Integer result2 = future2.get();

System.out.println(result1 + " and " + result2);

squareCalculator.shutdown();

Analysons maintenant la sortie de ce code:

calculating square for: 10
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
calculating square for: 100
future1 is done and future2 is not done
future1 is done and future2 is not done
future1 is done and future2 is not done
100 and 10000

Il est clair que le processus n’est pas parallèle. Remarquez que la deuxième tâche ne commence qu'une fois la première tâche terminée, ce qui prend environ 2 secondes à l'ensemble du processus.

Pour rendre notre programme vraiment multi-thread, nous devrions utiliser une autre saveur deExecutorService. Voyons comment le comportement de notre exemple change si nous utilisons un pool de threads, fourni par la méthode d'usineExecutors.newFixedThreadPool():

public class SquareCalculator {

    private ExecutorService executor = Executors.newFixedThreadPool(2);

    //...
}

Avec un simple changement dans notre classeSquareCalculator, nous avons maintenant un exécuteur capable d'utiliser 2 threads simultanés.

Si nous exécutons à nouveau exactement le même code client, nous obtiendrons le résultat suivant:

calculating square for: 10
calculating square for: 100
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
future1 is not done and future2 is not done
100 and 10000

Cela semble beaucoup mieux maintenant. Remarquez comment les 2 tâches commencent et finissent de s'exécuter simultanément, et l'ensemble du processus prend environ 1 seconde.

Il existe d'autres méthodes d'usine qui peuvent être utilisées pour créer des pools de threads, commeExecutors.newCachedThreadPool() qui réutilise lesThreads précédemment utilisés lorsqu'ils sont disponibles, etExecutors.newScheduledThreadPool() qui planifie les commandes à exécuter après un délai donné.

Pour plus d'informations sur lesExecutorService, lisez nosarticle dédiés au sujet.

5. Vue d'ensemble deForkJoinTask

ForkJoinTask est une classe abstraite qui implémenteFuture et est capable d'exécuter un grand nombre de tâches hébergées par un petit nombre de threads réels dansForkJoinPool.

Dans cette section, nous allons couvrir rapidement les principales caractéristiques desForkJoinPool. Pour un guide complet sur le sujet, consultez nosGuide to the Fork/Join Framework in Java.

Ensuite, la caractéristique principale d'unForkJoinTask est qu'il génère généralement de nouvelles sous-tâches dans le cadre du travail requis pour accomplir sa tâche principale. Il génère de nouvelles tâches en appelantfork() et il rassemble tous les résultats avecjoin(), donc le nom de la classe.

Il existe deux classes abstraites qui implémententForkJoinTask:RecursiveTask qui renvoie une valeur à la fin, etRecursiveAction qui ne renvoie rien. Comme les noms l’impliquent, ces classes doivent être utilisées pour des tâches récursives, comme par exemple la navigation dans un système de fichiers ou un calcul mathématique complexe.

Développons notre exemple précédent pour créer une classe qui, étant donné unInteger, calculera les carrés de somme pour tous ses éléments factoriels. Ainsi, par exemple, si nous passons le nombre 4 à notre calculatrice, nous devrions obtenir le résultat de la somme de 4² + 3² + 2² + 1² qui est 30.

Tout d'abord, nous devons créer une implémentation concrète deRecursiveTask et implémenter sa méthodecompute(). C'est ici que nous allons écrire notre logique métier:

public class FactorialSquareCalculator extends RecursiveTask {

    private Integer n;

    public FactorialSquareCalculator(Integer n) {
        this.n = n;
    }

    @Override
    protected Integer compute() {
        if (n <= 1) {
            return n;
        }

        FactorialSquareCalculator calculator
          = new FactorialSquareCalculator(n - 1);

        calculator.fork();

        return n * n + calculator.join();
    }
}

Remarquez comment nous atteignons la récursivité en créant une nouvelle instance deFactorialSquareCalculator danscompute(). En appelantfork(), une méthode non bloquante, nous demandons àForkJoinPool de lancer l'exécution de cette sous-tâche.

La méthodejoin() retournera le résultat de ce calcul, auquel nous ajoutons le carré du nombre que nous visitons actuellement.

Il ne nous reste plus qu'à créer unForkJoinPool pour gérer l'exécution et la gestion des threads:

ForkJoinPool forkJoinPool = new ForkJoinPool();

FactorialSquareCalculator calculator = new FactorialSquareCalculator(10);

forkJoinPool.execute(calculator);

6. Conclusion

Dans cet article, nous avons eu une vue complète de l'interfaceFuture, en visitant toutes ses méthodes. Nous avons également appris à tirer parti de la puissance des pools de threads pour déclencher plusieurs opérations parallèles. Les principales méthodes de la classeForkJoinTask,fork() etjoin() ont également été brièvement couvertes.

Nous avons beaucoup d'autres excellents articles sur les opérations parallèles et asynchrones en Java. Voici trois d'entre eux qui sont étroitement liés à l'interfaceFuture (certains d'entre eux sont déjà mentionnés dans l'article):

Vérifiez le code source utilisé dans cet article dans nosGitHub repository.