BT

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

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Le Modèle Mémoire révisé d'OpenJDK

Le Modèle Mémoire révisé d'OpenJDK

Retrouvez cet article dans notre eMag InfoQ FR consacré à Java 8.

 

Le Modèle Mémoire Java traditionnel (JMM) recouvre une grande partie des garanties de la sémantique du langage Java. Dans cet article, nous allons mettre en exergue quelques-unes de ces sémantiques et amener à une meilleure compréhension de celles-ci. Nous allons aussi tenter de communiquer les raisons derrière la volonté de mise à jour du modèle mémoire existant pour suivre les sémantiques décrites dans cet article. Les références à cette future mise à jour du JMM seront appelées JMM9 dans cet article.

Modèle Mémoire Java

Le Modèle Mémoire Java existant, tel que défini dans la JSR 133 (désormais qualifié de JMM-JSR133) décrit un modèle de cohérence pour la mémoire partagée et fourni aussi des définitions pour que les développeurs puissent représenter de manière uniforme le JMM-JSR133. Le but de la spécification JMM-JSR133 était d'assurer que la définition des sémantiques de threads interagissant à travers la mémoire soit suffisamment raffinée pour permettre des optimisations et fournir un modèle de programmation clair. Le JMM-JSR133 visait à fournir des définitions et sémantiques pour que les programmes multithreads puissent être non seulement corrects, mais aussi performants et avoir un impact minimal sur les bases de code existantes.

Avec ceci en tête, je voudrais vous guider à travers certaines des garanties qui ont été spécifiées de manière trop forte ou au contraire pas suffisamment dans le JMM-JSR133, tout en mettant en avant des discussions dans la communauté sur comment les améliorer dans le JMM9.

JMM9 - Le problème de la cohérence séquentielle et la non-compétition sur les données

Le JMM-JSR133 parle de l'exécution d'un problème par rapport à ses actions. Une telle exécution combine les actions avec des ordres pour décrire les relations entre ces actions. Dans cet article, je voudrais m'étendre un peu sur ces ordres et les relations, puis discuter de ce qui constitue une exécution cohérente séquentiellement. Commençons par "l'ordre du programme" - Un ordre du programme pour chaque thread est un ordre total qui indique l'ordre dans lequel toutes les actions vont être effectuées par chaque thread. Parfois, ces actions ne doivent pas nécessairement être ordonnées. Donc, nous avons certaines relations qui sont partiellement ordonnées. Par exemple, "se produit avant" et "synchronise avec" (NdT : les expressions du JMM sont "happens before" et "synchronize with") sont deux relations partiellement ordonnées. Quand une action se produit avant une autre, elle est non seulement visible avant la seconde, mais elle est aussi ordonnée avant cette seconde action. Cette relation entre ces deux actions est appelée une relation "se produit avant". Parfois, certaines actions doivent être ordonnées et elles sont appelées des "actions de synchronisation". Les lectures et écritures volatiles, le lock et unlock d'un monitor, etc, sont des exemples d'actions de synchronisation. Une action de synchronisation est un ordre partiel, ce qui signifie que tous les couples d'actions de synchronisations ne sont pas inclus. L'ordre total qui recouvre toutes ces actions est appelé l'ordre de synchronisation, et chaque exécution a son propre ordre de synchronisation.

Parlons maintenant de l'exécution séquentiellement cohérente. Une exécution qui semble survenir dans un ordre total sur toutes les actions de lecture et d'écriture est décrite comme étant séquentiellement cohérente (SC). Dans une exécution SC, les lectures de mémoire vont toujours voir les valeurs écrites par la dernière écriture dans cette variable. Quand une exécution SC ne présente pas de compétition sur les données (NdT : le terme anglais est data race), alors le programme est dit ne pas posséder de compétition sur les données (DRF) (NdT : datarace freedom). La compétition sur les données survient lorsqu'un programme présente deux accès qui ne sont pas ordonnées par une relation "se produit avant", qu'ils accèdent à la même variable, et qu'au moins une des actions est une écriture. SC pour DRF signifie que les programmes DRF se comportent comme s'ils étaient SC. Mais supporter de manière stricte SC a un coût en performances, la plupart des systèmes vont réordonner les opérations mémoire pour améliorer la vitesse d'exécution et cacher la latence des opérations coûteuses. De plus, le compilateur peut aussi réordonner le code pour en optimiser l'exécution. Dans un effort pour garantir la cohérence séquentielle stricte, tous les réarrangements des opérations mémoire ou les optimisations du code ne peuvent plus être faits et la performance en souffre. Le JMM-JSR133 incorpore déjà des restrictions d'ordre plus souples et la réorganisation par le compilateur ou les interactions mémoire ne sont pas observables par le programme.

Note : Les opérations coûteuses sont celles qui prennent un grand nombre de cycles CPU pour terminer et/ou bloquent le pipeline d'exécution.

JMM9 - Le problème des valeurs fabriquées à partir de rien (OoTA)

(NdT : le terme anglais est Out of Thin Air, OoTA) Une des autres garanties du JMM-JSR133 est la prohibition des valeurs fabriquées à partir de rien (OoTA). Le modèle "se produit avant" peut parfois autoriser des variables à être produites à partir de rien puisqu'il n'inclut pas de notion de causalité. Un point important à noter est que la cause par elle-même n'a pas de notion de dépendances sur les données et les structures de contrôle, comme nous allons le voir dans le code suivant correctement synchronisé, où les écritures illégales sont causées par les écritures elles-mêmes).

Note : x et y sont initialisés à 0

Thread a

Thread b

r1 = x;

r2 = y;

if (r1 != 0)

if (r2 != 0)

y = 42;

x = 42;

Ce code est cohérent du point de vue de "se produit avant", mais n'est pas réellement SC. Par exemple, si r1 voit l'écriture x = 42 et r2 voit l'écriture y = 42, x et y peuvent tous deux avoir la valeur de 42, qui est le résultat d'une compétition sur les données.

r1 = x;

y = 42;

r2 = y;

x = 42;

Ici, les deux écritures se sont produites avant la lecture de leurs variables et les lectures verraient les écritures respectives, ce qui conduirait à un résultat OoTA.

Afin d'interdire les valeurs OoTA, certaines écritures doivent attendre leur lecture pour éviter la compétition sur les données. Donc, la définition par le JMM-JSR133 du problème OoTA a formalisé l'interdiction des lectures OoTA. Cette définition formelle consiste en "l'exécution et les prérequis en causalité" (NdT executions and causality requirements) du modèle mémoire. De façon simple, une exécution correcte satisfait la clause de causalité si toutes les actions du programme peuvent être commises.

Note : Une exécution correcte se produit dans un thread obéissant à "se produit avant" et "ordre de synchronisation" quand toutes les lectures peuvent voir l'écriture sur la même variable.

Comme vous pouvez déjà le constater, les définitions du JMM-JSR133 ont été renforcées pour ne pas laisser les valeurs OoTA s'immiscer. Le JMM9 tente d'identifier et corriger la définition pour qu'elle autorise certaines optimisations courantes.

JMM9 - Effet des actions volatiles sur les variables non volatiles

D'abord, qu'est ce que le mot clé volatile ? Le mot clé volatile en Java garantit que quand un thread écrit dans une variable volatile, cette écriture est non seulement visible par les autres threads, mais aussi que les autres threads verront toutes les écritures visibles par le thread qui à écrit dans la variable volatile.

Note : Déclarer un champ volatile ne signifie pas que des locks soient utilisés. Donc les volatiles sont moins coûteux que la synchronisation utilisant des locks. Mais il est important de noter qu'avoir plusieurs champs volatiles dans vos méthodes peut les rendre plus coûteuses qu'un lock.

JMM9 - Le problème des lectures et écritures atomiques et le problème du Word-Tearing

Le JMM-JSR133 garantit aussi (avec des exceptions) l'atomicité des lectures et écritures pour les algorithmes concurrents utilisant de la mémoire partagée. Les exceptions sont pour les longs et doubles non volatiles pour lesquels une écriture peut être traitée comme deux écritures séparées. Une valeur 64 bits peut donc être modifiée par deux écritures 32 bits et un thread qui lirait alors qu'une de ces écritures est encore en cours pourrait observer seulement la moitié de la valeur correcte, perdant l'atomicité. C'est un exemple de la manière dont l'atomicité repose sur le support par le matériel sous-jacent ainsi que sur le sous-système mémoire. Les instructions assembleur doivent être capables de gérer la taille des opérandes afin de garantir l'atomicité, sinon si une opération de lecture ou d'écriture doit être séparée en plus d'une opération, on perd l'atomicité (comme c'est le cas pour les longs et doubles). De façon semblable, si l'implémentation déclenche plus d'une transaction avec le sous-système mémoire, l'atomicité est rompue.

Note : Les champs longs, doubles et les références volatiles ont la garantie d'être écrits atomiquement.

