Tous les langages de programmation proposent des solutions pour gérer l’asynchronisme et le langage Java n’est pas une Exception. Au fil des années, les architectes du langage ont ajouté des nouveaux outils et frameworks pour permettre aux développeurs, comme nous, de mieux exploiter le niveau de parallélisme des processeurs modernes. Dans cet article, je vous propose de voyager dans le temps pour découvrir l’évolution du langage en matière d’asynchronisme. La preuve ? Entre la première version de la vétuste classe Thread -Java 1- et la programmation réactive -Java 9- 22 ans se sont écoulés. Rome ne s’est pas faite en un jour comme on dit ! Voici le passé, le présent et le futur des opérations multitâches en Java.
La préhistoire : avant Java 5
La première approche historique à ce problème a été la création de threads. L’idée était simple : un thread principal peut lancer un ou plusieurs threads secondaires qui tournent en parallèle. La classe Thread existe depuis la version 1 de Java et, pendant les premières versions du langage, c’était le seul moyen de lancer des tâches en arrière plan. On pourrait discuter des heures et des heures sur l’utilisation des threads, mais je me suis permis d’ajouter une pépite que ma collègue Diana Ortega m’a apprise lors de sa conférence « Comunicating sequential processes » durant la Xebicon 2017.
En effet, leur utilisation et leur synchronisation est très compliquée car il faut avoir une connaissance assez poussée du multithreading en Java. Même les développeurs les plus avisés peuvent se prendre les pieds dans le tapis à cause d’un Deadlock. En plus, les problèmes de concurrence sont très difficiles à corriger car ils sont très difficiles à reproduire. Si vous ne me croyez pas, voici une conférence de Douglas Hawkins (@doughqh) qui aborde ce sujet.
Dans cette situation, c’était clair que les développeurs n’avaient pas les outils nécessaires pour créer des applications asynchrones et robustes. Afin de pallier ce manque, plusieurs fonctionnalités ont été ajoutées à la JVM au fil des années. Tout à commencé en septembre 2004, lorsque la version 5 de Java fut publiée…
Java 5 : les bases
La version 5 de Java, mis à part les Generics, a apporté de nouveaux outils permettant de lancer des traitements asynchrones dans nos applications. Ces classes et interfaces se trouvent sous le package java.util.concurrent
. Je voudrais mettre l’accent sur deux interfaces spécialement importantes :
ExecutorService
Comme indiqué précédemment, la gestion de threads manuellement est fastidieuse et une importante source d’erreurs. Pour éviter ceci, Java 5 introduit une couche d’abstraction qui gère nos threads à notre place. Si on survole cette interface, on découvre une fonction nommée submit qui permet de lancer des traitements (sous forme de Runnable ou Callable). Grâce à la magie des expressions lambda (merci Scala pour nous montrer le chemin), on peut lancer des traitements asynchrones sans beaucoup de code superflu. Voici un exemple de code :
public final static main(String... args) { ExecutorService executorService = Executors.newSingleThreadExecutor(); Future<Integer> future1 = executorService.submit(() -> longComputation(2, 500L)); Future<Integer> future2 = executorService.submit(() -> longComputation(3, 1000L)); // Handling future's return } private Integer longComputation(Integer value, Long sleepTime) throws Exception { Thread.sleep(sleepTime); // Simuler une opération lente return value * value; }
Je voudrais faire le focus sur plusieurs aspects du code présenté ci-dessus :
- Java propose une factory nommée Executors qui permet de créer des instances ExecutorService prêtes à l’emploi. Plusieurs types sont disponibles et on peut changer d’implémentation facilement grâce à la magie de l’OOP. Dans ce cas précis, un nouveau thread est créé avec une queue associée. Cette queue est consommée par le nouveau thread de manière séquentielle
- Lorsque l’on fait submit(), le thread secondaire exécute la fonction longComputation pendant que le thread principal suit son exécution normale
- Lorsque l’on appelle à la fonction submit(), l’exécuteur nous envoie immédiatement un objet qui implémente l’interface Future
Tout ça c’est très bien, mais concrètement à quoi sert l’interface Future ?
Future
Mettons-nous en situation. Supposons qu’on est dans un restaurant d’une enseigne de restauration rapide afin d’ingérer une quantité massive d’acides gras saturés
- On passe la commande
- Lorsque l’on paie, l'(hôte / hôtesse / machine) nous donne un ticket qui contient le numéro de commande
- On s’assoit et sort notre téléphone portable pour jouer à <Insérer jeu préféré>
- Quelques minutes plus tard, on s’adresse affamé au comptoir munis de notre ticket pour récupérer notre commande (si elle est prête bien sûr)
Comme on peut le constater, ce ticket est très important car il permet au client (nous) de récupérer le résultat (notre nourriture) de manière asynchrone. Future a le même rôle. Cet objet nous permet de récupérer le résultat d’une fonction que l’on lance arrière plan. Voici quelques opérations disponibles sur cette interface :
- get() récupère le résultat de la fonction. Si le traitement est toujours en cours, le thread qui appelle la fonction get reste bloqué jusqu’à obtenir la réponse.
- get(long timeout, TimeUnit unit): Variante de get à laquelle on peut passer un timeout
- cancel() permet d’annuler le traitement en cours
- isDone() permet de vérifier si le traitement asynchrone est terminé. Ceci est utile lorsque l’on veut faire de l’active-pooling
- isCancelled() permet de vérifier si le traitement a été annulé
Voyons un peu de code. Modifions le code précédent pour récupérer les résultats des opérations en arrière plan :
public final static main(String... args) { ExecutorService executorService = Executors.newSingleThreadExecutor(); Future<Integer> future1 = executorService.submit(() -> longComputation(2, 500L)); Future<Integer> future2 = executorService.submit(() -> longComputation(3, 1000L)); System.out.println(future1.get() + future2.get()); } private Integer longComputation(Integer value, Long sleepTime) throws Exception { Thread.sleep(sleepTime); // Simuler une opération lente return value * value; }
Malgré la nette amélioration par rapport à la gestion manuelle de threads, le modèle asynchrone présenté en Java 5 présente plusieurs faiblesses :
Combinaison de Future
Future ne permet pas de combiner deux opérations asynchrones lorsque la deuxième dépend de la première. Dans ce cas, la seule solution est d’utiliser la fonction get() sur le premier Future, récupérer la valeur et lancer le deuxième traitement. Supposons que nous voulions calculer la surface d’un cercle. Pour ce faire, nous avons crée une fonction qui calcule le nombre PI avec beaucoup de précision. On décide, donc, de lancer cette opération en arrière-plan à l’aide d’un ExecutorService. Voici le code:
ExecutorService executor = Executors.newCachedThreadPool(); Future<Double> futurePi = executor.submit(this::getPI); Double pi = futurePi.get(); // Bloque le thread courant Future<Double> circleSurface = executor.submit(() -> pi * Math.pow(radius, 2)); Double surface = circleSurface.get(); // Bloque le thread courant
Voici quelque observations :
- On doit faire un get() à chaque étape de notre traitement pour récupérer la valeur calculée et lancer le calcul suivant
- Les appels à la fonction get() bloquent le thread principal pendant que le thread secondaire réalise le traitement
- On doit soumettre chaque nouvelle tâche à la main
Dans ce cas précis, on n’a plus d’asynchronisme car il n’y a qu’un thread qui tourne au même temps : soit le thread principal, soit le thread choisit par l’ExecutorService. En plus, le fait d’utiliser ExecutorService ajoute de l’overhead par rapport au code mono-thread qu’on peut voir ci-dessous :
Double surface = this.getPI() * Math.pow(radius, 2);
Attendre la complétion de plusieurs Future
Si on veut lancer plusieurs traitements asynchrones et attendre que tous soient complétés avant de continuer, on doit faire de l‘active polling, ce qui n’est pas souhaitable dû à la consommation de ressources nécessaires. Voici un example de code:
ExecutorService executor = Executors.newCachedThreadPool(); List<Future<Integer>> futures = Stream.of(0, 1, 2, 3) .map(index -> executor.submit(this::getRandomNumber)) .collect(Collectors.toList()); // Active pooling boolean isFinished; do { System.out.println("Waiting for completion"); Thread.sleep(100L); isFinished = futures.stream().allMatch(Future::isDone); } while (!isFinished); System.out.println("Done");
Comme vous pouvez l’apprécier, on doit parcourir la liste de Futures de manière régulière pour savoir si tous les Futures ont été complétés.
Attendre la completion du Future le plus rapide
Imaginons que notre application doive s’intégrer avec un système tiers pour récupérer les prix des sociétés cotées en bourse. Pour ce faire, on a plusieurs APIs tierces à notre disposition. Comment s’y prendre ? Vu que ExecutorService nous permet de lancer plusieurs tâches en parallèle, on pourrait bien lancer un appel par service disponible et utiliser la réponse du service le plus rapide. Implementer ce genre de technique est assez fastidieux avec la sémantique proposée par Future.
Fournir manuellement le résultat de Future
Lorsque l’on a présenté l’interface Future, on a parlé de la méthode get et de sa variante qui accepte un timeout. Si on utilise la version sans timeout, on risque de bloquer le thread en cours si l’opération ne retourne jamais. C’est pour ça que l’utilisation de la version avec timeout est fortement conseillée. Imaginons que nous voulons obtenir un numéro aléatoire et que nous voulons fixer un timeout afin de garantir un certain temps de réponse. Nous pourrions faire ceci avec ce code :
ExecutorService executor = Executors.newCachedThreadPool(); Future<Integer> future = executor.submit(this::getRandomNumber); Integer randomNumber; try { randomNumber = future.get(300, TimeUnit.MILLISECONDS); } catch (Exception e) { randomNumber = 0; } System.out.println("Selected number " + randomNumber);
En effet, si la function this::getRandomNumber retourne une valeur en mois de 300 ms, on utilisera cette valeur, sinon on utilisera la valeur par défaut. Même si c’est faisable, ça rajoute beaucoup de complexité à notre code.
Ces manques ont été adressées dans la version 8 de Java avec la création de la classe CompletableFuture. Le laps de temps entre Java 5 et Java 8 a favorisée l’adoption d’autres abstractions comme ListenableFuture, qui fait partie de Guava, la librairie fourre-tout de Google. Cette classe permet d’injecter des callbacks qui permettent de réagir à la complétion d’une tâche asynchrone. Voici un example de code:
ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); ListenableFuture<Explosion> explosion = service.submit(new Callable<Explosion>() { public Explosion call() { return pushBigRedButton(); } }); Futures.addCallback(explosion, new FutureCallback<Explosion>() { // we want this handler to run immediately after we push the big red button! public void onSuccess(Explosion explosion) { walkAwayFrom(explosion); } public void onFailure(Throwable thrown) { battleArchNemesis(); // escaped the explosion! } });
Java 7
Après l’hiatus de Java 6, cette version de Java inclut des nouveaux joujoux pour exploiter nos processeurs. Un nouveau framework a été introduit permettant l’implémentation des algorithmes divide-and-conquer sur des processeurs multi-coeur. Ce framework s’appelle fork-join.
Fork/Join Framework
La pierre fondatrice de ce nouveau framework est la classe ForkJoinPool qui n’est qu’une nouvelle implémentation de l’interface ExecutorService dont on a parlé dans la section précédente. Ce qui rend cette implémentation intéressante, c’est la fonctionnalité nommée work-stealling. Par défaut, ce pool a une taille égale au nombre du coeurs. Autrement dit, si on a un processeur avec 6 coeurs, on aura 5 threads worker plus le thread main.
Pour soumettre des tâches sur cet ExecutionService, on peut utiliser une nouvelle classe nommée RecursiveTask. Si on étend cette classe abstraite, on devra implémenter la fonction compute(). Dans cette fonction, on doit implémenter la logique des algorithme divide-and-conquer. Vous pouvez trouver ci-dessous, le pseudo-code:
if (la portion du travail est petit ou trivial à calculer) calculer directement le résultat else diviser le travail en deux lancer deux nouvelles tâches et attendre le résultat
Vous pouvez trouver un exemple du code dans la documentation officielle de Java à ce sujet. Juste une petite remarque, il n’y a pas de règles heuristiques pour trouver la bonne taille pour découper nos données d’entrée pour optimiser algorithme divide-and-conquer. La seule manière est de modifier la taille et de faire des tests pour voir l’impact.
Work stealing
Comme on a dit tout à l’heure, la principale différence entre un ExecutorService de base et ForkJoinPool est le work-stealing, mais c’est quoi le work-stealing au juste ? Dans un monde idéal, lorsqu’on partitionne nos données d’entrée en tâches de taille égale, toutes les tâches devraient prendre le même temps de CPU. Nous savons que ceci n’est pas vrai dans la vie réelle car on n’est pas à l’abri de problèmes comme la surcharge d’un coeur du processeur, des lenteurs liées à de l’IO, etc. Dans ces situations, Il se peut que certains threads terminent leur tâches plus rapidement que d’autres. Dans un ExecutorService traditionnel, ce thread resterait en idle, ce qui ferait plomber la performance de notre programme. Dans le cadre du Fork/Join, les threads ont un rôle plus proactive :
- Chaque thread worker du ForkJoinPool a une queue de tâches à faire
- Chaque thread dépile sa liste de tâches jusqu’à qu’il n’en reste plus
- Si un thread n’a plus de tâches dans la liste mais le programme n’est pas terminé, il pioche -work-stealing- une autre tâche dans la liste d’un autre thread
- Le programme termine lorsqu’il ne reste plus de tâches
Voici un schéma de cet mécanisme:
Ce système est très intelligent car il permet de répartir la charge entre les différents threads workers du pool : le thread plus rapide traitera plus de tâches tandis que le plus lent en traitera moins. De ce fait, afin de mieux balancer les tâches, il est préférable de créer beaucoup de petites tâches que d’en créer peu mais très grosses.
Dans cet article, nous avons criblé les améliorations concernant l’asynchronisme jusqu’à Java 7, mais des évolutions critiques ont été apportées aux versions ultérieures. Je vous invite à découvrir ces évolutions dans un prochain article.