BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Choisissez la taille de votre pool de thread

Choisissez la taille de votre pool de thread

Quelle taille devrait avoir votre pool ?

Un jour sur Skype, un ami m'a posé quelques questions à propos d'un cluster de serveur tournant sur un 64-way (64 processeurs) qui lançaient 30 000 threads plusieurs fois par jour. Avec plus de 300 000 threads en cours, le kernel passait tellement de temps à les gérer qu'ils déstabilisaient complètement l'application. Il était évident que cette application avait besoin d'une réserve de thread pour pouvoir absorber les clients plutôt que les clients aient à subir la surcharge.

Même si cet exemple est un cas extrême, il permet de mettre en avant les raisons d'utiliser un pool de threads. Mais même avec un pool, il est encore possible d'infliger des problèmes à nos utilisateurs en perdant des données ou en ayant des échecs de transactions. Si le pool est trop grand ou trop petit, nous pouvons rapidement voir notre application déstabilisée. Une réserve correctement proportionnée doit permettre de gérer autant de requêtes que l'application et le matériel peuvent convenablement en supporter. En d'autres termes, nous ne voulons pas qu'une requête attende alors que nous avons encore la capacité de la gérer, mais nous ne voulons pas non plus lancer dans le traitement d'une requête pour laquelle nous n'avons plus de capacité. Alors, quelle taille doit faire votre réserve ?

Si nous suivons le mantra « measure, don't guess » (mesure, ne devine pas), nous devons commencer par jeter un œil à la technologie dont il est question et se demander quelles mesures ont du sens et comment instrumenter notre système pour les obtenir. Nous devons aussi poser quelques formules mathématiques sur la table. Si l'on considère qu'un pool de thread est juste un ou plusieurs fournisseurs de services combiné avec une file d'attente, alors on voit que ce système peut se modéliser en utilisant la loi de Little. Entrons un peu plus dans le détail.

Loi de Little

La loi de Little dit que le nombre de requêtes dans un système est égal à leur fréquence d'arrivée multiplié par le temps moyen de traitement d'une requête. Cette loi est fréquemment utilisée dans la vie quotidienne et c'est d'ailleurs étonnant quelle n'ait été proposée que durant les années 50 et démontrée dans les années 60. Voici un exemple de la loi de Little en pratique. Avez-vous déjà été bloqué dans une file d'attente en vous demandant combien de temps vous allez attendre ? Vous devez compter combien de personnes sont dans la queue et estimer combien de temps il faut pour servir la personne en tête de la file. Vous multipliez les deux et cela vous donne une estimation de combien de temps vous allez devoir attendre. Si au lieu de regarder la taille de la file vous observez la fréquence à laquelle de nouvelles personnes se joignent à la queue et multipliez ce nombre par le temps de traitement, vous obtenez le nombre moyen de personnes dans la file ou en train d'être servies.

Il existe bien d'autres jeux similaires auxquels vous pouvez jouer avec la loi de Little qui répondront à d'autres questions comme "en moyenne, combien de temps une personne attend dans la file avant d'être prise en charge ?" ...

Figure 1. La loi de Little

Dans la même veine, nous pouvons utiliser la loi de Little pour déterminer la taille du pool. Tout ce que nous devons mesurer c'est la fréquence à laquelle les requêtes arrivent et le temps moyen pour les traiter. Nous pouvons alors lier ces mesures avec la loi de Little pour calculer le nombre moyen de requêtes dans le service. Si ce nombre est plus petit que la taille de votre pool alors vous pouvez en réduire la taille. A l'inverse, si ce nombre est plus grand que la capacité de votre pool, alors les choses se compliquent.

Dans le cas où vous avez plus de requêtes qui attendent que celles qui sont en train d'être exécutées, la première chose à vérifier est si le système a suffisamment de capacité pour supporter un pool de threads plus grand. Pour cela, nous devons déterminer quelle ressource est la plus probable de limiter la capacité de l'application à s'adapter. Dans cet article nous allons considérer que c'est le CPU, mais en pratique il y a de grandes chances que ce soit autre chose. Le cas le plus simple est que vous ayez assez de capacité pour augmenter la taille du pool. Si vous ne pouvez pas, il va falloir considérer d'autres options comme tuner votre application, modifier le matériel ou bien combiner les deux.

Un cas pratique

Reprenons l'explication précédente en considérant un workflow où la requête commence à la socket puis est traitée. Durant le traitement, elle a besoin d'accéder à des données ou d'autres ressources jusqu'à ce que la réponse soit renvoyée au client. Dans notre démo, un serveur simule cette charge en exécutant une unité de travail qui effectue des calculs intensifs sur le CPU et récupère des données depuis la base ou une autre source de données externe. Pour notre démonstration, la partie de calcul intensif sera de calculer un certain nombre de décimales d'un nombre irrationnel comme Pi ou racine de 2. Thread.sleep est utilisé pour simuler l'appel à une source de données distante. Un pool de threads va être utilisé pour gérer le nombre de requêtes actives sur le serveur.

Pour surveiller chacun des comportements des threads, nous allons devoir avoir notre propre instrumentation dans java.util.concurrent (j.u.c). En pratique, l'instrumentation de j.u.c.ThreadPoolExecutor s'effectue en utilisant des aspects (AOP), ASM ou un autre outil d'instrumentation du byte code. Dans notre cas, cela sera fait manuellement en surchargeant la classe pour y ajouter les moyens de surveillance nécessaires.



public class InstrumentedThreadPoolExecutor extends ThreadPoolExecutor {

 // Keep track of all of the request times
 private final ConcurrentHashMap timeOfRequest =
         new ConcurrentHashMap();
 private final ThreadLocal startTime = new ThreadLocal();
 private long lastArrivalTime;
 // other variables are AtomicLongs and AtomicIntegers

 @Override
 protected void beforeExecute(Thread worker, Runnable task) {
   super.beforeExecute(worker, task);
   startTime.set(System.nanoTime());
 }

 @Override
 protected void afterExecute(Runnable task, Throwable t) {
   try {
     totalServiceTime.addAndGet(System.nanoTime() - startTime.get());
     totalPoolTime.addAndGet(startTime.get() - timeOfRequest.remove(task));
     numberOfRequestsRetired.incrementAndGet();
   } finally {
     super.afterExecute(task, t);
   }
 }

 @Override
 public void execute(Runnable task) {
   long now = System.nanoTime();

   numberOfRequests.incrementAndGet();
   synchronized (this) {
     if (lastArrivalTime != 0L) {
       aggregateInterRequestArrivalTime.addAndGet(now - lastArrivalTime);
     }
     lastArrivalTime = now;
     timeOfRequest.put(task, now);
   }
   super.execute(task);
  }
 }

Listing 1. L'essentiel de l'instrumentation d'InstrumentedThreadPoolExecutor

Ce listing contient la partie non triviale de l'instrumentation du code que notre serveur va utiliser à la place du j.u.c.ThreadPoolExecutor. Nous allons collecter ces données en surchargent les 3 méthodes clés de l'Executor : beforeExecute, execute et afterExecute et en exposant cela avec un MXBean. Plongeons dans le détail de ces méthodes.

La méthode execute de l’exécutant est utilisée pour passer la requête dans l'Executor. En surchargeant cette méthode, cela nous permet de collecter tout le timing nécessaire au démarrage de la tâche. Nous pouvons également en profiter pour suivre l'intervalle entre les requêtes. Ce calcul implique plusieurs étapes avec chaque thread qui remet à zéro la date d'arrivée de la dernière tâche (lastArrivalTime). Comme nous partageons cela entre les threads, les informations doivent être synchronisées. Ensuite nous déléguons l’exécution à la classe Executor parent.

Comme son nom l'indique, la méthode executeBefore est exécutée avant que la requête soit traitée. Jusqu'à ce moment-la, tout le temps qui s'est écoulé est le temps que la requête a passé dans la file d'attente. On appelle souvent cela le "temps mort". Le temps après la fin de cette méthode et avant que 'afterExecute' ne soit appelé est le temps de traitement de la requête que nous pourrons utiliser avec la loi de Little. Nous pouvons stocker l'heure de début dans une variable ThreadLocal. La méthode 'afterExecute' va finaliser les calculs pour "le temps passé dans le pool", "le temps de traitement" et va enregistrer que la requête a été traitée.

Nous allons également avoir besoin d'un MXBean pour publier ces données de performance que nous collectons avec notre InstrumentedThreadPoolExecutor. Ce sera le travail du MXBean ExecutorServiceMonitor (cf. Listing 2)


public class ExecutorServiceMonitor
             implements ExecutorServiceMonitorMXBean {

 public double getRequestPerSecondRetirementRate() {
   return (double) getNumberOfRequestsRetired() / 
    fromNanoToSeconds(threadPool.getAggregateInterRequestArrivalTime());
 }

 public double getAverageServiceTime() {
   return fromNanoToSeconds(threadPool.getTotalServiceTime()) /
    (double)getNumberOfRequestsRetired();
 }

 public double getAverageTimeWaitingInPool() {
   return fromNanoToSeconds(this.threadPool.getTotalPoolTime()) /
    (double) this.getNumberOfRequestsRetired();
 }

 public double getAverageResponseTime() {
   return this.getAverageServiceTime() + 
     this.getAverageTimeWaitingInPool();
 }

 public double getEstimatedAverageNumberOfActiveRequests() {
   return getRequestPerSecondRetirementRate() * (getAverageServiceTime() + 
     getAverageTimeWaitingInPool());
 }

 public double getRatioOfDeadTimeToResponseTime() {
   double poolTime = (double) this.threadPool.getTotalPoolTime();
   return poolTime / 
     (poolTime + (double)threadPool.getTotalServiceTime());
 }

 public double v() {
   return getEstimatedAverageNumberOfActiveRequests() / 
     (double) Runtime.getRuntime().availableProcessors();
 }
}

Listing 2. Les parties intéressantes du ExecutorServiceMonitor

Dans le listing ci-dessus, vous pouvez voir les méthodes non triviales qui nous donnent une explication de la manière dont est utilisée la file d'attente. La méthode getEstimatedAverageNumberOfActiveRequests() est notre implémentation de la Loi de Little. Dans celle-ci, la fréquence de traitement ou la fréquence observée à laquelle les requêtes quittent le système est multiplié par le temps moyen nécessaire au traitement d'une requête, qui donne le nombre moyen de requêtes dans la file. Les autres méthodes intéressantes sont getRatioOfDeadTimeToResponseTime() et getRatioOfDeadTimeToResponseTime(). Nous allons effectuer quelques expériences et voir comment ces résultats interagissent et jouent sur l'utilisation du CPU.

Expérience 1

La première expérience est volontairement triviale pour nous permettre de comprendre comment cela fonctionne. Les paramètres pour ce test basique sont : la taille du pool à 1 et un seul client qui effectue à plusieurs reprises la requête décrite au dessus pendant 30 secondes.

(Cliquez sur l'image pour l'agrandir)

Figure 2. Résultats avec 1 client et 1 thread

Le graphique dans la figure 2 a été obtenu en prenant une capture d'écran de la vue du MBean dans VisualVM. VisualVM (http://visualvm.java.net/) est un logiciel open source conçu pour héberger des outils de surveillance de la performance. L'utilisation de cet outil est en dehors du périmètre de cet article. En bref, la vue du MBean est un plugin optionnel qui vous donne accès à tous les MBeans JMX qui sont enregistrés avec le PlatformMBeansServer

Revenons à notre expérience, la taille du pool de thread était de 1. Etant donné le comportement répétitif du client, nous pourrions nous attendre à avoir un nombre moyen de requêtes actives égal à 1. Cependant, cela prend un certain temps au client avant de ré-initier une nouvelle requête, ce qui explique pourquoi la valeur est légèrement en dessous de 1 (0.98).

L'autre valeur, RatioOfDeadTimeToResponseTime vaut un peu plus de 0,1%. Etant donné qu'il n'y a toujours qu'une seule requête active et que cela correspond à la taille du pool, nous pourrions nous attendre à ce que cela vale 0. Mais les imprécisions des mesures et le délai introduit par les mesures expliquent que cette valeur ne soit pas 0. Cependant, la valeur est vraiment proche de 0 et nous pouvons donc l'ignorer. L'utilisation du CPU nous informe que moins d'un cœur est utilisé et que nous avons donc encore beaucoup de capacités pour gérer plus de requêtes.

Expérience 2

Dans cette deuxième expérience, nous conservons la taille du pool alors que nous augmentons le nombre de clients actifs en parallèle à 10. Comme attendu, l'utilisation du CPU n'a pas changé car nous n'avons qu'un thread qui travail à la fois. Cependant, nous sommes capables de traiter plus de requêtes, et cela probablement car le nombre de requêtes actives est de 10 ce qui permet au client de ne pas avoir à attendre pour initier une nouvelle requête. A partir de cela nous pouvons voir que nous avions toujours une requête active et que la taille de notre file d'attente était de 9. Ce qui est plus parlant, c'est le temps mort passé à attendre dans la queue qui représente maintenant 90% du temps global de traitement. C'est à dire que 90% du temps est passé à attendre que le service alloue un thread à la requête. Si nous voulons réduire le délai, nous voyons que le point noir est le temps d'attente dans la file et comme nous avons encore beaucoup de ressources CPU, il est possible d'augmenter la taille du pool pour permettre de gérer plus de requêtes.

(Cliquez sur l'image pour l'agrandir)

Figure 3. Résultats avec 10 clients et 1 thread dans le pool

Expérience 3

Comme notre machine est un quatre cœurs, essayons maintenant avec un pool de 4 threads.

(Cliquez sur l'image pour l'agrandir)

Figure 4. Résultats avec 10 clients et 4 threads dans le pool

Encore une fois nous pouvons voir que le nombre moyen de requêtes est de 10. La différence cette fois est le nombre de requêtes traitées qui est passé à un peu plus de 2000. Il y a une augmentation équivalente de l'utilisation du CPU mais elle n'est toujours pas à 1oo%. Le temps perdu dans la file d'attente représente toujours 60% du temps global de traitement ce qui signifie que l'on peut encore apporter quelques améliorations. Pouvons-nous utiliser le reste du CPU libre en augmentant la taille du pool ?

Expérience 4

Dans ce dernier test le pool a été dimensionné à 8 threads. En regardant les résultats nous pouvons constater que le nombre moyen de requêtes actives est juste en-dessous de 10, le temps mort dans la queue représente un peu moins de 19% et le nombre total de requêtes traitées a bondit jusque 3621. Cependant, avec une utilisation du CPU proche de 1oo%, on dirait que nous arrivons à la fin des améliorations que nous pouvons effectuer dans ces conditions de charge. Cela montre également que 8 est le nombre idéal pour la taille du pool. Une autre information que nous pouvons tirer de ces résultats est que le seul moyen de diminuer le temps mort dans la file est d'ajouter du CPU ou de diminuer la consommation CPU de l'application.

(Cliquez sur l'image pour l'agrandir)

Figure 5. Résultats avec 10 clients et 8 threads dans le pool

Quand la théorie correspond à la réalité

Une critique qui peut être effectuée par rapport à ces expériences est la sur-simplification du problème. Les grosses applications ont rarement un seul thread ou une seule connexion dans le pool. En fait, beaucoup d'applications ont un pool supplémentaire par technologie utilisée. Par exemple, pour une application qui reçoit des requêtes via HTTP avec une servlet ainsi que par JMS puis utilise JDBC pour les traiter, vous aurez un pool de threads pour le moteur de servlet, et un pool pour les composants JMS ainsi que pour les connexions JDBC. Une chose à faire pour ce genre de cas est de traiter tout cela comme un seul pool de threads. Cela implique que vous agrégiez les fréquences d'arrivée et les temps de traitement. En étudiant le système avec la loi de Little, nous pouvons déterminer le nombre total de threads. L'étape suivante est de les répartir entre les différents groupes de threads. La logique du découpement peut utiliser une des nombreuses techniques disponibles qui sera certainement guidée par vos besoins de performance. Une technique serait de répartir la taille du pool par rapport aux demandes par technologie. Cependant il sera important de donner la priorité aux clients Web par rapports aux requêtes arrivant en JMS et donc de le prendre en compte dans la répartition. Bien-sûr, à chaque répartition vous devrez ajuster les tailles des pools et prendre en compte les différences au niveaux du matériel.

Une autre considération est que cette formulation du problème se concentre sur le nombre moyen de requêtes dans le système. Vous devriez vous occuper du 90ème centile pour définir la taille de la queue. En utilisant cette valeur vous aurez plus de facilité à gérer les fluctuations naturelles de la fréquence d'arrivée des requêtes. Pour arriver à cette valeur le travail est plus compliqué mais aussi efficace que d'ajouter 20% à la valeur moyenne. Quand vous faites cela, assurez-vous d'avoir suffisamment de capacité pour gérer beaucoup de threads. Par exemple dans nos expériences, 8 threads avaient raison de la totalité du CPU ce qui veut dire qu'ajouter plus de threads commencerait à dégrader les performances. Finalement, le kernel est bien plus efficace pour gérer des threads que ne l'est un pool de threads. Mais alors que nous pensons deviner qu'un thread de plus perturberait les performances, des mesures pourraient en fait vous prouver que vous aviez bien encore assez de ressources. En résumé, essayez de tester votre application dans des conditions extrêmes (stress test) pour voir les effets sur les performances et les cadences de traitement.

Conclusion

Obtenir une configuration correcte pour la taille d'un pool de threads n'est pas simple mais ce n'est pas aussi compliqué qu'il y parait. Les formules mathématiques derrière ce problème sont bien connues, comprises et relativement intuitives car nous rencontrons ces problèmes dans nos vies quotidiennes. Ce qui manque ce sont les mesures (comme notre implémentation du j.u.c.ExecutorService) qui sont indispensables pour effectuer des choix éclairés. Parvenir à des réglages optimaux nécessite un peu de travail fastidieux car c'est parfois un peu empirique, mais vaut vraiment la peine d'y consacrer un peu de temps. En effet, il serait bien pire d'avoir à résoudre les problèmes d'une application qui est déstabilisée quand la charge est supérieure à ce que vous aviez prévu.

A propos de l'auteur

 Kirk Pepperdine travaille en tant que consultant indépendant spécialisé dans la performance et le tuning d'applications Java. Il a par ailleurs donné un séminaire sur le tuning de la performance qui a reçu un excellent accueil partout dans le monde. Celui-ci présente une méthodologie pour le tuning qui a été utilisé pour améliorer l'efficacité des équipes en charge de traiter les problèmes de performances Java. Java Champion depuis 2006, Kirk a écrit de nombreuses publications à propos de la performance et donné de nombreuses conférences notamment à Devoxx et JavaOne. Il a participé à la création du site Java Performance Tuning, un site bien connu comme source d'informations sur ce sujet.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT