Après avoir lu The JSR-133 Cookbook for Compiler Writers about the implementation of volatile, particulièrement la section "Interactions with Atomic Instructions", je suppose que la lecture d'une variable volatile sans la mettre à jour nécessite une barrière LoadLoad ou LoadStore. Plus bas sur la page, je vois que LoadLoad et LoadStore sont effectivement sans opération sur les processeurs X86. Est-ce à dire que les opérations de lecture volatile peuvent être effectuées sans invalidation de cache explicite sur x86, et sont aussi rapides qu'une lecture de variable normale (sans tenir compte des contraintes de réorganisation de volatile)?
Je crois que je ne comprends pas cela correctement. Quelqu'un pourrait-il vouloir m'éclairer?
EDIT: Je me demande s'il y a des différences dans les environnements multiprocesseurs. Sur les systèmes à processeur unique, le processeur peut regarder ses propres caches de threads, comme le dit John V., mais sur les systèmes à plusieurs processeurs, il doit y avoir une option de configuration pour les processeurs selon laquelle cela ne suffit pas et la mémoire principale doit être atteinte, ce qui rend la volatilité plus lente sur les systèmes multi cpu, non?
PS: Sur mon chemin pour en savoir plus à ce sujet, je suis tombé sur les excellents articles suivants, et puisque cette question peut être intéressante pour d'autres, je vais partager mes liens ici:
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 Java 7 à imprimer du code d'assemblage, la méthode d'exécution ressemble à ceci:
# {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 une charge 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 (la longueur est 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 une charge 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),%xmm0
faisant de l'opération de lecture une instruction unique (dans 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 de mémoire et peut être aussi bon marché qu'un accès au cache L1. Cependant, si un autre noyau é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.
De manière générale, sur la plupart des processeurs modernes, une charge volatile est comparable à une charge normale. Un magasin volatile représente environ 1/3 du temps d'une entrée/sortie de moniteur. Cela se voit sur les systèmes qui sont cohérents en 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.
Est-ce à dire que les opérations de lecture volatile peuvent être effectuées sans invalidation de cache explicite sur x86, et sont aussi rapides 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 d'en obtenir la valeur (explication très générale).
Cependant, j'appuie la suggestion de Neil que 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 CPU 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'avait, ce processeur supposera qu'il s'agit de la valeur la plus récente. Si un autre processeur essaie de charger le même emplacement de mémoire, le processeur déjà chargé en sera conscient et partagera réellement la référence mise en cache avec le processeur demandeur - maintenant, le processeur de demande a une copie de cette mémoire dans son cache de processeur. (Il n'a jamais eu à chercher dans la mémoire principale pour la référence)
Il y a un peu plus de protocole impliqué mais cela donne une idée de ce qui se passe. Pour répondre également à votre autre question, en l'absence de plusieurs processeurs, les lectures/écritures volatiles peuvent en fait être plus rapides qu'avec plusieurs processeurs. Il existe certaines applications qui s'exécuteraient en fait plus rapidement simultanément avec un seul processeur puis plusieurs.
Selon les termes du modèle de mémoire Java (tel que défini pour Java 5+ dans JSR 133), toute opération - en lecture ou en écriture - sur un volatile
crée une relation se produit avant par rapport à toute autre opération sur la même variable. Cela signifie que le compilateur et JIT sont contraints 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 beaucoup.
Néanmoins, vous ne devez pas créer une variable volatile
sauf si vous savez qu'elle sera accessible à partir de plusieurs threads en dehors des blocs synchronized
. Même alors, vous devez vous demander si volatile est le meilleur choix par rapport à synchronized
, AtomicReference
et ses amis, les classes explicites de Lock
, etc.
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.