=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]).