La volatilité est-elle chère?


111

Après avoir lu The JSR-133 Cookbook for Compiler Writers sur l'implémentation de volatile, en particulier la section "Interactions avec les instructions atomiques", je suppose que lire une variable volatile sans la mettre à jour nécessite un LoadLoad ou une barrière LoadStore. Plus bas dans la page, je vois que LoadLoad et LoadStore sont effectivement des no-ops sur les processeurs X86. Cela signifie-t-il que les opérations de lecture volatile peuvent être effectuées sans invalidation de cache explicite sur x86, et est aussi rapide qu'une lecture de variable normale (sans tenir compte des contraintes de réorganisation de volatile)?

Je crois que je ne comprends pas bien cela. Quelqu'un pourrait-il vouloir m'éclairer?

EDIT: Je me demande s'il existe des différences dans les environnements multiprocesseurs. Sur les systèmes à processeur unique, le processeur peut regarder ses propres caches de threads, comme le déclare John V., mais sur les systèmes à plusieurs processeurs, il doit y avoir une option de configuration pour les processeurs qui ne suffit pas et que la mémoire principale doit être touchée, ce qui ralentit la volatilité. sur les systèmes multi cpu, non?

PS: En chemin pour en savoir plus à ce sujet, je suis tombé sur les excellents articles suivants, et comme cette question peut intéresser d'autres personnes, je vais partager mes liens ici:


1
Vous pouvez lire ma modification sur la configuration avec plusieurs processeurs auxquels vous faites référence. Il peut arriver que sur des systèmes multi-processeurs pour une référence de courte durée, il n'y ait plus qu'une seule lecture / écriture dans la mémoire principale.
John Vint

2
la lecture volatile elle-même n'est pas chère. le principal coût est de savoir comment il empêche les optimisations. en pratique, ce coût en moyenne n'est pas non plus très élevé, à moins que la volatilité ne soit utilisée en boucle serrée.
irréprochable

2
Cet article sur infoq ( infoq.com/articles/memory_barriers_jvm_concurrency ) pourrait également vous intéresser, il montre les effets de volatile et synchronisé sur le code généré pour différentes architectures. C'est aussi un cas où le jvm peut fonctionner mieux qu'un compilateur à l'avance, car il sait s'il fonctionne sur un système monoprocesseur et peut omettre certaines barrières de mémoire.
Jörn Horstmann

Réponses:


123

Sur Intel, une lecture volatile non contestée est assez bon marché. Si nous considérons le cas simple suivant:

public static long l;

public static void run() {        
    if (l == -1)
        System.exit(-1);

    if (l == -2)
        System.exit(-1);
}

En utilisant la capacité de Java 7 à imprimer le code d'assemblage, la méthode d'exécution ressemble à quelque chose comme:

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb396ce80: mov    %eax,-0x3000(%esp)
0xb396ce87: push   %ebp
0xb396ce88: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 33)
0xb396ce8e: mov    $0xffffffff,%ecx
0xb396ce93: mov    $0xffffffff,%ebx
0xb396ce98: mov    $0x6fa2b2f0,%esi   ;   {oop('Test2')}
0xb396ce9d: mov    0x150(%esi),%ebp
0xb396cea3: mov    0x154(%esi),%edi   ;*getstatic l
                                    ; - Test2::run@0 (line 33)
0xb396cea9: cmp    %ecx,%ebp
0xb396ceab: jne    0xb396ceaf
0xb396cead: cmp    %ebx,%edi
0xb396ceaf: je     0xb396cece         ;*getstatic l
                                    ; - Test2::run@14 (line 37)
0xb396ceb1: mov    $0xfffffffe,%ecx
0xb396ceb6: mov    $0xffffffff,%ebx
0xb396cebb: cmp    %ecx,%ebp
0xb396cebd: jne    0xb396cec1
0xb396cebf: cmp    %ebx,%edi
0xb396cec1: je     0xb396ceeb         ;*return
                                    ; - Test2::run@28 (line 40)
0xb396cec3: add    $0x8,%esp
0xb396cec6: pop    %ebp
0xb396cec7: test   %eax,0xb7732000    ;   {poll_return}
;... lines removed

Si vous regardez les 2 références à getstatic, la première implique un chargement de la mémoire, la seconde ignore la charge car la valeur est réutilisée à partir du ou des registres dans lesquels elle est déjà chargée (longue est de 64 bits et sur mon ordinateur portable 32 bits il utilise 2 registres).

Si nous rendons la variable l volatile, l'assemblage résultant est différent.

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb3ab9340: mov    %eax,-0x3000(%esp)
0xb3ab9347: push   %ebp
0xb3ab9348: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 32)
0xb3ab934e: mov    $0xffffffff,%ecx
0xb3ab9353: mov    $0xffffffff,%ebx
0xb3ab9358: mov    $0x150,%ebp
0xb3ab935d: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab9365: movd   %xmm0,%eax
0xb3ab9369: psrlq  $0x20,%xmm0
0xb3ab936e: movd   %xmm0,%edx         ;*getstatic l
                                    ; - Test2::run@0 (line 32)
