Verrouillage vérifié: intelligent, mais cassé

Des éléments très appréciés du style Java aux pages de JavaWorld (voir Java Astuce 67), de nombreux gourous bien intentionnés de Java encouragent l'utilisation de l'idiome de verrouillage à double vérification (DCL). Il n'y a qu'un seul problème avec cela - cet idiome d'apparence intelligente peut ne pas fonctionner.

Le verrouillage revérifié peut être dangereux pour votre code!

Cette semaine, JavaWorld se concentre sur les dangers de l'idiome de verrouillage revérifié. En savoir plus sur la façon dont ce raccourci apparemment inoffensif peut faire des ravages sur votre code:
  • "Attention! Threading dans un monde multiprocesseur", Allen Holub
  • Verrouillage revérifié: intelligent, mais cassé, "Brian Goetz
  • Pour en savoir plus sur le verrouillage revérifié, consultez la discussion sur la théorie et la pratique de la programmation d' Allen Holub

Qu'est-ce que DCL?

L'idiome DCL a été conçu pour prendre en charge l'initialisation tardive, qui se produit lorsqu'une classe diffère l'initialisation d'un objet possédé jusqu'à ce qu'elle soit réellement nécessaire:

class SomeClass {ressource de ressource privée = null; ressource publique getResource () {if (resource == null) resource = new Resource (); ressource de retour; }}

Pourquoi voudriez-vous différer l'initialisation? La création d'un Resourceest peut-être une opération coûteuse, et les utilisateurs de SomeClasspeuvent ne pas appeler getResource()dans une exécution donnée. Dans ce cas, vous pouvez éviter de créer Resourceentièrement le fichier. Quoi qu'il en soit, l' SomeClassobjet peut être créé plus rapidement s'il n'a pas besoin de créer également un Resourceau moment de la construction. Retarder certaines opérations d'initialisation jusqu'à ce qu'un utilisateur ait réellement besoin de ses résultats peut aider les programmes à démarrer plus rapidement.

Que faire si vous essayez d'utiliser SomeClassdans une application multithread? Il en résulte une condition de concurrence: deux threads peuvent exécuter simultanément le test pour voir si resourceest nul et, par conséquent, s'initialiser resourcedeux fois. Dans un environnement multithread, vous devez déclarer getResource()être synchronized.

Malheureusement, les méthodes synchronisées fonctionnent beaucoup plus lentement - jusqu'à 100 fois plus lentement - que les méthodes non synchronisées ordinaires. L'une des motivations de l'initialisation paresseuse est l'efficacité, mais il semble que pour accélérer le démarrage du programme, vous devez accepter un temps d'exécution plus lent une fois le programme démarré. Cela ne semble pas être un excellent compromis.

DCL prétend nous offrir le meilleur des deux mondes. En utilisant DCL, la getResource()méthode ressemblerait à ceci:

class SomeClass {ressource de ressource privée = null; ressource publique getResource () {if (resource == null) {synchronized {if (resource == null) resource = new Resource (); }} ressource de retour; }}

Après le premier appel à getResource(), resourceest déjà initialisé, ce qui évite le coup de synchronisation dans le chemin de code le plus courant. DCL évite également la condition de concurrence en vérifiant resourceune seconde fois à l'intérieur du bloc synchronisé; cela garantit qu'un seul thread tentera de s'initialiser resource. DCL semble être une optimisation intelligente - mais cela ne fonctionne pas.

Découvrez le modèle de mémoire Java

Plus précisément, DCL n'est pas garanti de fonctionner. Pour comprendre pourquoi, nous devons examiner la relation entre la JVM et l'environnement informatique sur lequel elle s'exécute. En particulier, nous devons examiner le modèle de mémoire Java (JMM), défini au chapitre 17 de la spécification du langage Java , par Bill Joy, Guy Steele, James Gosling et Gilad Bracha (Addison-Wesley, 2000), qui détaille comment Java gère l'interaction entre les threads et la mémoire.

Contrairement à la plupart des autres langages, Java définit sa relation avec le matériel sous-jacent via un modèle de mémoire formel qui devrait tenir sur toutes les plates-formes Java, ce qui permet à Java de promettre «Écrire une fois, exécuter n'importe où». En comparaison, d'autres langages comme C et C ++ n'ont pas de modèle de mémoire formel; dans ces langages, les programmes héritent du modèle de mémoire de la plate-forme matérielle sur laquelle le programme s'exécute.

