¿Es caro lo volátil?

111

Después de leer The JSR-133 Cookbook for Compiler Writers sobre la implementación de volátiles, especialmente la sección "Interacciones con instrucciones atómicas", asumo que leer una variable volátil sin actualizarla necesita una barrera LoadLoad o LoadStore. Más abajo en la página, veo que LoadLoad y LoadStore son efectivamente no operativos en las CPU X86. ¿Significa esto que las operaciones de lectura volátil se pueden realizar sin una invalidación de caché explícita en x86, y es tan rápido como una lectura de variable normal (sin tener en cuenta las restricciones de reordenación de volátil)?

Creo que no entiendo esto correctamente. ¿A alguien le importaría iluminarme?

EDITAR: Me pregunto si hay diferencias en entornos multiprocesador. En los sistemas de CPU única, la CPU puede mirar sus propios cachés de subprocesos, como dice John V., pero en los sistemas de CPU múltiples debe haber alguna opción de configuración para las CPU de que esto no es suficiente y la memoria principal debe ser activada, lo que hace que la volatilidad sea más lenta en sistemas de múltiples CPU, ¿verdad?

PD: En mi camino para aprender más sobre esto, tropecé con los siguientes artículos geniales, y dado que esta pregunta puede ser interesante para otros, compartiré mis enlaces aquí:

Daniel
fuente
1
Puede leer mi edición sobre la configuración con varias CPU a las que se refiere. Puede suceder que en sistemas de CPU múltiples para una referencia de corta duración, no se lleve a cabo más de una lectura / escritura en la memoria principal.
John Vint
2
la lectura volátil en sí no es cara. el principal costo es cómo evita las optimizaciones. en la práctica, el costo promedio tampoco es muy alto, a menos que se utilice volátil en un circuito cerrado.
irrefutable
2
Este artículo sobre infoq ( infoq.com/articles/memory_barriers_jvm_concurrency ) también puede interesarle, muestra los efectos de volatilidad y sincronización en el código generado para diferentes arquitecturas. Este es también un caso en el que jvm puede funcionar mejor que un compilador de antemano, ya que sabe si se está ejecutando en un sistema monoprocesador y puede omitir algunas barreras de memoria.
Jörn Horstmann

Respuestas:

123

En Intel, una lectura volátil no contenida es bastante barata. Si consideramos el siguiente caso simple:

public static long l;

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

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

Usando la capacidad de Java 7 para imprimir código ensamblador, el método de ejecución se parece a lo siguiente:

# {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 observa las 2 referencias a getstatic, la primera implica una carga de la memoria, la segunda omite la carga ya que el valor se reutiliza de los registros en los que ya está cargado (el largo es de 64 bits y en mi computadora portátil de 32 bits utiliza 2 registros).

Si hacemos volátil la variable l, el ensamblado resultante es diferente.

# {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

En este caso, las dos referencias getstatic a la variable l implican una carga desde la memoria, es decir, el valor no se puede mantener en un registro a través de múltiples lecturas volátiles. Para garantizar que haya una lectura atómica, el valor se lee desde la memoria principal en un registro MMX, lo que movsd 0x6fb7b2f0(%ebp),%xmm0hace que la operación de lectura sea una sola instrucción (del ejemplo anterior vimos que el valor de 64 bits normalmente requeriría dos lecturas de 32 bits en un sistema de 32 bits).

Por lo tanto, el costo total de una lectura volátil será aproximadamente equivalente a una carga de memoria y puede ser tan barato como un acceso a la caché L1. Sin embargo, si otro núcleo está escribiendo en la variable volátil, la línea de caché se invalidará, lo que requiere una memoria principal o quizás un acceso a la caché L3. El costo real dependerá en gran medida de la arquitectura de la CPU. Incluso entre Intel y AMD, los protocolos de coherencia de caché son diferentes.

Michael Barker
fuente
nota al margen, java 6 tiene la misma capacidad para mostrar ensamblado (es el punto de acceso que lo hace)
bestsss
+1 En JDK5 los volátiles no se pueden reordenar con respecto a ninguna lectura / escritura (lo que corrige el bloqueo de doble verificación, por ejemplo). ¿Eso implica que también afectará la forma en que se manipulan los campos no volátiles? Sería interesante combinar el acceso a campos volátiles y no volátiles.
Ewernli
@evemli, debes tener cuidado, yo mismo hice esta declaración una vez, pero se encontró que era incorrecta. Hay un caso de borde. El modelo de memoria de Java permite la semántica de motel de cucarachas, cuando las tiendas se pueden reordenar antes que las tiendas volátiles. Si recogió esto del artículo de Brian Goetz en el sitio de IBM, entonces vale la pena mencionar que este artículo simplifica en exceso la especificación JMM.
Michael Barker
20

En términos generales, en la mayoría de los procesadores modernos, una carga volátil es comparable a una carga normal. Una tienda volátil es aproximadamente 1/3 del tiempo de un montior-enter / monitor-exit. Esto se ve en sistemas que son coherentes en caché.

Para responder a la pregunta del OP, las escrituras volátiles son caras, mientras que las lecturas generalmente no lo son.

¿Significa esto que las operaciones de lectura volátil se pueden realizar sin una invalidación de caché explícita en x86, y es tan rápido como una lectura de variable normal (sin tener en cuenta las restricciones de reordenamiento de volátil)?

Sí, a veces, al validar un campo, es posible que la CPU ni siquiera llegue a la memoria principal, sino que espíe otros cachés de subprocesos y obtenga el valor de allí (explicación muy general).

Sin embargo, apoyo la sugerencia de Neil de que si tiene un campo al que acceden varios hilos, debe envolverlo como AtomicReference. Al ser una AtomicReference, ejecuta aproximadamente el mismo rendimiento para lecturas / escrituras, pero también es más obvio que varios subprocesos accederán y modificarán el campo.

Editar para responder a la edición de OP:

La coherencia de la caché es un protocolo un poco complicado, pero en resumen: las CPU compartirán una línea de caché común que se adjunta a la memoria principal. Si una CPU carga memoria y ninguna otra CPU la tenía, la CPU asumirá que es el valor más actualizado. Si otra CPU intenta cargar la misma ubicación de memoria, la CPU ya cargada se dará cuenta de esto y, de hecho, compartirá la referencia almacenada en caché con la CPU solicitante; ahora la CPU de solicitud tiene una copia de esa memoria en su caché de CPU. (Nunca tuvo que buscar en la memoria principal la referencia)

Hay un poco más de protocolo involucrado, pero esto da una idea de lo que está sucediendo. Además, para responder a su otra pregunta, con la ausencia de varios procesadores, las lecturas / escrituras volátiles pueden ser más rápidas que con varios procesadores. Hay algunas aplicaciones que de hecho se ejecutarían más rápido al mismo tiempo con una sola CPU y luego con varias.

John Vint
fuente
5
Una AtomicReference es solo una envoltura de un campo volátil con funciones nativas agregadas que brindan funcionalidad adicional como getAndSet, compareAndSet, etc., por lo que desde el punto de vista del rendimiento, usarlo es útil si necesita la funcionalidad adicional. Pero me pregunto por qué se refiere al sistema operativo aquí. La funcionalidad se implementa directamente en los códigos de operación de la CPU. ¿Y esto implica que en los sistemas multiprocesador, donde una CPU no tiene conocimiento sobre el contenido de la caché de otras CPU, los volátiles son más lentos porque las CPU siempre tienen que llegar a la memoria principal?
Daniel
Tienes razón, extraño hablar sobre el sistema operativo que debería haber escrito CPU, arreglando eso ahora. Y sí, sé que AtomicReference es simplemente un contenedor para campos volátiles, pero también agrega como una especie de documentación que el campo en sí será accesible por múltiples subprocesos.
John Vint
@John, ¿por qué agregarías otra indirección a través de una AtomicReference? Si necesita CAS - ok, pero AtomicUpdater podría ser una mejor opción. Por lo que recuerdo, no hay intrínsecos sobre AtomicReference.
bestsss
@bestsss Para todos los propósitos generales, tienes razón, no hay diferencia entre AtomicReference.set / get y la carga y las tiendas volátiles. Dicho esto, tuve el mismo sentimiento (y hasta cierto punto) sobre cuándo usar cuál. Esta respuesta puede detallarlo un poco stackoverflow.com/questions/3964317/… . Usar cualquiera de los dos es más una preferencia, mi único argumento para usar AtomicReference sobre un volátil simple es para una documentación clara, eso en sí mismo tampoco es el mejor argumento, según entiendo
John Vint
En una nota al margen, algunos argumentan que el uso de un campo volátil / AtomicReference (sin la necesidad de un CAS) conduce al código con errores old.nabble.com/…
John Vint
12

En palabras del Modelo de memoria de Java (como se define para Java 5+ en JSR 133), cualquier operación, de lectura o escritura, en una volatilevariable crea una relación de suceder antes con respecto a cualquier otra operación en la misma variable. Esto significa que el compilador y JIT se ven obligados a evitar ciertas optimizaciones, como reordenar instrucciones dentro del hilo o realizar operaciones solo dentro de la caché local.

Dado que algunas optimizaciones no están disponibles, el código resultante es necesariamente más lento de lo que hubiera sido, aunque probablemente no mucho.

Sin embargo, no debe crear una variable a volatilemenos que sepa que se accederá a ella desde múltiples subprocesos fuera de los synchronizedbloques. Incluso entonces, debe considerar si volátil es la mejor opción frente a synchronized, AtomicReferencey sus amigos, las Lockclases explícitas , etc.

Neil Bartlett
fuente
4

Acceder a una variable volátil es en muchos aspectos similar a envolver el acceso a una variable ordinaria en un bloque sincronizado. Por ejemplo, el acceso a una variable volátil evita que la CPU reordene las instrucciones antes y después del acceso, y esto generalmente ralentiza la ejecución (aunque no puedo decir cuánto).

De manera más general, en un sistema multiprocesador no veo cómo se puede acceder a una variable volátil sin penalización; debe haber alguna forma de garantizar que una escritura en el procesador A se sincronice con una lectura en el procesador B.

Krakover
fuente
4
La lectura de variables volátiles tiene la misma penalización que hacer una entrada de monitor, con respecto a las posibilidades de reordenamiento de las instrucciones, mientras que escribir una variable volátil equivale a una salida de monitor. Una diferencia podría ser qué variables (por ejemplo, cachés de procesador) se vacían o invalidan. Si bien la sincronización elimina o invalida todo, el acceso a la variable volátil siempre debe ignorar la memoria caché.
Daniel
12
-1, Acceder a una variable volátil es bastante diferente a usar un bloque sincronizado. Entrar en un bloque sincronizado requiere una escritura basada en compareAndSet atómica para eliminar el bloqueo y una escritura volátil para liberarlo. Si el bloqueo está satisfecho, entonces el control tiene que pasar del espacio del usuario al espacio del kernel para arbitrar el bloqueo (este es el bit caro). Acceder a un volátil siempre permanecerá en el espacio del usuario.
Michael Barker
@MichaelBarker: ¿Estás seguro de que todos los monitores deben estar protegidos por el kernel y no por la aplicación?
Daniel
@Daniel: Si representas a un monitor usando un bloque sincronizado o un bloqueo, entonces sí, pero solo si el monitor está satisfecho. La única forma de hacer esto sin el arbitraje del kernel es usar la misma lógica, pero girar ocupado en lugar de estacionar el hilo.
Michael Barker
@MichaelBarker: Okey, para cerraduras satisfechas, entiendo esto.
Daniel