Guide de l’ExecutorService Java

Aperçu

L’ExecutorService est une API du JDK qui simplifie l’exécution de tâches en mode asynchrone. D’une manière générale, ExecutorService fournit automatiquement un pool de threads et une API pour y affecter des tâches.

Lectures complémentaires :

Guide du framework Fork/Join en Java

Une introduction au framework fork/join présenté dans Java 7 et aux outils permettant d’accélérer les traitements parallèles en tentant d’utiliser tous les cœurs de processeur disponibles.
Lire la suite →

Vue d’ensemble du cadre java.util.concurrent

Découvrez le contenu du paquetage java.util.concurrent.
Lisez plus →

Guide sur java.util.concurrent.Locks

Dans cet article, nous explorons diverses implémentations de l’interface Lock et la classe StampedLock nouvellement introduite dans Java 9.
Lire la suite →

Instauration d’ExecutorService

2.1. Méthodes d’usine de la classe Executors

La façon la plus simple de créer un ExecutorService est d’utiliser l’une des méthodes d’usine de la classe Executors.

Par exemple, la ligne de code suivante créera un pool de threads avec 10 threads:

ExecutorService executor = Executors.newFixedThreadPool(10);

Il existe plusieurs autres méthodes d’usine pour créer un ExecutorService prédéfini qui répond à des cas d’utilisation spécifiques. Pour trouver la méthode la plus adaptée à vos besoins, consultez la documentation officielle d’Oracle.

2.2. Créer directement un ExecutorService

Parce que ExecutorService est une interface, une instance de n’importe laquelle de ses implémentations peut être utilisée. Il existe plusieurs implémentations parmi lesquelles choisir dans le paquetage java.util.concurrent, ou vous pouvez créer la vôtre.

Par exemple, la classe ThreadPoolExecutor possède quelques constructeurs que nous pouvons utiliser pour configurer un service exécuteur et son pool interne :

ExecutorService executorService = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

Vous pouvez remarquer que le code ci-dessus est très similaire au code source de la méthode de fabrique newSingleThreadExecutor(). Pour la plupart des cas, une configuration manuelle détaillée n’est pas nécessaire.

Assignation de tâches à l’ExecutorService

L’ExecutorService peut exécuter des tâches Runnable et Callable. Pour garder les choses simples dans cet article, deux tâches primitives seront utilisées. Remarquez que nous utilisons ici des expressions lambda au lieu de classes internes anonymes :

Runnable runnableTask = () -> { try { TimeUnit.MILLISECONDS.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); }};Callable<String> callableTask = () -> { TimeUnit.MILLISECONDS.sleep(300); return "Task's execution";};List<Callable<String>> callableTasks = new ArrayList<>();callableTasks.add(callableTask);callableTasks.add(callableTask);callableTasks.add(callableTask);

Nous pouvons affecter des tâches à l’ExecutorService à l’aide de plusieurs méthodes, notamment execute(), qui est héritée de l’interface Executor, mais aussi submit(), invokeAny() et invokeAll().

La méthode execute() est nulle et ne donne aucune possibilité d’obtenir le résultat de l’exécution d’une tâche ou de vérifier le statut de la tâche (est-elle en cours d’exécution):

executorService.execute(runnableTask);

submit() soumet une tâche Callable ou Runnable à un ExecutorService et renvoie un résultat de type Future :

Future<String> future = executorService.submit(callableTask);

invokeAny() affecte une collection de tâches à un ExecutorService, provoquant l’exécution de chacune d’elles, et renvoie le résultat de l’exécution réussie d’une tâche (s’il y a eu une exécution réussie) :

String result = executorService.invokeAny(callableTasks);

invokeAll() affecte une collection de tâches à un ExecutorService, provoquant l’exécution de chacune d’elles, et renvoie le résultat de toutes les exécutions de tâches sous la forme d’une liste d’objets de type Future :

List<Future<String>> futures = executorService.invokeAll(callableTasks);

Avant d’aller plus loin, nous devons aborder deux autres points : la fermeture d’un ExecutorService et le traitement des types de retour Future.

Arrêter un ExecutorService

En général, l’ExecutorService ne sera pas automatiquement détruit lorsqu’il n’y a plus de tâche à traiter. Il restera en vie et attendra un nouveau travail à effectuer.

Dans certains cas, cela est très utile, par exemple lorsqu’une appli doit traiter des tâches qui apparaissent de manière irrégulière ou que la quantité de tâches n’est pas connue au moment de la compilation.

D’un autre côté, une app pourrait atteindre sa fin mais ne pas être arrêtée parce qu’un ExecutorService en attente fera en sorte que la JVM continue à fonctionner.

Pour arrêter correctement un ExecutorService, nous avons les API shutdown() et shutdownNow().

La méthode shutdown() ne provoque pas la destruction immédiate de l’ExecutorService. Elle fait en sorte que l’ExecutorService cesse d’accepter de nouvelles tâches et s’arrête après que tous les threads en cours d’exécution aient terminé leur travail actuel :

executorService.shutdown();

La méthode shutdownNow() tente de détruire immédiatement l’ExecutorService, mais elle ne garantit pas que tous les threads en cours d’exécution seront arrêtés en même temps :

List<Runnable> notExecutedTasks = executorService.shutDownNow();

Cette méthode renvoie une liste de tâches qui attendent d’être traitées. C’est au développeur de décider ce qu’il faut faire de ces tâches.

Une bonne façon d’arrêter l’ExecutorService (qui est également recommandée par Oracle) est d’utiliser ces deux méthodes combinées avec la méthode awaitTermination():

executorService.shutdown();try { if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException e) { executorService.shutdownNow();}

Avec cette approche, l’ExecutorService cessera d’abord de prendre de nouvelles tâches, puis attendra jusqu’à une période de temps spécifiée que toutes les tâches soient terminées. Si ce délai expire, l’exécution est immédiatement arrêtée.

L’interface Future

Les méthodes submit() et invokeAll() renvoient un objet ou une collection d’objets de type Future, ce qui nous permet d’obtenir le résultat de l’exécution d’une tâche ou de vérifier l’état de la tâche (est-elle en cours d’exécution).

L’interface Future fournit une méthode spéciale de blocage get(), qui renvoie un résultat réel de l’exécution de la tâche Callable ou null dans le cas d’une tâche Runnable:

Future<String> future = executorService.submit(callableTask);String result = null;try { result = future.get();} catch (InterruptedException | ExecutionException e) { e.printStackTrace();}

L’appel de la méthode get() alors que la tâche est toujours en cours d’exécution entraînera un blocage de l’exécution jusqu’à ce que la tâche s’exécute correctement et que le résultat soit disponible.

Avec un blocage très long causé par la méthode get(), les performances d’une application peuvent se dégrader. Si les données résultantes ne sont pas cruciales, il est possible d’éviter un tel problème en utilisant des timeouts :

String result = future.get(200, TimeUnit.MILLISECONDS);

Si la période d’exécution est plus longue que celle spécifiée (dans ce cas, 200 millisecondes), une TimeoutException sera levée.

Nous pouvons utiliser la méthode isDone() pour vérifier si la tâche assignée a déjà été traitée ou non.

L’interface Future prévoit également d’annuler l’exécution de la tâche avec la méthode cancel() et de vérifier l’annulation avec la méthode isCancelled():

boolean canceled = future.cancel(true);boolean isCancelled = future.isCancelled();

L’interface ScheduledExecutorService

Le ScheduledExecutorService exécute les tâches après un certain délai prédéfini et/ou périodiquement.

Encore une fois, la meilleure façon d’instancier un ScheduledExecutorService est d’utiliser les méthodes de fabrique de la classe Executors.