Lorsqu'il est exécuté dans un environnement synchrone (monothread), l'interaction d'un programme avec la mémoire est assez simple, ou du moins elle le semble. Les programmes stockent les éléments dans des emplacements de mémoire et s'attendent à ce qu'ils soient toujours là la prochaine fois que ces emplacements de mémoire seront examinés.

En fait, la vérité est assez différente, mais une illusion compliquée maintenue par le compilateur, la JVM et le matériel nous la cache. Bien que nous considérions les programmes comme s'exécutant séquentiellement - dans l'ordre spécifié par le code du programme - cela ne se produit pas toujours. Les compilateurs, processeurs et caches sont libres de prendre toutes sortes de libertés avec nos programmes et données, tant qu'ils n'affectent pas le résultat du calcul. Par exemple, les compilateurs peuvent générer des instructions dans un ordre différent de l'interprétation évidente suggérée par le programme et stocker des variables dans des registres au lieu de la mémoire; les processeurs peuvent exécuter des instructions en parallèle ou dans le désordre; et les caches peuvent varier l'ordre dans lequel les écritures sont validées dans la mémoire principale. Le JMM déclare que toutes ces réorganisations et optimisations sont acceptables,tant que l'environnement maintientsémantique en tant que série , c'est-à-dire tant que vous obtenez le même résultat que si les instructions étaient exécutées dans un environnement strictement séquentiel.

Les compilateurs, les processeurs et les caches réorganisent la séquence des opérations du programme afin d'améliorer les performances. Ces dernières années, nous avons constaté d'énormes améliorations dans les performances informatiques. Alors que l'augmentation des fréquences d'horloge du processeur a considérablement contribué à des performances plus élevées, un parallélisme accru (sous la forme d'unités d'exécution en pipeline et superscalaires, de la planification des instructions dynamiques et de l'exécution spéculative, et des caches mémoire multiniveaux sophistiqués) a également été un contributeur majeur. En même temps, la tâche d'écrire des compilateurs est devenue beaucoup plus compliquée, car le compilateur doit protéger le programmeur de ces complexités.

Lors de l'écriture de programmes à thread unique, vous ne pouvez pas voir les effets de ces diverses instructions ou réorganisations d'opérations de mémoire. Cependant, avec les programmes multithread, la situation est assez différente: un thread peut lire les emplacements mémoire qu'un autre thread a écrits. Si le thread A modifie certaines variables dans un certain ordre, en l'absence de synchronisation, le thread B peut ne pas les voir dans le même ordre - ou ne pas les voir du tout, d'ailleurs. Cela pourrait résulter du fait que le compilateur a réorganisé les instructions ou stocké temporairement une variable dans un registre et l'a écrite en mémoire plus tard; ou parce que le processeur a exécuté les instructions en parallèle ou dans un ordre différent de celui spécifié par le compilateur; ou parce que les instructions se trouvaient dans différentes régions de la mémoire,et l'antémémoire a mis à jour les emplacements de mémoire principale correspondants dans un ordre différent de celui dans lequel ils ont été écrits. Quelles que soient les circonstances, les programmes multithreads sont intrinsèquement moins prévisibles, sauf si vous vous assurez explicitement que les threads ont une vue cohérente de la mémoire en utilisant la synchronisation.

Que signifie vraiment synchronisé?

Java traite chaque thread comme s'il s'exécutait sur son propre processeur avec sa propre mémoire locale, chacun parlant et se synchronisant avec une mémoire principale partagée. Même sur un système à processeur unique, ce modèle a du sens en raison des effets des caches de mémoire et de l'utilisation de registres de processeur pour stocker des variables. Lorsqu'un thread modifie un emplacement dans sa mémoire locale, cette modification doit éventuellement apparaître également dans la mémoire principale, et le JMM définit les règles pour lesquelles la JVM doit transférer des données entre la mémoire locale et la mémoire principale. Les architectes Java ont réalisé qu'un modèle de mémoire trop restrictif nuirait gravement aux performances du programme. Ils ont tenté de créer un modèle de mémoire qui permettrait aux programmes de bien fonctionner sur le matériel informatique moderne tout en offrant des garanties qui permettraient aux threads d'interagir de manière prévisible.

Le principal outil de Java pour rendre les interactions entre les threads de manière prévisible est le synchronizedmot - clé. De nombreux programmeurs pensent synchronizedstrictement à l'application d'un sémaphore d'exclusion mutuelle ( mutex ) pour empêcher l'exécution de sections critiques par plus d'un thread à la fois. Malheureusement, cette intuition ne décrit pas complètement ce que synchronizedsignifie.

La sémantique de synchronizedinclut en effet une exclusion mutuelle de l'exécution basée sur l'état d'un sémaphore, mais elle inclut également des règles sur l'interaction du thread de synchronisation avec la mémoire principale. En particulier, l'acquisition ou la libération d'un verrou déclenche une barrière mémoire - une synchronisation forcée entre la mémoire locale du thread et la mémoire principale. (Certains processeurs - comme l'Alpha - ont des instructions machine explicites pour effectuer des barrières de mémoire.) Lorsqu'un thread quitte un synchronizedbloc, il effectue une barrière d'écriture - il doit vider toutes les variables modifiées dans ce bloc vers la mémoire principale avant de libérer le fermer à clé. De même, lors de la saisie d'unsynchronized block, il effectue une barrière de lecture - c'est comme si la mémoire locale avait été invalidée, et il doit récupérer toutes les variables qui seront référencées dans le bloc de la mémoire principale.

L'utilisation appropriée de la synchronisation garantit qu'un thread verra les effets d'un autre de manière prévisible. Ce n'est que lorsque les threads A et B se synchronisent sur le même objet que le JMM garantit que le thread B voit les modifications apportées par le thread A et que les modifications apportées par le thread A à l'intérieur du synchronizedbloc apparaissent de manière atomique au thread B (soit le bloc entier s'exécute, soit aucun des C'est le cas.) De plus, le JMM garantit que les synchronizedblocs qui se synchronisent sur le même objet sembleront s'exécuter dans le même ordre que dans le programme.

Alors, qu'est-ce qui ne va pas avec DCL?

DCL relies on an unsynchronized use of the resource field. That appears to be harmless, but it is not. To see why, imagine that thread A is inside the synchronized block, executing the statement resource = new Resource(); while thread B is just entering getResource(). Consider the effect on memory of this initialization. Memory for the new Resource object will be allocated; the constructor for Resource will be called, initializing the member fields of the new object; and the field resource of SomeClass will be assigned a reference to the newly created object.

However, since thread B is not executing inside a synchronized block, it may see these memory operations in a different order than the one thread A executes. It could be the case that B sees these events in the following order (and the compiler is also free to reorder the instructions like this): allocate memory, assign reference to resource, call constructor. Suppose thread B comes along after the memory has been allocated and the resource field is set, but before the constructor is called. It sees that resource is not null, skips the synchronized block, and returns a reference to a partially constructed Resource! Needless to say, the result is neither expected nor desired.

When presented with this example, many people are skeptical at first. Many highly intelligent programmers have tried to fix DCL so that it does work, but none of these supposedly fixed versions work either. It should be noted that DCL might, in fact, work on some versions of some JVMs -- as few JVMs actually implement the JMM properly. However, you don't want the correctness of your programs to rely on implementation details -- especially errors -- specific to the particular version of the particular JVM you use.

Other concurrency hazards are embedded in DCL -- and in any unsynchronized reference to memory written by another thread, even harmless-looking reads. Suppose thread A has completed initializing the Resource and exits the synchronized block as thread B enters getResource(). Now the Resource is fully initialized, and thread A flushes its local memory out to main memory. The resource's fields may reference other objects stored in memory through its member fields, which will also be flushed out. While thread B may see a valid reference to the newly created Resource, because it didn't perform a read barrier, it could still see stale values of resource's member fields.

Volatile doesn't mean what you think, either

A commonly suggested nonfix is to declare the resource field of SomeClass as volatile. However, while the JMM prevents writes to volatile variables from being reordered with respect to one another and ensures that they are flushed to main memory immediately, it still permits reads and writes of volatile variables to be reordered with respect to nonvolatile reads and writes. That means -- unless all Resource fields are volatile as well -- thread B can still perceive the constructor's effect as happening after resource is set to reference the newly created Resource.

Alternatives to DCL

Le moyen le plus efficace de corriger l'idiome DCL est de l'éviter. Le moyen le plus simple de l'éviter, bien sûr, est d'utiliser la synchronisation. Chaque fois qu'une variable écrite par un thread est lue par un autre, vous devez utiliser la synchronisation pour garantir que les modifications sont visibles par les autres threads de manière prévisible.

Une autre option pour éviter les problèmes avec DCL est de supprimer l'initialisation différée et d'utiliser à la place une initialisation hâtive . Plutôt que de retarder l'initialisation de resourcejusqu'à sa première utilisation, initialisez-le à la construction. Le chargeur de classe, qui se synchronise sur l' Classobjet des classes , exécute des blocs d'initialisation statiques au moment de l'initialisation de la classe. Cela signifie que l'effet des initialiseurs statiques est automatiquement visible par tous les threads dès que la classe se charge.