0xb3ab9372: cmp    %ecx,%eax
0xb3ab9374: jne    0xb3ab9378
0xb3ab9376: cmp    %ebx,%edx
0xb3ab9378: je     0xb3ab93ac
0xb3ab937a: mov    $0xfffffffe,%ecx
0xb3ab937f: mov    $0xffffffff,%ebx
0xb3ab9384: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab938c: movd   %xmm0,%ebp
0xb3ab9390: psrlq  $0x20,%xmm0
0xb3ab9395: movd   %xmm0,%edi         ;*getstatic l
                                    ; - Test2::run@14 (line 36)
0xb3ab9399: cmp    %ecx,%ebp
0xb3ab939b: jne    0xb3ab939f
0xb3ab939d: cmp    %ebx,%edi
0xb3ab939f: je     0xb3ab93ba         ;*return
;... lines removed

Dans ce cas, les deux références getstatic à la variable l impliquent un chargement de la mémoire, c'est-à-dire que la valeur ne peut pas être conservée dans un registre sur plusieurs lectures volatiles. Pour s'assurer qu'il y a une lecture atomique, la valeur est lue de la mémoire principale dans un registre MMX movsd 0x6fb7b2f0(%ebp),%xmm0faisant de l'opération de lecture une seule instruction (de l'exemple précédent, nous avons vu que la valeur 64 bits nécessiterait normalement deux lectures 32 bits sur un système 32 bits).

Ainsi, le coût global d'une lecture volatile sera à peu près équivalent à une charge mémoire et peut être aussi bon marché qu'un accès au cache L1. Cependant, si un autre cœur écrit dans la variable volatile, la ligne de cache sera invalidée, nécessitant une mémoire principale ou peut-être un accès au cache L3. Le coût réel dépendra fortement de l'architecture du processeur. Même entre Intel et AMD, les protocoles de cohérence du cache sont différents.


note latérale, java 6 a la même capacité à afficher l'assemblage (c'est le hotspot qui le fait)
bestsss

+1 Dans JDK5, volatile ne peut pas être réorganisé par rapport à toute lecture / écriture (ce qui corrige le verrouillage de double-vérification, par exemple). Cela signifie-t-il que cela affectera également la manière dont les champs non volatils sont manipulés? Il serait intéressant de mélanger l'accès aux champs volatils et non volatils.
ewernli

@evemli, vous devez faire attention, j'ai fait cette déclaration moi-même une fois, mais elle a été jugée incorrecte. Il y a un cas de pointe. Le modèle de mémoire Java permet la sémantique des motels de gardons, lorsque les magasins peuvent être réorganisés avant les magasins volatils. Si vous avez repris cela dans l'article de Brian Goetz sur le site IBM, il convient de mentionner que cet article simplifie à l'excès la spécification JMM.
Michael Barker

20

D'une manière générale, sur la plupart des processeurs modernes, une charge volatile est comparable à une charge normale. Un magasin volatil représente environ 1/3 du temps d'une entrée / sortie de surveillance. Cela se voit sur les systèmes qui sont cohérents avec le cache.

Pour répondre à la question du PO, les écritures volatiles coûtent cher alors que les lectures ne le sont généralement pas.

Cela signifie-t-il que les opérations de lecture volatile peuvent être effectuées sans invalidation de cache explicite sur x86, et est aussi rapide qu'une lecture de variable normale (sans tenir compte des contraintes de réorganisation de volatile)?

Oui, parfois lors de la validation d'un champ, le CPU peut même ne pas atteindre la mémoire principale, au lieu d'espionner d'autres caches de threads et obtenir la valeur à partir de là (explication très générale).

Cependant, j'appuie la suggestion de Neil selon laquelle si vous avez un champ accessible par plusieurs threads, vous devez l'envelopper comme une référence atomique. Étant un AtomicReference, il exécute à peu près le même débit pour les lectures / écritures, mais il est également plus évident que le champ sera accessible et modifié par plusieurs threads.

Modifier pour répondre à la modification de OP:

La cohérence du cache est un peu un protocole compliqué, mais en bref: les processeurs partageront une ligne de cache commune qui est attachée à la mémoire principale. Si un processeur charge de la mémoire et qu'aucun autre processeur ne l'a eu, ce processeur supposera qu'il s'agit de la valeur la plus à jour. Si un autre CPU essaie de charger le même emplacement mémoire, le CPU déjà chargé en sera conscient et partagera en fait la référence mise en cache avec le CPU demandeur - maintenant le CPU de demande a une copie de cette mémoire dans son cache CPU. (Il n'a jamais eu à chercher dans la mémoire principale la référence)

Il y a un peu plus de protocole impliqué mais cela donne une idée de ce qui se passe. Aussi pour répondre à votre autre question, en l'absence de plusieurs processeurs, les lectures / écritures volatiles peuvent en fait être plus rapides qu'avec plusieurs processeurs. Certaines applications fonctionneraient en fait plus rapidement simultanément avec un seul processeur puis plusieurs.


5
Un AtomicReference est juste un wrapper pour un champ volatile avec des fonctions natives ajoutées fournissant des fonctionnalités supplémentaires telles que getAndSet, compareAndSet etc. Mais je me demande pourquoi vous faites référence au système d'exploitation ici? La fonctionnalité est implémentée directement dans les opcodes CPU. Et cela implique-t-il que sur les systèmes multiprocesseurs, où un processeur n'a aucune connaissance du contenu du cache des autres processeurs, les volatiles sont plus lents parce que les processeurs doivent toujours atteindre la mémoire principale?
Daniel

Vous avez raison, j'ai manqué de parler du système d'exploitation qui aurait dû écrire le processeur, en le corrigeant maintenant. Et oui, je sais qu'AtomicReference est simplement un wrapper pour les champs volatils, mais il ajoute également comme une sorte de documentation que le champ lui-même sera accessible par plusieurs threads.
John Vint

@John, pourquoi ajouterais-tu une autre indirection via une référence atomique? Si vous avez besoin de CAS - ok, mais AtomicUpdater pourrait être une meilleure option. Autant que je me souvienne, il n'y a pas d'intrinsèques sur AtomicReference.
bestsss

@bestsss Pour toutes les raisons générales, vous avez raison, il n'y a aucune différence entre AtomicReference.set / get et volatile load and stores. Cela étant dit, j'avais le même sentiment (et je le fais dans une certaine mesure) sur le moment de l'utiliser. Cette réponse peut la détailler un peu stackoverflow.com/questions/3964317/… . Utiliser l'un ou l'autre est plus une préférence, mon seul argument pour utiliser AtomicReference sur un simple volatile est pour une documentation claire - qui en soi ne fait pas le plus grand argument non plus je comprends
John Vint

Sur une note latérale, certains soutiennent que l'utilisation d'un champ volatil / AtomicReference (sans avoir besoin d'un CAS) conduit à un code buggy old.nabble.com
John Vint

12

Selon les termes du modèle de mémoire Java (tel que défini pour Java 5+ dans JSR 133), toute opération - lecture ou écriture - sur une volatilevariable crée une relation d' avance par rapport à toute autre opération sur la même variable. Cela signifie que le compilateur et JIT sont obligés d'éviter certaines optimisations telles que la réorganisation des instructions dans le thread ou l'exécution d'opérations uniquement dans le cache local.

Comme certaines optimisations ne sont pas disponibles, le code résultant est nécessairement plus lent qu'il l'aurait été, mais probablement pas de beaucoup.

Néanmoins, vous ne devriez pas créer une variable à volatilemoins que vous ne sachiez qu'elle sera accessible à partir de plusieurs threads en dehors des synchronizedblocs. Même dans ce cas, vous devriez vous demander si volatile est le meilleur choix par rapport à synchronized, AtomicReferenceet ses amis, les Lockclasses explicites , etc.


4

L'accès à une variable volatile est à bien des égards similaire à l'encapsulation de l'accès à une variable ordinaire dans un bloc synchronisé. Par exemple, l'accès à une variable volatile empêche le CPU de réorganiser les instructions avant et après l'accès, ce qui ralentit généralement l'exécution (bien que je ne puisse pas dire de combien).

Plus généralement, sur un système multiprocesseur, je ne vois pas comment l'accès à une variable volatile peut se faire sans pénalité - il doit y avoir un moyen de garantir qu'une écriture sur le processeur A sera synchronisée avec une lecture sur le processeur B.


4
La lecture de variables volatiles a la même pénalité que de faire une entrée de moniteur, en ce qui concerne les possibilités de réordonnancement des instructions, alors que l'écriture d'une variable volatile équivaut à une sortie de moniteur. Une différence peut être les variables (par exemple, les caches de processeur) qui sont vidées ou invalidées. Alors que synchronisé vide ou invalide tout, l'accès à la variable volatile doit toujours ignorer le cache.
Daniel

12
-1, l'accès à une variable volatile est un peu différent de l'utilisation d'un bloc synchronisé. La saisie d'un bloc synchronisé nécessite une écriture basée sur compareAndSet atomique pour supprimer le verrou et une écriture volatile pour le libérer. Si le verrou est satisfait, alors le contrôle doit passer de l'espace utilisateur à l'espace noyau pour arbitrer le verrou (c'est le bit coûteux). L'accès à un volatile restera toujours dans l'espace utilisateur.
Michael Barker

@MichaelBarker: Êtes-vous sûr que tous les moniteurs doivent être gardés par le noyau et non par l'application?
Daniel le

@Daniel: Si vous représentez un moniteur utilisant un bloc synchronisé ou un verrou alors oui, mais seulement si le moniteur est content. La seule façon de faire cela sans l'arbitrage du noyau est d'utiliser la même logique, mais une rotation occupée au lieu de garer le thread.
Michael Barker

@MichaelBarker: Okey, pour les verrous satisfaits, je comprends cela.
Daniel
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.