Favoriser une taille de pointeur en particulier n'est pas une solution idéale puisque si l'exception pour 64 bits est supprimée, les architectures 32 bits vont en souffrir. Si les architectures 64 bits sont pénalisées, alors vous devez introduire des volatiles pour les longs et doubles quand l'atomicité est désirée même si le matériel sous-jacent garantit l'atomicité. Par exemple, volatile n'est pas nécessaire avec un champ double quand l'architecture, l'ISA ou l'unité de calculs flottants prennent en charge les valeurs 64 bits. Un des objectifs du JMM9 est d'identifier les garanties d'atomicité provenant du matériel.

Le JMM-JSR133 a été écrit il y a plus d'une décennie, la taille des pointeurs supportés par les processeurs a évolué et le 64 bits est devenu le plus courant. Cela a immédiatement mis en avant le compromis que le JMM-JSR133 a fait sur les opérations 64 bits, même si elles peuvent être rendues atomiques sur n'importe quelle architecture, il y a toujours besoin de prendre un lock sur certaines. Donc, les lectures et écritures 64 bits sont coûteuses sur ces architectures. Si une implémentation raisonnable d'opérations atomiques sur les valeurs 64 bits pour les architectures x86 32 bits ne peut pas être trouvée, alors l'atomicité ne peut être changée.

Note : Il y a un problème dans le design du langage, le mot clé volatile possède plusieurs sens. Il est difficile pour le runtime de déterminer si le mot clé volatile a été ajouté pour regagner l'atomicité (et donc s'il peut être ignoré sur les plateformes 64 bits) ou pour des raisons d'ordre des opérations mémoire.

Quand on parle d'atomicité des accès, l'indépendance des opérations de lecture et d'écriture est une considération importante. Une écriture dans un champ particulier ne devrait pas interagir avec une lecture ou écriture dans n'importe quel autre champ. Cette garantie du JMM-JSR133 signifie que la synchronisation ne devrait pas être nécessaire pour obtenir la cohérence séquentielle. Le JMM-JSR133 interdit donc un problème connu sous le nom de "word-tearing". Un point important à retenir est que ce problème est une des raisons pour lesquelles les longs et doubles ne peuvent avoir de garantie d'atomicité. Le word-tearing est interdit dans le JMM-JSR133 et continuera à l'être dans le JMM9.

JMM9 - Le problème des champs final

Les champs final sont différents des champs normaux. Par exemple, un thread lisant un objet "complètement initialisé" avec un champ final 'x' après que l'objet soit complètement initialisé à la garantie de lire le champ final initialisé à la valeur 'y'. La même garantie ne s'applique pas à un champ normal.

Note : "Complètement initialisé" signifie que le constructeur se termine.

A la lumière de ce qui précède, il y a des choses simples qui peuvent être corrigées dans le JMM9. Par exemple : les champs volatiles - un champ volatile initialisé dans le constructeur n'est pas garanti d'être visible même si l'instance elle-même est visible. Une question arrive donc : est-ce que les garanties des champs final devraient être étendues aux champs volatiles ? De plus, si la valeur d'un champ "normal" non final d'un objet complètement initialisé ne change pas, peut-on étendre la garantie des champs final à ce champ normal ?

Bibliographie

J'ai appris beaucoup de ces sites et ils fournissent aussi des exemples de code. Mon article devrait être considéré comme une introduction et les suivants sont plus adaptés pour comprendre plus en détail le Modèle Mémoire Java.

  1. JSR 133: JavaTM Memory Model and Thread Specification Revision
  2. The Java Memory Model 
  3. JAVA CONCURRENCY (&C) 
  4. The jmm-dev Archives 
  5. Threads and Locks 
  6. Synchronization and the Java Memory Model
  7. All Accesses Are Atomic
  8. Java Memory Model Pragmatics (transcript)
  9. Memory Barriers: a Hardware View for Software Hackers

Remerciements

Je voudrais remercier Jeremy Manson pour m'avoir aidé à corriger quelques incompréhensions et pour m'avoir donné des définitions claires de termes qui étaient nouveaux pour moi. Je voudrais aussi remercier Aleksey Shipilev pour m'avoir aidé à réduire les complexités des concepts des versions de travail de cet article. Aleksey a aussi publié son article JMM-pragmatics qui nous a donné une compréhension plus profonde, ainsi que des clarifications et des exemples.

A propos de l'Auteure

Monica Beckwith est Consultante en Performance Java. Ses expériences passées incluent la collaboration avec Oracle / Sun et AMD et l'optimisation de la JVM pour les systèmes de classe serveur. Monica a été élue JavaOne Rock Star 2013 et a été la Performance Lead pour les Ramasse-Miettes Garbage First (G1 GC). Vous pouvez suivre Monica sur Twitter mon_beck.

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT