Java inclut le package java.util.concurrent
, que nous appelons l’API de Concurrence, pour gérer le travail complexe de gestion des threads à votre place. L’API de Concurrence inclut l’interface ExecutorService
, qui définit des services qui créent et gèrent des threads.
Vous obtenez d’abord une instance de l’interface ExecutorService
, puis vous envoyez des tâches au service pour qu’elles soient traitées. Le framework inclut de nombreuses fonctionnalités utiles, comme le regroupement de threads et la planification. Il est recommandé d’utiliser ce framework chaque fois que vous devez créer et exécuter une tâche séparée, même si vous n’avez besoin que d’un seul thread.
Lorsque vous écrivez des programmes multithreads en pratique, il est souvent préférable d’utiliser l’API de Concurrence (ou un autre SDK multithread) plutôt que de travailler directement avec les objets Thread
. Les bibliothèques sont beaucoup plus robustes, et il est plus facile de gérer des interactions complexes.
Introduction à l’Exécuteur Mono-Thread
Puisque ExecutorService
est une interface, comment obtenir une instance ? L’API de Concurrence inclut la classe factory Executors
qui peut être utilisée pour créer des instances de l’objet ExecutorService
. Réécrivons notre exemple précédent avec les deux instances Runnable
en utilisant un ExecutorService
.
ExecutorService service = Executors.newSingleThreadExecutor();
try {
System.out.println("début");
service.execute(imprimerInventaire);
service.execute(imprimerRegistres);
service.execute(imprimerInventaire);
System.out.println("fin");
} finally {
service.shutdown();
}
Dans cet exemple, nous utilisons la méthode newSingleThreadExecutor()
pour créer le service. Contrairement à notre exemple précédent, où nous avions quatre threads (un main()
et trois nouveaux threads), nous n’avons que deux threads (un main()
et un nouveau thread). Cela signifie que la sortie, bien qu’encore imprévisible, aura moins de variations qu’auparavant. Par exemple, voici une sortie possible :
début
Impression de l'inventaire du zoo
Impression de l'enregistrement: 0
Impression de l'enregistrement: 1
fin
Impression de l'enregistrement: 2
Remarquez que la boucle imprimerRegistres
n’est plus interrompue par d’autres tâches Runnable
envoyées à l’exécuteur de thread. Avec un exécuteur mono-thread, les tâches sont garanties d’être exécutées séquentiellement. Notez que le texte de fin est affiché alors que nos tâches d’exécuteur de thread sont toujours en cours d’exécution. C’est parce que la méthode main()
est toujours un thread indépendant du ExecutorService
.
Arrêt d’un Exécuteur de Thread
Une fois que vous avez fini d’utiliser un exécuteur de thread, il est important d’appeler la méthode shutdown()
. Un exécuteur de thread crée un thread non-daemon lors de la première tâche exécutée, donc ne pas appeler shutdown()
entraînera que votre application ne se terminera jamais.
Le processus d’arrêt d’un exécuteur de thread consiste d’abord à rejeter toutes les nouvelles tâches soumises à l’exécuteur de thread tout en continuant à exécuter les tâches précédemment soumises. Pendant ce temps, l’appel à isShutdown()
retournera true
, tandis que isTerminated()
retournera false
. Si une nouvelle tâche est soumise à l’exécuteur de thread pendant qu’il s’arrête, une RejectedExecutionException
sera lancée. Une fois que toutes les tâches actives sont terminées, isShutdown()
et isTerminated()
retourneront tous deux true
. La Figure 13.3 montre le cycle de vie d’un objet ExecutorService
.
Vous devez savoir que shutdown()
n’arrête pas les tâches qui ont déjà été soumises à l’exécuteur de thread.
Que faire si vous voulez annuler toutes les tâches en cours et à venir ? Le ExecutorService
fournit une méthode appelée shutdownNow()
, qui tente d’arrêter toutes les tâches en cours et supprime celles qui n’ont pas encore été démarrées. Il n’est pas garanti de réussir car il est possible de créer un thread qui ne se terminera jamais, donc toute tentative de l’interrompre peut être ignorée.
Comme vous l’avez appris dans le Chapitre 11, “Exceptions et Localisation”, les ressources telles que les exécuteurs de thread doivent être correctement fermées pour éviter les fuites de mémoire. Malheureusement, l’interface ExecutorService
n’étend pas l’interface AutoCloseable
, donc vous ne pouvez pas utiliser une instruction try-with-resources. Vous pouvez toujours utiliser un bloc finally
, comme nous le faisons tout au long de ce chapitre. Bien que vous ne soyez pas obligé d’utiliser un bloc finally
, c’est considéré comme une bonne pratique.
Soumission de Tâches
Vous pouvez soumettre des tâches à une instance ExecutorService
de plusieurs façons. La première méthode présentée, execute()
, est héritée de l’interface Executor
, que l’interface ExecutorService
étend. La méthode execute()
prend une instance Runnable
et complète la tâche de manière asynchrone. Comme le type de retour de la méthode est void
, elle ne nous dit rien sur le résultat de la tâche. C’est considéré comme une méthode “fire-and-forget”, car une fois soumise, les résultats ne sont pas directement disponibles pour le thread appelant.
Heureusement, les créateurs de Java ont ajouté des méthodes submit()
à l’interface ExecutorService
, qui, comme execute()
, peuvent être utilisées pour compléter des tâches de manière asynchrone. Contrairement à execute()
, cependant, submit()
renvoie une instance Future
qui peut être utilisée pour déterminer si la tâche est terminée. Elle peut également être utilisée pour renvoyer un objet de résultat générique après la fin de la tâche.
Le Tableau 13.1 montre les cinq méthodes, y compris execute()
et deux méthodes submit()
, que vous devriez connaître. Ne vous inquiétez pas si vous n’avez pas encore vu Future
ou Callable
; nous les discutons en détail dans la section suivante.
En pratique, l’utilisation de la méthode submit()
est assez similaire à l’utilisation de la méthode execute()
, sauf que la méthode submit()
renvoie une instance Future
qui peut être utilisée pour déterminer si la tâche a terminé son exécution.
Nom de la méthode | Description |
---|---|
void execute(Runnable command) | Exécute la tâche Runnable à un moment dans le futur. |
Future<?> submit(Runnable task) | Exécute la tâche Runnable à un moment dans le futur et renvoie un Future représentant la tâche. |
<T> Future<T> submit(Callable<T> task) | Exécute la tâche Callable à un moment dans le futur et renvoie un Future représentant les résultats en attente de la tâche. |
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) | Exécute les tâches données et attend que toutes les tâches soient terminées. Renvoie une List d’instances Future dans le même ordre que dans la collection originale. |
<T> T invokeAny(Collection<? extends Callable<T>> tasks) | Exécute les tâches données et attend qu’au moins une soit terminée. |
Soumission de Tâches: execute() vs. submit()
Comme vous l’avez peut-être remarqué, les méthodes execute()
et submit()
sont presque identiques lorsqu’elles sont appliquées aux expressions Runnable
. La méthode submit()
a l’avantage évident de faire la même chose que execute()
, mais avec un objet de retour qui peut être utilisé pour suivre le résultat. En raison de cet avantage et du fait que execute()
ne prend pas en charge les expressions Callable
, nous préférons généralement submit()
à execute()
, même si nous ne stockons pas la référence Future
.
Nous vous recommandons submit()
plutôt que execute()
dans votre propre code, chaque fois que possible.
Attente de Résultats
Comment savons-nous quand une tâche soumise à un ExecutorService
est terminée ? Comme mentionné dans la section précédente, la méthode submit()
renvoie une instance Future<V>
qui peut être utilisée pour déterminer ce résultat.
Future<?> future = service.submit(() -> System.out.println("Bonjour"));
Le type Future
est en fait une interface. Pour l’examen, vous n’avez pas besoin de connaître les classes qui implémentent Future
, juste qu’une instance Future
est renvoyée par diverses méthodes d’API. Le Tableau 13.2 inclut des méthodes utiles pour déterminer l’état d’une tâche.
Nom de la méthode | Description |
---|---|
boolean isDone() | Renvoie true si la tâche a été terminée, a lancé une exception ou a été annulée. |
boolean isCancelled() | Renvoie true si la tâche a été annulée avant qu’elle ne soit normalement terminée. |
boolean cancel(boolean mayInterruptIfRunning) | Tente d’annuler l’exécution de la tâche et renvoie true si elle a été annulée avec succès ou false si elle n’a pas pu être annulée ou est déjà terminée. |
V get() | Récupère le résultat de la tâche, en attendant indéfiniment s’il n’est pas encore disponible. |
V get(long timeout, TimeUnit unit) | Récupère le résultat de la tâche, en attendant le temps spécifié. Si le résultat n’est pas prêt au moment où le délai d’attente est atteint, une TimeoutException vérifiée sera lancée. |
Voici une version mise à jour de notre exemple de polling précédent, la classe VerifierResultats
, qui utilise une instance Future
pour attendre les résultats :
import java.util.concurrent.*;
public class VerifierResultats {
private static int compteur = 0;
public static void main(String[] args) throws Exception {
ExecutorService service = Executors.newSingleThreadExecutor();
try {
Future<?> resultat = service.submit(() -> {
for(int i = 0; i < 1_000_000; i++) compteur++;
});
resultat.get(10, TimeUnit.SECONDS); // Renvoie null pour Runnable
System.out.println("Atteint !");
} catch (TimeoutException e) {
System.out.println("Pas atteint à temps");
} finally {
service.shutdown();
}
}
}
Cet exemple est similaire à notre implémentation de polling précédente, mais il n’utilise pas directement la classe Thread
. En partie, c’est l’essence de l’API de Concurrence : faire des choses complexes avec des threads sans avoir à gérer directement les threads. Il attend également au maximum 10 secondes, lançant une TimeoutException
lors de l’appel à resultat.get()
si la tâche n’est pas terminée.
Quelle est la valeur de retour de cette tâche ? Comme Future<V>
est une interface générique, le type V est déterminé par le type de retour de la méthode Runnable
. Puisque le type de retour de Runnable.run()
est void
, la méthode get()
renvoie toujours null
lorsqu’elle travaille avec des expressions Runnable
.
La méthode Future.get()
peut prendre une valeur optionnelle et un type enum java.util.concurrent.TimeUnit
. Le Tableau 13.3 présente la liste complète des valeurs TimeUnit
puisque de nombreuses méthodes dans l’API de Concurrence utilisent cet enum.
Nom de l’enum | Description |
---|---|
TimeUnit.NANOSECONDS | Temps en milliardièmes de seconde (1/1 000 000 000) |
TimeUnit.MICROSECONDS | Temps en millionièmes de seconde (1/1 000 000) |
TimeUnit.MILLISECONDS | Temps en millièmes de seconde (1/1 000) |
TimeUnit.SECONDS | Temps en secondes |
TimeUnit.MINUTES | Temps en minutes |
TimeUnit.HOURS | Temps en heures |
TimeUnit.DAYS | Temps en jours |
Introduction à Callable
L’interface fonctionnelle java.util.concurrent.Callable
est similaire à Runnable
sauf que sa méthode call()
renvoie une valeur et peut lancer une exception vérifiée. Voici la définition de l’interface Callable
:
@FunctionalInterface public interface Callable<V> {
V call() throws Exception;
}
L’interface Callable
est souvent préférable à Runnable
, car elle permet de récupérer plus facilement des détails de la tâche après son achèvement. Cela dit, nous utilisons les deux interfaces tout au long de ce chapitre, car elles sont interchangeables dans les situations où le lambda ne lance pas d’exception et qu’il n’y a pas de type de retour. Heureusement, le ExecutorService
inclut une version surchargée de la méthode submit()
qui prend un objet Callable
et renvoie une instance générique Future<T>
.
Contrairement à Runnable
, où les méthodes get()
retournent toujours null
, les méthodes get()
d’une instance Future
retournent le type générique correspondant (qui pourrait aussi être une valeur null
).
Prenons un exemple utilisant Callable
:
var service = Executors.newSingleThreadExecutor();
try {
Future<Integer> resultat = service.submit(() -> 30 + 11);
System.out.println(resultat.get()); // 41
} finally {
service.shutdown();
}
Nous pourrions réécrire cet exemple en utilisant Runnable
, un objet partagé et une interruption ou une attente chronométrée, mais cette implémentation est beaucoup plus facile à coder et à comprendre. En essence, c’est l’esprit de l’API de Concurrence, vous donnant les outils pour écrire du code multithread qui est thread-safe, performant, et facile à suivre.
Attendre que Toutes les Tâches se Terminent
Après avoir soumis un ensemble de tâches à un exécuteur de thread, il est courant d’attendre les résultats. Comme vous l’avez vu dans les sections précédentes, une solution consiste à appeler get()
sur chaque objet Future
renvoyé par la méthode submit()
. Si nous n’avons pas besoin des résultats des tâches et que nous avons fini d’utiliser notre exécuteur de thread, il existe une approche plus simple.
D’abord, nous arrêtons l’exécuteur de thread en utilisant la méthode shutdown()
. Ensuite, nous utilisons la méthode awaitTermination()
disponible pour tous les exécuteurs de thread. La méthode attend le temps spécifié pour que toutes les tâches soient terminées, en retournant plus tôt si toutes les tâches sont terminées ou si une InterruptedException
est détectée. Vous pouvez voir un exemple de cela dans l’extrait de code suivant :
ExecutorService service = Executors.newSingleThreadExecutor();
try {
// Ajouter des tâches à l'exécuteur de thread
…
} finally {
service.shutdown();
}
service.awaitTermination(1, TimeUnit.MINUTES);
// Vérifier si toutes les tâches sont terminées
if(service.isTerminated()) System.out.println("Terminé !");
else System.out.println("Au moins une tâche est encore en cours d'exécution");
Dans cet exemple, nous soumettons un certain nombre de tâches à l’exécuteur de thread, puis nous arrêtons l’exécuteur de thread et attendons jusqu’à une minute pour les résultats. Notez que nous pouvons appeler isTerminated()
après que la méthode awaitTermination()
se termine pour confirmer que toutes les tâches sont terminées.
Planification de Tâches
Souvent en Java, nous devons planifier une tâche pour qu’elle se produise à un moment futur. Nous pourrions même avoir besoin de planifier la tâche pour qu’elle se répète périodiquement, à un intervalle défini. Par exemple, imaginez que nous voulions vérifier l’approvisionnement en nourriture pour les animaux du zoo une fois par heure et remplir au besoin. ScheduledExecutorService
, qui est une sous-interface de ExecutorService
, peut être utilisée pour une telle tâche.
Comme ExecutorService
, nous obtenons une instance de ScheduledExecutorService
en utilisant une méthode factory dans la classe Executors
, comme le montre l’extrait suivant :
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
Nous pourrions stocker une instance de ScheduledExecutorService
dans une variable ExecutorService
, bien que cela signifierait que nous devrions caster l’objet pour appeler des méthodes de planification.
Référez-vous au Tableau 13.4 pour notre résumé des méthodes ScheduledExecutorService
. Chacune de ces méthodes renvoie un objet ScheduledFuture
.
Nom de la méthode | Description |
---|---|
schedule(Callable<V> callable, long delay, TimeUnit unit) | Crée et exécute une tâche Callable après le délai donné |
schedule(Runnable command, long delay, TimeUnit unit) | Crée et exécute une tâche Runnable après le délai donné |
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) | Crée et exécute une tâche Runnable après le délai initial donné, en créant une nouvelle tâche chaque période qui passe |
scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) | Crée et exécute une tâche Runnable après le délai initial donné et ensuite avec le délai donné entre la fin d’une exécution et le début de la suivante |
En pratique, ces méthodes sont parmi les plus pratiques de l’API de Concurrence, car elles effectuent des tâches relativement complexes avec une seule ligne de code. Les paramètres de délai et de période s’appuient sur l’argument TimeUnit
pour déterminer le format de la valeur, comme les secondes ou les millisecondes.
Les deux premières méthodes schedule()
du Tableau 13.4 prennent un Callable
ou un Runnable
, respectivement ; exécutent la tâche après un certain délai ; et renvoient une instance ScheduledFuture
. L’interface ScheduledFuture
est identique à l’interface Future
, sauf qu’elle inclut une méthode getDelay()
qui renvoie le délai restant. Voici comment utiliser la méthode schedule
avec des tâches Callable
et Runnable
:
ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();
Runnable tache1 = () -> System.out.println("Bonjour Zoo");
Callable<String> tache2 = () -> "Singe";
ScheduledFuture<?> r1 = service.schedule(tache1, 10, TimeUnit.SECONDS);
ScheduledFuture<?> r2 = service.schedule(tache2, 8, TimeUnit.MINUTES);
La première tâche est programmée 10 secondes dans le futur, tandis que la seconde tâche est programmée 8 minutes dans le futur.
Bien que ces tâches soient planifiées dans le futur, l’exécution réelle peut être retardée. Par exemple, il peut n’y avoir aucun thread disponible pour effectuer les tâches, auquel cas elles attendront simplement dans la file d’attente. De plus, si le ScheduledExecutorService
est arrêté au moment où l’heure d’exécution de la tâche planifiée est atteinte, ces tâches seront alors supprimées.
Chacune des méthodes ScheduledExecutorService
est importante et a des applications réelles. Par exemple, vous pouvez utiliser la commande schedule()
pour vérifier l’état du nettoyage de la cage d’un lion. Elle peut ensuite envoyer des notifications si ce n’est pas terminé ou même appeler schedule()
pour vérifier à nouveau plus tard.
Les deux dernières méthodes du Tableau 13.4 peuvent être un peu déroutantes si vous ne les avez jamais vues auparavant. Conceptuellement, elles sont similaires car elles effectuent toutes deux la même tâche de manière répétée, après un délai initial. La différence est liée au timing du processus et au moment où la prochaine tâche commence.
La méthode scheduleAtFixedRate()
crée une nouvelle tâche et la soumet à l’exécuteur chaque période, indépendamment du fait que la tâche précédente soit terminée ou non. L’exemple suivant exécute une tâche Runnable
chaque minute, après un délai initial de cinq minutes :
service.scheduleAtFixedRate(commande, 5, 1, TimeUnit.MINUTES);
La méthode scheduleAtFixedRate()
est utile pour les tâches qui doivent être exécutées à des intervalles spécifiques, comme vérifier la santé des animaux une fois par jour. Même s’il faut deux heures pour examiner un animal le lundi, cela ne signifie pas que l’examen du mardi devrait commencer plus tard dans la journée.
De mauvaises choses peuvent se produire avec scheduleAtFixedRate()
si chaque tâche prend systématiquement plus de temps à s’exécuter que l’intervalle d’exécution. Imaginez que votre patron passe à votre bureau chaque minute et dépose un morceau de papier. Maintenant, imaginez qu’il vous faut cinq minutes pour lire chaque morceau de papier. Très vite, vous seriez submergé par des piles de papier. C’est ce que ressent un exécuteur. Avec suffisamment de temps, le programme soumettrait plus de tâches au service d’exécution que ce qui pourrait tenir en mémoire, provoquant le plantage du programme.
D’autre part, la méthode scheduleWithFixedDelay()
crée une nouvelle tâche seulement après que la tâche précédente soit terminée. Par exemple, si une tâche s’exécute à 12h00 et prend cinq minutes à terminer, avec une période entre les exécutions de deux minutes, la prochaine tâche commencera à 12h07.
service.scheduleWithFixedDelay(tache1, 0, 2, TimeUnit.MINUTES);
La méthode scheduleWithFixedDelay()
est utile pour des processus que vous voulez voir se produire de manière répétée mais dont le moment précis importe peu. Par exemple, imaginez que nous avons un travailleur de la cafétéria du zoo qui réapprovisionne périodiquement le bar à salade tout au long de la journée. Le processus peut prendre 20 minutes ou plus, car il nécessite que le travailleur transporte un grand nombre d’articles depuis l’arrière-salle. Une fois que le travailleur a rempli le bar à salade avec de la nourriture fraîche, il n’a pas besoin de vérifier à une heure précise, juste après un temps suffisant pour que le stock devienne bas à nouveau.
Augmenter la Concurrence avec les Pools
Tous nos exemples jusqu’à présent ont été avec un exécuteur mono-thread, qui, bien qu’intéressant, n’était pas particulièrement utile. Après tout, le nom de ce chapitre est “Concurrence”, et vous ne pouvez pas en faire beaucoup avec un exécuteur mono-thread !
Nous présentons maintenant trois méthodes de fabrique supplémentaires dans la classe Executors
qui agissent sur un pool de threads plutôt que sur un seul thread. Un pool de threads est un groupe de threads préinstanciés réutilisables qui sont disponibles pour effectuer un ensemble de tâches arbitraires. Le Tableau 13.5 inclut nos deux méthodes d’exécuteur mono-thread précédentes, ainsi que les nouvelles que vous devriez connaître.
Nom de la méthode | Description |
---|---|
ExecutorService newSingleThreadExecutor() | Crée un exécuteur mono-thread qui utilise un seul thread de travail opérant sur une file d’attente non bornée. Les résultats sont traités séquentiellement dans l’ordre dans lequel ils sont soumis. |
ScheduledExecutorService newSingleThreadScheduledExecutor() | Crée un exécuteur mono-thread qui peut planifier des commandes à exécuter après un délai donné ou à exécuter périodiquement. |
ExecutorService newCachedThreadPool() | Crée un pool de threads qui crée de nouveaux threads selon les besoins mais réutilise les threads précédemment construits lorsqu’ils sont disponibles. |
ExecutorService newFixedThreadPool(int) | Crée un pool de threads qui réutilise un nombre fixe de threads opérant sur une file d’attente partagée non bornée. |
ScheduledExecutorService newScheduledThreadPool(int) | Crée un pool de threads qui peut planifier des commandes à exécuter après un délai donné ou à exécuter périodiquement. |
Comme indiqué dans le Tableau 13.5, ces méthodes renvoient les mêmes types d’instances, ExecutorService
et ScheduledExecutorService
, que nous avons utilisés plus tôt dans ce chapitre. En d’autres termes, tous nos exemples précédents sont compatibles avec ces nouveaux exécuteurs de pool de threads !
La différence entre un exécuteur mono-thread et un exécuteur de pool de threads est ce qui se passe lorsqu’une tâche est déjà en cours d’exécution. Alors qu’un exécuteur mono-thread attendra que le thread devienne disponible avant d’exécuter la tâche suivante, un exécuteur de pool de threads peut exécuter la tâche suivante simultanément. Si le pool n’a plus de threads disponibles, la tâche sera mise en file d’attente par l’exécuteur de thread et attendra d’être terminée.