Pour cette section, nous utilisons un ScheduledExecutorService avec un thread:

ScheduledExecutorService executorService = Executors .newSingleThreadScheduledExecutor();

Pour planifier l’exécution d’une seule tâche après un délai fixe, utilisez la méthode scheduled() du ScheduledExecutorService.

Deux méthodes scheduled() permettent d’exécuter des tâches Runnable ou Callable :

Future<String> resultFuture = executorService.schedule(callableTask, 1, TimeUnit.SECONDS);

La méthode scheduleAtFixedRate() nous permet d’exécuter une tâche périodiquement après un délai fixe. Le code ci-dessus retarde d’une seconde avant d’exécuter callableTask.

Le bloc de code suivant exécutera une tâche après un délai initial de 100 millisecondes. Et après cela, il exécutera la même tâche toutes les 450 millisecondes:

Future<String> resultFuture = service .scheduleAtFixedRate(runnableTask, 100, 450, TimeUnit.MILLISECONDS);

Si le processeur a besoin de plus de temps pour exécuter une tâche assignée que le paramètre de période de la méthode scheduleAtFixedRate(), le ScheduledExecutorService attendra que la tâche actuelle soit terminée avant de commencer la suivante.

S’il est nécessaire d’avoir un délai de longueur fixe entre les itérations de la tâche, il faut utiliser la méthode scheduleWithFixedDelay().

Par exemple, le code suivant garantira une pause de 150 millisecondes entre la fin de l’exécution en cours et le début d’une autre :

service.scheduleWithFixedDelay(task, 100, 150, TimeUnit.MILLISECONDS);

Selon les contrats de méthode scheduleAtFixedRate() et scheduleWithFixedDelay(), l’exécution de la période de la tâche se terminera à la fin de l’ExecutorService ou si une exception est levée pendant l’exécution de la tâche.

ExecutorService vs Fork/Join

Après la sortie de Java 7, de nombreux développeurs ont décidé de remplacer le framework ExecutorService par le framework fork/join.

Ce n’est cependant pas toujours la bonne décision. Malgré la simplicité et les fréquents gains de performance associés à fork/join, il réduit le contrôle du développeur sur l’exécution concurrente.

ExecutorService donne au développeur la possibilité de contrôler le nombre de threads générés et la granularité des tâches qui doivent être exécutées par des threads séparés. Le meilleur cas d’utilisation d’ExecutorService est le traitement de tâches indépendantes, telles que des transactions ou des requêtes selon le schéma « un thread pour une tâche. »

En revanche, selon la documentation d’Oracle, fork/join a été conçu pour accélérer le travail qui peut être décomposé en plus petits morceaux de manière récursive.

Conclusion

Malgré la simplicité relative d’ExecutorService, il existe quelques pièges courants.

Résumons-les :

Garder en vie un ExecutorService inutilisé : Voir l’explication détaillée dans la section 4 sur la façon d’arrêter un ExecutorService.

Mauvaise capacité du pool de threads tout en utilisant un pool de threads à longueur fixe : Il est très important de déterminer le nombre de threads dont l’application aura besoin pour exécuter les tâches efficacement. Un pool de threads trop grand entraînera des frais généraux inutiles juste pour créer des threads qui seront principalement en mode d’attente. Un nombre trop faible peut donner l’impression qu’une application n’est pas réactive en raison des longues périodes d’attente des tâches dans la file d’attente.

Appeler la méthode get() d’un Future après l’annulation de la tâche : Tenter d’obtenir le résultat d’une tâche déjà annulée déclenche une CancellationException.

Blocage inopinément long avec la méthode get() d’un Future : Nous devrions utiliser des timeouts pour éviter les attentes inattendues.

Comme toujours, le code de cet article est disponible dans le dépôt GitHub.

Débutez avec Spring 5 et Spring Boot 2, grâce au cours Learn Spring:

>> VÉRIFIEZ LE COURS

.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *