BT

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

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Exterminer les Heisenbugs

Exterminer les Heisenbugs

Le terme "Heisenbug" ne se trouvera probablement pas dans la liste des nouveaux mots ou des mots les plus fréquemment utilisés de Webster cette année. Malheureusement, en tant qu'ingénieurs en développement nous sommes (trop) familiers avec cette créature sinistre. Un jeu de mots sur le Principe d'Incertitude d'Heisenberg - un concept de physique quantique qui dit que les observateurs affectent ce qu'ils observent par le simple acte d'observation - le terme d'Heisenbug est assigné de manière légère à un bug informatique qui se produit au moment où on s'y attend le moins en production, mais pour lequel toutes les tentatives de reproduction alors que les instruments de mesure sont branchés échouent. De fait, il devient presque impossible de reproduire simultanément les circonstances du bug et le bug lui-même.

Il existe, cependant, une manière infaillible d'introduire un heisenbug:

  1. Écrire un programme concurrent et s'assurer d'ignorer des concepts de concurrence tels que la publication, le modèle mémoire Java et le réordonnancement de bytecode.
  2. Tester le programme exhaustivement (ne vous inquiétez pas, les tests vont passer!)
  3. Déployer le programme en production
  4. Attendre que le changement en production suivant plante et vérifiez votre boîte de réception. En un instant, vous trouverez un tas d'emails vitrioliques vous critiquant vous et votre application pleine de bugs.

Avant d'adresser ce que nous pouvons faire pour éviter de tels heisenbugs, une question plus fondamentale pourrait être plus appropriée: si la programmation concurrente est tellement difficile, pourquoi s'embêter à le faire quand même? Dans les faits, il y a un certain nombre de raisons pour lesquelles cela vaut le coût de s'embêter:

  • le Parallélisme - alors que le nombre de processeurs et de coeurs augmente, le multithreading permet aux programmes d'exploiter le parallélisme afin de s'assurer que nos programmes ne surchargent pas un coeur alors que les autres sont inoccupés. Même sur une machine monocœur, les applications qui ne sont pas occupées à faire des calculs, peut-être parce qu’elles sont en attente d'E/S ou d'autres sous-systèmes, auront des cycles processeurs libres. La programmation concurrente permet aux programmes d'utiliser ces cycles libres pour optimiser la performance.
  • l’Équitabilité - si deux clients ou plus sont en attente d'accéder à un sous-système, il serait indésirable que chaque client doive attendre la complétion du précédent avant de s'exécuter. La concurrence permet à notre programme d'assigner chaque requête client à un thread, minimisant ainsi la latence perçue.
  • le Pragmatisme - il est souvent plus facile d'écrire une série de petites tâches qui opèrent individuellement plutôt que de créer et coordonner un large programme qui gère toutes ces tâches.

Ces raisons cependant ne mitigent pas le fait que la programmation concurrente est difficile. Si nous, développeurs, ne le prenons pas totalement en compte dans nos applications, nous nous exposons à de sérieux heisenbugs. Dans cet article nous allons introduire dix astuces à garder à l'esprit quand nous architecturons ou nous développons nos applications concurrentes en Java.

Astuce 1 - Informez-vous

Je suggère de lire en détail "Java Concurrency in Practice" par Brian Goetz. Ce classique de 2006 couvre la concurrence en Java depuis les bases. La première fois que j'ai lu ce livre (vous vous surprendrez à vous y référer encore et encore), j'avais une boule à l'estomac juste en réfléchissant à toutes les infractions que j'avais commises au fil des années. Pour une plongée profonde dans tous les aspects de la concurrence en Java, pensez à la formation "Concurrency Specialist", basée sur le livre "Java Concurrency in Practice", créée par le spécialiste Dr Heiz Kabutz et approuvée par Brian Goetz.

Astuce 2 - Utilisez les ressources pour experts disponibles

Utilisez le package java.util.concurrent introduit dans Java 5. Si vous n'avez pas énormément d'expérience avec tous les composants de ce package, je vous recommande de télécharger l'application "Java Concurrent Animated" depuis SourceForge, qui contient une série d'animations (écrites par votre serviteur) qui illustrent chacun des composants du package. Le programme sert de catalogue interactif auquel vous pouvez vous référer quand vous architecturez vos propres solutions concurrentes.

Quand Java est apparu sur la scène fin 1995, c'était l'un des premiers langages de programmation qui incluait le multithread en tant que fonctionnalité de base du langage. Parce que la concurrence est tellement difficile, de très bons développeurs écrivaient parfois de très mauvais programmes concurrents. Peu de temps après, le gourou de la concurrence, le professeur Doug Lea de l'Université d'État d'Oswego publiait son magnum opus, "Concurrent Programming in Java". Maintenant en seconde édition, ce travail introduisait un catalogue de patrons de conception liés à la concurrence qui devinrent les fondations du package java.util.concurrent. Doug Lea nous a appris que le code concurrent que nous aurions précédemment pu intégrer à nos classes pouvait en fait être extrait dans des composants individuels pouvant être certifiés par des spécialistes, nous permettant de nous concentrer sur la logique métier de notre programme sans nous encombrer outre mesure des caprices de la concurrence. En nous familiarisant avec ce package et en l'utilisant dans nos programmes, nous pouvons substantiellement réduire le risque d'introduire des erreurs liées à la concurrence.

Astuce 3 - Soyez conscients des écueils

La concurrence n'est pas une fonctionnalité avancée du langage, donc ne pensez pas un instant que votre programme est immunisé contre les problématiques liées aux threads. Tous les programmes Java sont multithreadés: non seulement la JVM crée des threads tels que le Garbage Collector, les finalizers ou les shutdown hooks , mais d'autres frameworks introduisent leurs propres thread eux aussi. Par exemple, Swing et AWT (Abstract Window Toolkit) introduisent un thread de publication d’événements, l'Event Dispatch Thread (ou EDT). L'interface de programmation d'applications Java RMI (Remote Method Invocation), ainsi que les frameworks web dit "Modèle-Vue-Contrôleur" (MVC) tels que Struts et Spring MVC introduisent un thread par appel et sont donc particulièrement vulnérables. Tous ces threads peuvent appeler des méthodes dans votre code qui modifient potentiellement l'état de votre application. Si de multiples threads sont autorisés à accéder à des variables d'état en mode lecture ou écriture sans être proprement synchronisés, alors votre programme est erroné. Soyez conscients de cela, et soyez proactif en tenant compte de la concurrence quand vous codez.

Astuce 4 - Prenez une approche directe

Codez en premier lieu un programme correct, puis corrigez-le pour la performance. Des techniques telles que l'encapsulation peuvent rendre votre programme thread-safe, mais peuvent aussi potentiellement le ralentir. Cependant, HotSpot fera généralement un bon boulot d'optimisation pour se débarrasser de ces problèmes, et c'est donc une bonne pratique de s'assurer d'abord que son programme s'exécute correctement avant de s'inquiéter des ajustements de performance. Trop souvent nous nous rendons compte que des optimisations fines, qui rendent notre code difficile à comprendre et à maintenir, ne produisent tout simplement pas d'économies de temps d'exécution mesurables. Évitez de telles optimisations, toutes élégantes ou intelligentes qu'elles semblent être. En général, il est recommandable d'écrire du code non optimisé et d'ensuite utiliser un profiler pour trouver les points chauds et goulots d'étranglement et essayer de les corriger. Visez le plus gros goulot d'étranglement en premier, puis réanalysez votre code; vous vous apercevrez souvent que vous avez déjà corrigé quelque un des points suivants les plus sévères. C'est un ordre de grandeur plus facile d'écrire correctement votre programme depuis le début que ça ne l'est d'essayer de réintroduire la tolérance aux threads plus tard, donc gardez-le simple et correct et documentez diligemment toutes les suppositions liées à la concurrence dès le début. L'utilisation d'annotations telles que @ThreadSafe, @NotThreadSafe, @Immutable et @GuardedBy peuvent être d'une grande aide dans la documentation des assomptions de concurrence de votre code.

Astuce 5 - Gardez trace de l'atomicité

En programmation concurrente, une opération (ou ensemble d'opérations) est atomique si pour le reste du système elle paraît se produire en un unique instant entre son invocation et sa réponse. L'atomicité est une garantie d'isolation des processus concurrents. Parce que Java s'assure que les opérations 32 bits sont atomiques, affecter une valeur aux entiers et flottants assurera donc toujours au moins une sécurité vis-à-vis de l'effet "comme par magie". Cela veut dire que quand plusieurs threads affectent une valeur en même temps, ils sont garantis de produire l'une de ces valeurs, et pas une valeur hybride mutante apparue "comme par magie". Les opérations 64 bits portant sur les longs et les doubles n'offrent cependant pas de telles garanties; la spécification du langage Java autorise une affectation 64-bits à être traitée comme deux affectations 32-bits, de manière non atomique. En conséquence, lorsqu'on utilise des variables de type long ou double, des threads qui essayent de modifier ces variables de manière concurrente peuvent produire des résultats imprévus et imprévisibles, dans la mesure où la valeur résultante pourrait n'être déterminée ni par le premier ni par le second thread mais plutôt par une combinaison des octets affectés par chacun. Si par exemple un thread A affecte la valeur hexadécimale 1111AAAA et un thread B affecte une valeur 2222BBBB, il est fort possible que la valeur résultante soit une variation hybride 1111BBBB, qui n'était affectée par aucun des deux. Cela peut être aisément corrigé en déclarant de telles variables comme volatil, ce qui indique à la plate-forme de traiter les setters de manière atomique. Une déclaration volatil n'est cependant pas une panacée; bien qu'elle assure que les setters soient atomiques, une synchronisation supplémentaire est nécessaire pour s'assurer que les autres opérations sur les variables soient atomiques. Si, par exemple, "count" est une variable de type long, alors il est tout à fait possible que des appels à la fonction incrémentale count++ soient perdus. Cela est dû au fait que, bien qu'en apparence unique, l'opérateur ++ est une combinaison de trois opérations : chargement, incrémentation et affectation. Si des threads concurrents exécutent les opérations en une séquence entrelacée, il est possible que les deux threads effectuent l'opération de lecture en même temps et obtiennent donc la même valeur, l'opération d'incrémentation ayant donc par la suite le même résultat, avec pour conséquence que l'affectation produise la même valeur. Si "count" était un compteur de résultat, un résultat n'est pas comptabilisé. Quand de multiples threads pourraient faire de manière concurrente des opérations de vérification, de mise à jour ou d'affectation, soyez sûrs de les effectuer dans un bloc synchronisé ou d'utiliser une implémentation de java.util.Lock telle que ReentrantLock.

Beaucoup de développeurs ont l'impression erronée qu'il n'est nécessaire de synchroniser qu'à l'écriture - mais pas à la lecture - des valeurs. C'est une idée fausse. Par exemple, si l'écriture est synchronisée mais pas la lecture, il est fort possible que le thread qui lit ne voie pas la valeur écrite par son homologue. Alors que cela pourrait sembler être un bug, c'est en vérité une fonctionnalité importante de Java. Les threads s'exécutent souvent sur des CPU ou des coeurs différents, et la conception des processeurs est telle que l'opération de déplacement d'une donnée d'un coeur à un autre n'est pas rapide. Java le prend en compte et autorise en conséquence chaque thread à abstraire une copie de son état par défaut quand le thread est lancé. Cet état original peut par la suite être accédé après que des changements d'état non qualifiés puissent avoir eu lieu plus récemment dans des threads différents. Bien que déclarer une variable comme volatile garantisse sa visibilité, cela n'assure toujours pas l'atomicité. Vous avez le pouvoir (ainsi que l'obligation) de résoudre le problème en codant une synchronisation là où elle est nécessaire.

Astuce 6 - Isolez les threads

Une manière d'empêcher les threads d'entrer en compétition les uns avec les autres pour l'accès à une donnée partagée est de ne pas la partager! Si une donnée particulière n'est jamais accédée que par un seul thread, alors pas besoin de réfléchir à une synchronisation supplémentaire. Cette technique est appelée "Isolation de Thread".

Une manière d'isoler les threads est de rendre les objets immuables. Bien que les objets immuables soient plus coûteux en termes de nombre d'objets produits, ils sont parfois exactement ce que le docteur préconise en terme de maintenance.

Astuce 7 - Respectez le modèle mémoire Java

Soyez conscients du contrat de modèle de mémoire Java pour la visibilité des variables. La règle dite de "l'Ordre du Programme" stipule que quand une variable est affectée dans un thread, elle reste visible par ce thread à compter de ce moment, indépendamment de toute synchronisation. Quand un verrou intrinsèque sur M est acquis par le thread A par son invocation de synchronized(M) et que ce verrou est par la suite libéré puis acquis par le thread B, tout ce qui était affecté par le thread A avant la libération du verrou - en incluant (peut être étonnamment) ces variables qui étaient affectées avant que le verrou ne soit obtenu - seront visibles au thread B après son acquisition du verrou sur M. Libérer un verrou est une sorte de commit de la mémoire pour toute valeur qui était visible du thread. (voir illustration ci-dessous). Notez que les nouveaux composants de verrou Semaphore et ReentrantLock dans java.util.concurrent affichent le même comportement. Les variables volatiles ont aussi une dynamique similaire: quand un thread A écrit dans une variable volatile x, c'est analogue à sortir d'un bloc synchronisé; quand le thread B lis cette variable x, c'est analogue à une entrée dans un bloc synchronisé sur la même variable, ce qui signifie que tout ce qui était visible à A quand il écrivait sur x sera visible à B après qu'il ai lu x.

Astuce 8 - Nombre de threads

Déterminer quel est le nombre de threads correct pour votre application. Une formule peut être développée pour effectuer ce calcul de manière formelle avec les variables suivantes:

  • soit T le nombre idéal de threads que nous recherchons
  • soit C le nombre de CPU
  • soit X le pourcentage d'utilisation de chaque processus
  • soit U le pourcentage d'utilisation cible

Si nous n'avions qu'un seul CPU qui était 100% utilisé, alors nous ne voudrions qu'un thread. Quand l'utilisation est à 100%, la règle spécifie que le nombre de threads devrait égaler le nombre de CPU, comme représenté par la formule T=C. Si cependant nous voyons que chaque CPU n'est utilisé qu'à x%, alors nous voulons augmenter le nombre de threads en divisant le nombre de CPU par x, avec pour résultat que T=C/x. Si x = 0,5 par exemple, alors nous pouvons avoir deux fois plus de threads que de CPU, ce qui produirait une utilisation de 100% sur tous les threads. Si nous ne voulons qu'une utilisation cible de U%, alors nous devons multiplier par U, ce qui donne T=UC/x. Finalement, si nous voyons un thread comme étant lié au processeur à p% (c'est à dire en train de calculer) et n% non-lié au processeur (c'est à dire en attente), alors de manière évidente x=p/(n+p). (Notez que n%+p%=100%). En substituant ces variables dans la formule ci-dessus, nous obtenons le résultat:

T=CU(n+p)/n ou de manière équivalente

T=CU(1+p/n)

De manière à déterminer p et n, vous pouvez équiper vos threads avec des capacités de logging du temps avant et après chaque appel CPU et chaque appel non-CPU tels qu'un appel E/S ou JDBC, puis en analysant les temps relatifs de chacun. Bien sûr, tous les threads ne vont pas montrer des métriques égales, et vous devez donc prendre la moyenne; cela devrait cependant vous donner une bonne base de départ pour débuter vos ajustements de configuration. Il est important de s'y prendre approximativement bien, parce qu'introduire trop de threads peut en fait dégrader les performances. Une fois que vous avez dérivé une valeur pour le nombre de threads, passez cette valeur à un FixedThreadPoolExecutor. Parce que le système d'exploitation s'occupe du basculement de contexte entre les processus, fixez U% suffisamment bas pour accommoder les autres processus. Vous ne devriez considérer que vos threads pour les autres parties du calcul de cette formule.

La bonne nouvelle, c'est qu'il y a un peu de mou, donc si votre compte de thread est décalé de jusqu'à 20% environ, vous ne verrez probablement pas un impact majeur sur les performances.

Astuce 9 - Développez en mode serveur

Développez en mode serveur, même si vous développez une application cliente. Le drapeau du mode serveur agit comme un indice pour la plate-forme qu'elle peut opérer certains ré-ordonnancement du byte-code de manière à réaliser des optimisations de performance. Bien que de telles optimisations du compilateur se produisent plus fréquemment en mode serveur qu'en mode client, elles peuvent se produire tout de même dans ce dernier. Utilisez le mode serveur pour exposer ces optimisations tôt pendant le processus de test plutôt que d'attendre une mise en production.

Mais effectuez vos tests en utilisant -server, -client et -Xcomp (pour éliminer le profiling à l'exécution, en effectuant les optimisations maximales avant çà).

Pour activer le mode serveur, appelez java avec le paramètre -server. Mon ami le Dr. Heinz Kabutz, connu pour sa liste de diffusion Java Specialist et son cours Java Masters Course, souligne que certaines JVMs (comme celle de l'OSX d'Apple jusqu'à récemment) en 64-bit ignoreront le paramètre -server, et vous devriez donc probablement aussi inclure le paramètre -showversion qui montrera la version en haut des logs du programme, incluant le mode client ou serveur. Si vous utilisez OSX, vous pouvez utiliser le paramètre -d32 pour passer en 32-bits. En bref, testez en utilisant -showversion -d32 -server et de manière générale en vérifiant les logs pour vous assurer que vous avez bien la version de Java désirée. De manière similaire, le Dr. Heinz suggère d'utiliser des tests séparés, alternant entre les flags -Xcomp (qui enlève des optimisations de la plate-forme en Just in Time) et -Xmixed (la valeur par défaut, qui optimise les "*hot spots*").

Astuce 10 - Testez le code concurrent

Nous savons tous comment construire des tests unitaires pour vérifier les post-conditions des appels de méthodes de notre code. C'est une routine difficile que nous nous sommes habitués à réaliser au fil des années, parce que nous comprenons leur valeur en terme de temps gagné, alors que nous passons du développement à la maintenance. L'aspect le plus important de la programmation que nous puissions tester, cependant, c'est le code concurrent. Ceci parce qu'il s'agit du code le plus fragile, et que c'est là que nous pouvons trouver la majorité de bugs. Ironiquement, c'est sur cet aspect que nous sommes tendons à être les plus légers, principalement parce que les tests unitaires liés à la concurrence sont si difficiles à produire. C'est attribuable à la nature non déterministe de l'interaction des threads - ce n'est généralement pas possible de prédire quels threads s’exécuteront dans quel ordre, puisque cela variera généralement d'une machine à l'autre ou même de processus en processus sur la même machine.

Une technique pour tester la concurrence consiste à utiliser les composants de concurrence eux-mêmes pour produire les tests. Pour simuler des timings de threads arbitraires par exemple, pensez aux différentes possibilités de séquencement de threads et utilisez des executors ordonnancés pour ordonner les threads de manière déterministe en accord avec ces possibilités. En testant la concurrence, souvenez-vous que lorsqu'un thread lance une exception, il ne causera pas d'erreur dans JUnit; JUnit échoue seulement quand le thread principal rencontre une Exception, pas quand elles se produisent dans d'autres threads. Une manière de contourner le problème est d'utiliser une FutureTask pour spécifier votre tâche à la place d'un Runnable. Comme la FutureTask implémente aussi Runnable, elle peut être soumise à un ScheduledExecutor. FutureTask accepte comme paramètre de constructeur soit un Callable, soit un Runnable. Callable est similaire à Runnable, à deux points notables près: Callable retourne une valeur alors que Runnable ne retourne rien, et Callable renvoie l'exception ExecutionException alors que Runnable ne peut que renvoyer des exceptions non vérifiées. ExecutionException possède une méthode getCause qui retourne la véritable exception déclenchante. (Notez que si vous passez un Runnable, vous devez aussi passer un objet retour. FutureTask combinera en interne le Runnable et l'objet retour dans un Callable, et vous aurez donc quand même tous les avantages d'un Callable). En utilisant une FutureTask, vous pouvez ordonnancer votre code concurrent et appeler get sur le résultat dans le thread principal de JUnit. La méthode get est faite pour relancer toute exception qui était lancée de manière asynchrone par son Callable ou son Runnable.

Il existe des challenges cependant. Supposez que vous ayez une méthode qui est supposée bloquer, et que vous vouliez tester qu'elle fait ce à quoi on s'attend. Comment testez-vous ce blocage? Combien de temps attendez-vous avant de décider que la méthode a réellement bloqué? FindBugs peut détecter certains problèmes de concurrence, et vous pourriez vouloir considérer l'utilisation d'un framework de test concurrent. MultithreadedTC, co-créé par Bill Pugh (co-auteur de FindBugs), fournit une API pour spécifier et tester toutes les variations d'intercalations, ainsi que d'autres fonctionnalités spécifiques à la concurrence.

Un point important à garder à l'esprit pendant les tests - les Heisenbugs ne sortent pas leur hideuse tête à chaque exécution, donc le résultat n'est pas un simple oui/non. Bouclez au travers de vos tests de concurrence de nombreuses fois (des milliers) et obtenez une métrique statistique en termes de moyennes et de déviations standards pour mesurer le succès.

En Résumé

Brian Goetz a prévenu que la visibilité des échecs va augmenter au cours du temps, alors que les fabricants de puces passent à des modèles mémoires plus faibles et que le nombre croissant de cœurs cause de plus nombreux intercalations.

En conséquence, évitons à tout prix de croire la concurrence toute acquise. Soyons conscients du fonctionnement interne du Modèle Mémoire Java et utilisons java.util.concurrent aussi souvent que possible pour exterminer les heisenbugs dans les programmes concurrents, libérant nos boîtes de réception pour ne garder que des avalanches de satisfaction et de félicitations.

Liens

Java Concurrency In Practice

Java Specialists’ Newsletter

Concurrency Specialist Course

Java Concurrent Animated

A propos de l'auteur

Récemment consacré Oracle Java Champion, Victor Grazi travaille depuis 2005 au Crédit Suisse, dans le domaine de l'Architecture de Banque d'Investissement et plus particulièrement l'architecture de plate-forme, et aussi en tant que consultant technique et évangéliste Java. Il intervient aussi fréquemment dans des conférences techniques dans lesquelles il parle de son premier amour, la concurrence en Java, et autres sujets liés à Java. Victor contribue et forme sur le populaire cours "*Concurrency Specialist Course*", et dirige le projet open source "Concurrence Java en Animations" (*Java Concurrent Animated*) sur SourceForge. Il vit à Brooklyn, New-York avec sa femme et sa famille.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT