Extrait n° 3

publicité
=Java FM.book Page 117 Mardi, 7. avril 2009 7:11 07
6
Exécution des tâches
La plupart des applications concurrentes sont organisées autour de l’exécution de tâches,
que l’on peut considérer comme des unités de travail abstraites. Diviser une application
en plusieurs tâches simplifie l’organisation du programme et facilite la découverte des
erreurs grâce aux frontières naturelles séparant les différentes transactions. Cette division
encourage également la concurrence en fournissant une structure naturelle permettant
de paralléliser le travail.
6.1
Exécution des tâches dans les threads
La première étape pour organiser un programme autour de l’exécution de tâches consiste
à identifier les frontières entre ces tâches. Dans l’idéal, les tâches sont des activités
indépendantes, c’est-à-dire des opérations qui ne dépendent ni de l’état, ni du résultat,
ni des effets de bord des autres tâches. Cette indépendance facilite la concurrence puisque
les tâches indépendantes peuvent s’exécuter en parallèle si l’on dispose des ressources
de traitement adéquates. Pour disposer de plus de souplesse dans l’ordonnancement et
la répartition de la charge entre ces tâches, chacune devrait également représenter une
petite fraction des possibilités du traitement de l’application.
Les applications serveur doivent fournir un bon débit de données et une réactivité correcte
sous une charge normale. Les fournisseurs d’applications veulent des programmes permettant de supporter autant d’utilisateurs que possible afin de réduire d’autant les coûts par
utilisateur ; ces utilisateurs veulent évidemment obtenir rapidement les réponses qu’ils
demandent. En outre, les applications doivent non pas se dégrader brutalement lorsqu’elles
sont surchargées mais réagir le mieux possible. Tous ces objectifs peuvent être atteints en
choisissant de bonnes frontières entre les tâches et en utilisant une politique raisonnable
d’exécution des tâches (voir la section 6.2.2).
La plupart des applications serveur offrent un choix naturel pour les frontières entre
tâches : les différentes requêtes des clients. Les serveurs web, de courrier, de fichiers,
=Java FM.book Page 118 Mardi, 7. avril 2009 7:11 07
118
Structuration des applications concurrentes
Partie II
les conteneurs EJB et les serveurs de bases de données reçoivent tous des requêtes de
clients distants via des connexions réseau. Utiliser ces différentes requêtes comme des
frontières de tâches permet généralement d’obtenir à la fois des tâches indépendantes et
de taille appropriée. Le résultat de la soumission d’un message à un serveur de courrier,
par exemple, n’est pas affecté par les autres messages qui sont traités en même temps,
et la prise en charge d’un simple message ne nécessite généralement qu’un très petit
pourcentage de la capacité totale du serveur.
6.1.1
Exécution séquentielle des tâches
Il existe un certain nombre de politiques possibles pour ordonnancer les tâches au sein
d’une application ; parmi elles, certaines exploitent mieux la concurrence que d’autres.
La plus simple consiste à exécuter les tâches séquentiellement dans un seul thread. La
classe SingleThreadWebServer du Listing 6.1 traite ses tâches – des requêtes HTTP
arrivant sur le port 80 – en séquence. Les détails du traitement de la requête ne sont pas
importants ; nous ne nous intéressons ici qu’à la concurrence des différentes politiques
d’ordonnancement.
Listing 6.1 : Serveur web séquentiel.
class SingleThreadWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
SingleThreadedWebServer est simple et correcte d’un point de vue théorique, mais
serait très inefficace en production car elle ne peut gérer qu’une seule requête à la fois.
Le thread principal alterne constamment entre accepter des connexions et traiter la
requête associée : pendant que le serveur traite une requête, les nouvelles connexions
doivent attendre qu’il ait fini ce traitement et qu’il appelle à nouveau accept(). Cela
peut fonctionner si le traitement des requêtes est suffisamment rapide pour que handle
Request() se termine immédiatement, mais cette hypothèse ne reflète pas du tout la
situation des serveurs web actuels.
Traiter une requête web implique un mélange de calculs et d’opérations d’E/S. Le
serveur doit lire dans un socket pour obtenir la requête et y écrire pour envoyer la
réponse ; ces opérations peuvent être bloquantes en cas de congestion du réseau ou de
problèmes de connexion. Il peut également effectuer des E/S sur des fichiers ou lancer
des requêtes de bases de données, qui peuvent elles aussi être bloquantes. Avec un
serveur monothread, un blocage ne fait pas que retarder le traitement de la requête en
cours : il empêche également celui des requêtes en attente. Si une requête se bloque
pendant un temps très long, les utilisateurs penseront que le serveur n’est plus disponible
=Java FM.book Page 119 Mardi, 7. avril 2009 7:11 07
Chapitre 6
Exécution des tâches
119
puisqu’il ne semble plus répondre. En outre, les ressources sont mal utilisées puisque le
processeur reste inactif pendant que l’unique thread attend que ses E/S se terminent.
Pour les applications serveur, le traitement séquentiel fournit rarement un bon débit ou
une réactivité correcte. Il existe des exceptions – lorsqu’il y a très peu de tâches et qu’elles
durent longtemps, ou quand le serveur ne sert qu’un seul client qui n’envoie qu’une
seule requête à la fois – mais la plupart des applications serveur ne fonctionnent pas de
cette façon1.
6.1.2
Création explicite de threads pour les tâches
Une approche plus réactive consiste à créer un nouveau thread pour répondre à chaque
nouvelle requête, comme dans le Listing 6.2.
Listing 6.2 : Serveur web lançant un thread par requête.
class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
new Thread(task).start();
}
}
}
La structure de la classe ThreadPerTaskWebServer ressemble à celle de la version
monothread – le thread principal continue d’alterner entre la réception d’une connexion
entrante et le traitement de la requête. Mais, ici, la boucle principale crée un nouveau
thread pour chaque connexion afin de traiter la requête au lieu de le faire dans le thread
principal. Ceci a trois conséquences importantes :
m
Le thread principal se décharge du traitement de la tâche, ce qui permet à la boucle
principale de poursuivre et de venir attendre plus rapidement la connexion entrante
suivante. Ceci autorise de nouvelles connexions alors que les requêtes sont en cours
de traitement, ce qui améliore les temps de réponse.
m
Les tâches peuvent être traitées en parallèle, ce qui permet de traiter simultanément
plusieurs requêtes. Ceci améliore le débit lorsqu’il y a plusieurs processeurs ou si
des tâches doivent se bloquer en attente d’une opération d’E/S, de l’acquisition d’un
verrou ou de la disponibilité d’une ressource, par exemple.
1. Dans certaines situations, le traitement séquentiel offre des avantages en termes de simplicité et de
sécurité ; la plupart des interfaces graphiques traitent séquentiellement les tâches dans un seul thread.
Nous reviendrons sur le modèle séquentiel au Chapitre 9.
=Java FM.book Page 120 Mardi, 7. avril 2009 7:11 07
120
m
Structuration des applications concurrentes
Partie II
Le code du traitement de la tâche doit être thread-safe car il peut être invoqué de
façon concurrente par plusieurs tâches.
En cas de charge modérée, cette approche est plus efficace qu’une exécution séquentielle.
Tant que la fréquence d’arrivée des requêtes ne dépasse pas les capacités de traitement
du serveur, elle offre une meilleure réactivité et un débit supérieur.
6.1.3
Inconvénients d’une création illimitée de threads
Cependant, dans un environnement de production, l’approche "un thread par tâche" a quelques inconvénients pratiques, notamment lorsqu’elle peut produire un grand nombre de
threads :
m
Surcoût dû au cycle de vie des threads. La création d’un thread et sa suppression
ne sont pas gratuites. Bien que le surcoût dépende des plates-formes, la création d’un
thread prend du temps, induit une certaine latence dans le traitement de la requête et
nécessite un traitement de la part de la JVM et du système d’exploitation. Si les
requêtes sont fréquentes et légères, comme dans la plupart des applications serveur,
la création d’un thread par requête peut consommer un nombre non négligeable de
ressources.
m
Consommation des ressources. Les threads actifs consomment des ressources du
système, notamment la mémoire. S’il y a plus de threads en cours d’exécution qu’il
n’y a de processeurs disponibles, certains threads resteront inactifs, ce qui peut
consommer beaucoup de mémoire et surcharger le ramasse-miettes. En outre, le fait
que de nombreux threads concourent pour l’accès aux processeurs peut également
avoir des répercussions sur les performances. Si vous avez suffisamment de threads
pour garder tous les processeurs occupés, en créer plus n’améliorera rien, voire
dégradera les performances.
m
Stabilité. Il y a une limite sur le nombre de threads qui peuvent être créés. Cette
limite varie en fonction des plates-formes, des paramètres d’appels de la JVM, de la
taille de pile demandée dans le constructeur de Thread et des limites imposées aux
threads par le système d’exploitation1. Lorsque vous atteignerez cette limite, vous
obtiendrez très probablement une exception OutOfMemoryError. Tenter de se rétablir
de cette erreur est très risqué ; il est bien plus simple de structurer votre programme
afin d’éviter d’atteindre cette limite.
1. Sur des machines 32 bits, un facteur limitant important est l’espace d’adressage pour les piles des
threads. Chaque thread gère deux piles d’exécution : une pour le code Java, l’autre pour le code natif.
Généralement, la JVM produit par défaut une taille de pile combinée d’environ 512 kilo-octets (vous
pouvez changer cette valeur avec le paramètre -Xss de la JVM ou lors de l’appel du constructeur de
Thread). Si vous divisez les 232 adresses par la taille de la pile de chaque thread, vous obtenez une
limite de quelques milliers ou dizaines de milliers de threads. D’autres facteurs, comme les limites du
système d’exploitation, peuvent imposer des limites plus contraignantes.
=Java FM.book Page 121 Mardi, 7. avril 2009 7:11 07
Chapitre 6
Exécution des tâches
121
Jusqu’à un certain point, ajouter plus de threads permet d’améliorer le débit mais, audelà de ce point, créer des threads supplémentaires ne fera que ralentir, et en créer un de
trop peut totalement empêcher l’application de fonctionner. Le meilleur moyen de se
protéger de ce danger consiste à fixer une limite au nombre de threads qu’un programme
peut créer et à tester sérieusement l’application pour vérifier qu’elle ne tombera pas à
court de ressources, même lorsque cette limite sera atteinte.
Le problème de l’approche "un thread par tâche" est que la seule limite imposée au nombre
de threads créés est la fréquence à laquelle les clients distants peuvent lancer des requêtes
HTTP. Comme tous les autres problèmes liés à la concurrence, la création infinie de
threads peut sembler fonctionner parfaitement au cours des phases de prototypage et
de développement, ce qui n’empêchera pas le problème d’apparaître lorsque l’application
sera déployée en production et soumise à une forte charge.
Un utilisateur pervers, voire des utilisateurs ordinaires, peut donc faire planter votre
serveur web en le soumettant à une charge trop forte. Pour une application serveur
supposée fournir une haute disponibilité et se dégrader correctement en cas de charge
importante, il s’agit d’un sérieux défaut.
6.2
Le framework Executor
Les tâches sont des unités logiques de travail et les threads sont un mécanisme grâce
auquel ces tâches peuvent s’exécuter de façon asynchrone. Nous avons étudié deux
politiques d’exécution des tâches à l’aide des threads – l’exécution séquentielle des tâches
dans un seul thread et l’exécution de chaque tâche dans son propre thread. Toutes les
deux ont de sévères limitations : l’approche séquentielle implique une mauvaise réactivité
et un faible débit et l’approche "une tâche par thread" souffre d’une mauvaise gestion des
ressources.
Au Chapitre 5, nous avons vu comment utiliser des files de taille fixe pour empêcher
qu’une application surchargée ne soit à court de mémoire. Un pool de threads offrant
les mêmes avantages pour la gestion des threads, java.util.concurrent en fournit une
implémentation dans le cadre du framework Executor. Comme le montre le Listing 6.3,
la principale abstraction de l’exécution des tâches dans la bibliothèque des classes Java
est non pas Thread mais Executor.
Listing 6.3 : Interface Executor.
public interface Executor {
void execute(Runnable command);
}
Executor est peut-être une interface simple, mais elle forme les fondements d’un
framework souple et puissant pour l’exécution asynchrone des tâches sous un grand
nombre de politiques d’exécution des tâches. Elle fournit un moyen standard pour
=Java FM.book Page 122 Mardi, 7. avril 2009 7:11 07
122
Structuration des applications concurrentes
Partie II
découpler la soumission des tâches de leur exécution en les décrivant comme des objets
Runnable. Les implémentations de Executor fournissent également un contrôle du
cycle de vie et des points d’ancrage permettant d’ajouter une collecte des statistiques,
ainsi qu’une gestion et une surveillance des applications.
Executor repose sur le patron producteur-consommateur, où les activités qui soumettent
des tâches sont les producteurs (qui produisent le travail à faire) et les threads qui
exécutent ces tâches sont les consommateurs (qui consomment ce travail). L’utilisation
d’un Executor est, généralement, le moyen le plus simple d’implémenter une conception
de type producteur-consommateur dans une application.
6.2.1
Exemple : serveur web utilisant Executor
La création d’un serveur web à partir d’un Executor est très simple. La classe Task
ExecutionWebServer du Listing 6.4 remplace la création des threads, qui était codée en
dur dans la version précédente, par un Executor. Ici, nous utilisons l’une de ses implémentations standard, un pool de threads de taille fixe, avec 100 threads.
Dans TaskExecutionWebServer, la soumission de la tâche de gestion d’une requête est
séparée de son exécution grâce à un Executor et son comportement peut être modifié en
utilisant simplement une autre implémentation de Executor. Le changement d’implémentation ou de configuration d’Executor est une opération bien moins lourde que
modifier la façon dont les tâches sont soumises ; généralement, la configuration est un
événement unique qui peut aisément être présenté lors du déploiement de l’application,
alors que le code de soumission des tâches a tendance à être disséminé un peu partout
dans le programme et à être plus difficile à mettre en évidence.
Listing 6.4 : Serveur web utilisant un pool de threads.
class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec
= Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
TaskExecutionWebServer peut être facilement modifié pour qu’il se comporte comme
ThreadPerTaskWebServer : comme le montre le Listing 6.5, il suffit d’utiliser un
Executor qui crée un nouveau thread pour chaque requête, ce qui est très simple.
=Java FM.book Page 123 Mardi, 7. avril 2009 7:11 07
Chapitre 6
Exécution des tâches
123
Listing 6.5 : Executor lançant un nouveau thread pour chaque tâche.
public class ThreadPerTaskExecutor implements Executor {
public void execute(Runnable r) {
new Thread(r).start();
};
}
De même, il est tout aussi simple d’écrire un Executor pour que TaskExecutionWeb
Server se comporte comme la version monothread, en exécutant chaque tâche de façon
synchrone dans execute(), comme le montre la classe WithinThreadExecutor du
Listing 6.6.
Listing 6.6 : Executor exécutant les tâches de façon synchrone dans le thread appelant.
public class WithinThreadExecutor implements Executor {
public void execute(Runnable r) {
r.run();
};
}
6.2.2
Politiques d’exécution
L’intérêt de séparer la soumission de l’exécution est que cela permet de spécifier facilement, et donc de modifier simplement, la politique d’exécution d’une classe de tâches.
Une politique d’exécution répond aux questions "quoi, où, quand et comment" concernant
l’exécution des tâches :
m
Dans quel thread les tâches s’exécuteront-elles ?
m
Dans quel ordre les tâches devront-elles s’exécuter (FIFO, LIFO, selon leurs priorités) ?
m
Combien de tâches peuvent s’exécuter simultanément ?
m
Combien de tâches peuvent être mises en attente d’exécution ?
m
Si une tâche doit être rejetée parce que le système est surchargé, quelle sera la
victime et comment l’application sera-t-elle prévenue ?
m
Quelles actions faut-il faire avant ou après l’exécution d’une tâche ?
Les politiques d’exécution sont un outil de gestion des ressources et la politique optimale dépend des ressources de calcul disponibles et de la qualité du service recherchée.
En limitant le nombre de tâches simultanées, vous pouvez garantir que l’application
n’échouera pas si les ressources sont épuisées et que ses performances ne souffriront
pas de problèmes dus à la concurrence pour des ressources en quantités limitées 1.
1. Ceci est analogue à l’un des rôles d’un moniteur de transactions dans une application d’entreprise ;
il peut contrôler la fréquence à laquelle les transactions peuvent être traitées, afin de ne pas épuiser des
ressources limitées.
=Java FM.book Page 124 Mardi, 7. avril 2009 7:11 07
124
Structuration des applications concurrentes
Partie II
Séparer la spécification de la politique d’exécution de la soumission des tâches permet
de choisir une politique d’exécution lors du déploiement adaptée au matériel disponible.
À chaque fois que vous voyez un code de la forme :
new Thread(runnable).start()
et que vous pensez avoir besoin d’une politique d’exécution plus souple, réfléchissez
sérieusement à son remplacement par l’utilisation d’un Executor.
6.2.3
Pools de threads
Un pool de threads gère un ensemble homogène de threads travailleurs. Il est étroitement
lié à une file contenant les tâches en attente d’exécution. La vie des threads travailleurs
est simple : demander la tâche suivante dans la file, l’exécuter et revenir attendre une
autre tâche.
L’exécution des tâches avec un pool de threads présente un certain nombre d’avantages
par rapport à l’approche "un thread par tâche". La réutilisation d’un thread existant au
lieu d’en créer un nouveau amortit les coûts de création et de suppression des threads en
les répartissant sur plusieurs requêtes. En outre, le thread travailleur existant souvent
déjà lorsque la requête arrive, le temps de latence associé à la création du thread ne
retarde pas l’exécution de la tâche, d’où une réactivité accrue. En choisissant soigneusement la taille du pool, vous pouvez avoir suffisamment de threads pour occuper les
processeurs tout en évitant que l’application soit à court de mémoire ou se plante à
cause d’une trop forte compétition pour les ressources.
La bibliothèque standard fournit une implémentation flexible des pools de threads, ainsi
que quelques configurations prédéfinies assez utiles. Pour créer un pool, vous pouvez
appeler l’une des méthodes fabriques statiques de la classe Executors :
m
newFixedThreadPool(). Crée un pool de taille fixe qui crée les threads à mesure
que les tâches sont soumises jusqu’à atteindre la taille maximale du pool, puis qui
tente de garder constante la taille de ce pool (en créant un nouveau thread lorsqu’un
thread meurt à cause d’une Exception inattendue).
m
newCachedThreadPool(). Crée un pool de threads en cache, ce qui donne plus de
souplesse pour supprimer les threads inactifs lorsque la taille courante du pool dépasse
la demande de traitement et pour ajouter de nouveaux threads lorsque cette demande
augmente, tout en ne fixant pas de limite à la taille du pool.
m
newSingleThreadExecutor(). Crée une instance Executor monothread qui ne produit
qu’un seul thread travailleur pour traiter les tâches, en le remplaçant s’il meurt
=Java FM.book Page 125 Mardi, 7. avril 2009 7:11 07
Chapitre 6
Exécution des tâches
125
accidentellement. Les tâches sont traitées séquentiellement selon l’ordre imposé par
la file d’attente des tâches (FIFO, LIFO, ordre des priorités)1.
m
newScheduledThreadPool(). Crée un pool de threads de taille fixe, permettant de
différer ou de répéter l’exécution des tâches, comme Timer (voir la section 6.2.5).
Les fabriques newFixedThreadPool() et newCachedThreadPool() renvoient des instances
de la classe générale ThreadPoolExecutor qui peuvent également servir directement à
construire des "exécuteurs" plus spécialisés. Nous présenterons plus en détail les
options de configuration des pools de threads au Chapitre 8.
Le serveur web de TaskExecutionWebServer utilise un Executor avec un pool limité de
threads travailleurs. Soumettre une tâche avec execute() l’ajoute à la file d’attente dans
laquelle les threads viennent sans cesse chercher des tâches pour les exécuter.
Passer d’une politique "un thread par tâche" à une politique utilisant un pool a un effet
non négligeable sur la stabilité de l’application : le serveur web ne souffrira plus lorsqu’il
sera soumis à une charge importante2. En outre, son comportement se dégradera moins
violemment puisqu’il ne crée pas des milliers de threads qui combattent pour des
ressources processeur et mémoire limitées. Enfin, l’utilisation d’un Executor ouvre la
porte à toutes sortes d’opportunités de configuration, de gestion, de surveillance, de
journalisation, de suivi des erreurs et autres possibilités qui sont bien plus difficiles à
ajouter sans un framework d’exécution des tâches.
6.2.4
Cycle de vie d’un Executor
Nous avons vu comment créer un Executor mais pas comment l’arrêter. Une implémentation de Executor crée des threads pour traiter des tâches mais, la JVM ne pouvant
pas se terminer tant que tous les threads (non démons) ne se sont pas terminés, ne pas
arrêter un Executor empêche l’arrêt de la JVM.
Un Executor traitant les tâches de façon asynchrone, l’état à un instant donné des
tâches soumises n’est pas évident. Certaines se sont peut-être terminées, certaines
peuvent être en cours d’exécution et d’autres peuvent être en attente d’exécution. Pour
arrêter une application, il y a une marge entre un arrêt en douceur (finir ce qui a été
lancé et ne pas accepter de nouveau travail) et un arrêt brutal (éteindre la machine), avec
1. Les instance Executor monothreads fournissent également une synchronisation interne suffisante
pour garantir que toute écriture en mémoire par les tâches sera visible par les tâches suivantes ; ceci
signifie que les objets peuvent être confinés en toute sécurité au "thread tâche", même si ce thread est
remplacé épisodiquement par un autre.
2. Même s’il ne souffrira plus à cause de la création d’un nombre excessif de threads, il peut quand
même (bien que ce soit plus difficile) arriver à court de mémoire si la fréquence d’arrivée des tâches
est supérieure à celle de leur traitement pendant une période suffisamment longue, à cause de
l’augmentation de la taille de la file des Runnable en attente d’exécution. Avec le framework Executor,
ce problème peut se résoudre en utilisant une file d’attente de taille fixe – voir la section 8.3.2.
=Java FM.book Page 126 Mardi, 7. avril 2009 7:11 07
126
Structuration des applications concurrentes
Partie II
plusieurs degrés entre les deux. Les Executor fournissant un service aux applications,
ils devraient également pouvoir être arrêtés, en douceur et brutalement, et renvoyer des
informations à l’application sur l’état des tâches affectées par cet arrêt.
Pour résoudre le problème du cycle de vie du service d’exécution, l’interface Executor
Service étend Executor en lui ajoutant un certain nombre de méthodes dédiées à la
gestion du cycle de vie, présentées dans le Listing 6.7 (elle ajoute également certaines
méthodes utilitaires pour la soumission des tâches).
Listing 6.7 : Méthodes de ExecutorService pour le cycle de vie.
public interface ExecutorService extends Executor {
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTermination (long timeout, TimeUnit unit)
throws InterruptedException ;
// ... méthodes utilitaires pour la soumission des tâches
}
Le cycle de vie qu’implique ExecutorService a trois états : en cours d’exécution, en
cours d’arrêt et terminé. Les objets ExecutorService sont initialement créés dans l’état
en cours d’exécution. La méthode shutdown() lance un arrêt en douceur : aucune nouvelle
tâche n’est acceptée, mais les tâches déjà soumises sont autorisées à se terminer – même
celles qui n’ont pas encore commencé leur exécution. La méthode shutdownNow() lance
un arrêt brutal : elle tente d’annuler les tâches en attente et ne lance aucune des tâches
qui sont dans la file et qui n’ont pas commencé.
Les tâches soumises à un ExecutorService après son arrêt sont gérées par le gestionnaire d’exécution rejetée (voir la section 8.3.3), qui peut supprimer la tâche sans prévenir
ou forcer execute() à lancer l’exception non controlée RejectedExecutionException.
Lorsque toutes les tâches se sont terminées, l’objet ExecutorService passe dans l’état
terminé. Vous pouvez attendre qu’il atteigne cet état en appelant la méthode await
Termination() ou en l’interrogeant avec isTerminated() pour savoir s’il est terminé.
En général, on fait suivre immédiatement l’appel à shutdown() par awaitTermination(),
afin d’obtenir l’effet d’un arrêt synchrone de ExecutorService (l’arrêt de Executor et
l’annulation de tâche sont présentés plus en détail au Chapitre 7).
La classe LifecycleWebServer du Listing 6.8 ajoute un cycle de vie à notre serveur
web. Ce dernier peut désormais être arrêté de deux façons : par programme en appelant
stop() ou via une requête client en envoyant au serveur une requête HTTP respectant
un certain format.
Listing 6.8 : Serveur web avec cycle de vie.
class LifecycleWebServer {
private final ExecutorService exec = ...;
public void start() throws IOException {
=Java FM.book Page 127 Mardi, 7. avril 2009 7:11 07
Chapitre 6
Exécution des tâches
127
ServerSocket socket = new ServerSocket(80);
while (!exec.isShutdown()) {
try {
final Socket conn = socket.accept();
exec.execute(new Runnable() {
public void run() { handleRequest(conn); }
});
} catch (RejectedExecutionException e) {
if (!exec.isShutdown())
log("task submission rejected", e);
}
}
}
public void stop() { exec.shutdown(); }
void handleRequest (Socket connection) {
Request req = readRequest(connection);
if (isShutdownRequest(req))
stop();
else
dispatchRequest(req);
}
}
6.2.5
Tâches différées et périodiques
La classe utilitaire Timer gère l’exécution des tâches différées ("lancer cette tâche dans
100 ms") et périodiques ("lancer cette tâche toutes les 10 ms"). Cependant, elle a
quelques inconvénients et il est préférable d’utiliser ScheduledThreadPoolExecutor à
la place1. Pour construire un objet ScheduledThreadPoolExecutor, on peut utiliser son
constructeur ou la méthode fabrique newScheduledThreadPool().
Un Timer ne crée qu’un seul thread pour exécuter les tâches qui lui sont confiées. Si l’une
de ces tâches met trop de temps à s’exécuter, cela peut perturber la précision du timing des
autres TimerTask.
Si une tâche TimerTask est planifiée pour s’exécuter toutes les 10 ms et qu’une autre
TimerTask met 40 ms pour terminer son exécution, par exemple, la tâche récurrente
sera soit appelée quatre fois de suite après la fin de la tâche longue soit "manquera"
totalement quatre appels (selon qu’elle a été planifiée pour une fréquence donnée ou
pour un délai fixé). Les pools de thread résolvent cette limitation en permettant d’utiliser
plusieurs threads pour exécuter des tâches différées et planifiées.
Un autre problème de Timer est son comportement médiocre lorsqu’une TimerTask
lance une exception non contrôlée : le thread Timer ne capturant pas l’exception, une
exception non contrôlée lancée par une TimerTask met fin au planificateur. En outre,
dans cette situation, Timer ne ressuscite pas le thread : il suppose à tort que l’objet Timer
tout entier a été annulé. En ce cas, les TimerTasks déjà planifiées mais pas encore
1. Timer ne permet d’ordonnancer les tâches que de façon absolue, pas relative, ce qui les rend dépendantes des modifications de l’horloge système ; ScheduledThreadPoolExecutor n’utilise qu’un temps
relatif.
=Java FM.book Page 128 Mardi, 7. avril 2009 7:11 07
128
Structuration des applications concurrentes
Partie II
exécutées ne seront jamais lancées et les nouvelles tâches ne pourront pas être planifiées
(ce problème, appelé "fuite de thread", est décrit dans la section 7.3, en même temps
que les techniques permettant de l’éviter).
La classe OutOfTime du Listing 6.9 illustre la façon dont un Timer peut être perturbé de
cette manière et, comme un problème ne vient jamais seul, comment l’objet Timer
partage cette confusion avec le malheureux client suivant qui tente de soumettre une
nouvelle TimerTask. Vous pourriez vous attendre à ce que ce programme s’exécute
pendant 6 secondes avant de se terminer alors qu’en fait il se terminera après 1 seconde
avec une exception IllegalStateException associée au message "Timer already
cancelled". ScheduledThreadPoolExecutor sachant correctement gérer ces tâches qui
se comportent mal, il y a peu de raisons d’utiliser Timer à partir de Java 5.0.
Listing 6.9 : Classe illustrant le comportement confus de Timer.
public class OutOfTime {
public static void main(String[] args) throws Exception {
Timer timer = new Timer();
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(1);
timer.schedule(new ThrowTask(), 1);
SECONDS.sleep(5);
}
static class ThrowTask extends TimerTask {
public void run() { throw new RuntimeException(); }
}
}
Si vous devez mettre en place un service de planification, vous pouvez quand même
tirer parti de la bibliothèque standard en utilisant DelayQueue, une implémentation de
BlockingQueue fournissant les fonctionnalités de planification de ScheduledThread
PoolExecutor. Un objet DelayQueue gère une collection d’objets Delayed, associés à
un délai : DelayQueue ne vous autorise à prendre un élément que si son délai a expiré.
Les objets sortent d’une DelayQueue dans l’ordre de leur délai.
6.3
Trouver un parallélisme exploitable
Le framework Executor facilite la spécification d’une politique d’exécution mais, pour
utiliser un Executor, vous devez décrire votre tâche sous la forme d’un objet Runnable.
Dans la plupart des applications serveur, le critère de séparation des tâches est évident :
c’est une requête client. Parfois, dans la plupart des applications classiques notamment,
il n’est pas si facile de trouver une bonne séparation des tâches. Même dans les applications serveur, il peut également exister un parallélisme exploitable dans une même requête
client ; c’est parfois le cas avec les serveurs de bases de données (pour plus de détails
sur les forces en présence lors du choix de la séparation des tâches, voir [CPJ 4.4.1.1]).
Téléchargement