Java 8 inseguro: xxxFence() instrucciones
concurrency java-8 (3)
En Java 8, se agregaron tres instrucciones de barrera de memoria a la clase Unsafe
( source ):
/**
* Ensures lack of reordering of loads before the fence
* with loads or stores after the fence.
*/
void loadFence();
/**
* Ensures lack of reordering of stores before the fence
* with loads or stores after the fence.
*/
void storeFence();
/**
* Ensures lack of reordering of loads or stores before the fence
* with loads or stores after the fence.
*/
void fullFence();
Si definimos la barrera de memoria de la siguiente manera (que considero más o menos fácil de entender):
Considere que X e Y son tipos / clases de operación que están sujetos a reordenamiento,
X_YFence()
es una instrucción de barrera de memoria que asegura que todas las operaciones de tipo X antes de que se complete la barrera antes de cualquier operación de tipo Y después del inicio de la barrera.
Ahora podemos "asignar" nombres de barrera de Unsafe
a esta terminología:
-
loadFence()
convierte enload_loadstoreFence()
; -
storeFence()
convierte enstore_loadStoreFence()
; -
fullFence()
convierte enloadstore_loadstoreFence()
;
Finalmente, mi pregunta es : ¿por qué no tenemos load_storeFence()
, store_loadFence()
, store_storeFence()
y load_loadFence()
?
Mi suposición sería que no son realmente necesarios, pero no entiendo por qué en este momento. Entonces, me gustaría saber los motivos para no agregarlos. Las conjeturas sobre eso también son bienvenidas (espero que esto no provoque que esta pregunta sea fuera de lugar como basada en la opinión).
Gracias por adelantado.
Resumen
Los núcleos de CPU tienen búferes de ordenación de memoria especiales para ayudarlos con la ejecución fuera de orden. Estos pueden ser (y típicamente son) separados para cargar y almacenar: LOB para búferes de orden de carga y SOB para búferes de orden de tienda.
Las operaciones de cercado elegidas para la API insegura se seleccionaron en base a la siguiente suposición : los procesadores subyacentes tendrán búferes de orden de carga separados (para reordenar cargas), búferes de órdenes de tienda (para reordenar tiendas).
Por lo tanto, en base a esta suposición, desde el punto de vista del software, puede solicitar una de tres cosas de la CPU:
- Vacíe los LOB (loadFence): significa que ninguna otra instrucción comenzará a ejecutarse en este núcleo, hasta que TODAS las entradas hayan sido procesadas. En x86 esto es un LFENCE.
- Vacíe los SOB (storeFence): significa que ninguna otra instrucción comenzará a ejecutarse en este núcleo, hasta que TODAS las entradas en los SOB hayan sido procesadas. En x86 esto es un SFENCE.
- Vacíe tanto LOB como SOB (fullFence): significa ambos de los anteriores. En x86, este es un MEFENCE.
En realidad, cada arquitectura de procesador específica proporciona diferentes garantías de ordenamiento de memoria, que pueden ser más estrictas o más flexibles que las anteriores. Por ejemplo, la arquitectura SPARC puede reordenar las secuencias de carga-almacenamiento y almacenamiento-carga, mientras que x86 no hará eso. Además, existen arquitecturas en las que los LOB y los SOB no pueden controlarse individualmente (es decir, solo es posible la valla completa). En ambos casos, sin embargo:
cuando la arquitectura es más flexible, la API simplemente no proporciona acceso a las combinaciones de secuenciamiento "laxer" como una cuestión de elección
cuando la arquitectura es más estricta, la API simplemente implementa la garantía de secuenciamiento más estricta en todos los casos (por ejemplo, las 3 llamadas en realidad se implementan como una valla completa)
El motivo de las opciones de API particulares se explica en el JEP según la respuesta que proporciona assylias, que es 100% sobre el terreno. Si conoce el orden de la memoria y la coherencia del caché, la respuesta de Assylias debería ser suficiente. Creo que el hecho de que coincidan con la instrucción estandarizada en la API de C ++ fue un factor importante (simplifica mucho la implementación de JVM): http://en.cppreference.com/w/cpp/atomic/memory_order Es muy probable que la implementación real llamar a la respectiva API de C ++ en lugar de utilizar algunas instrucciones especiales.
A continuación tengo una explicación detallada con ejemplos basados en x86, que proporcionarán todo el contexto necesario para comprender estas cosas. De hecho, la sección demarcada responde a otra pregunta: "¿Puede proporcionar ejemplos básicos de cómo funcionan las vallas de memoria para controlar la coherencia del caché en la arquitectura x86?"
La razón de esto es que yo mismo (proveniente de un desarrollador de software y no del diseñador de hardware) tuve problemas para entender qué es el reordenamiento de memoria, hasta que aprendí ejemplos específicos de cómo la coherencia del caché realmente funciona en x86. Esto proporciona un contexto invaluable para discutir cercas de memoria en general (para otras arquitecturas también). Al final, analizo SPARC un poco usando el conocimiento obtenido de los ejemplos de x86.
La referencia [1] es una explicación aún más detallada y tiene una sección separada para analizar cada uno de: x86, SPARC, ARM y PowerPC, por lo que es una lectura excelente si está interesado en obtener más detalles.
ejemplo de arquitectura x86
x86 proporciona 3 tipos de instrucciones de esgrima: LFENCE (valla de carga), SFENCE (valla de la tienda) y MFENCE (valla de la tienda de carga), por lo que se asigna al 100% a la API de Java.
Esto se debe a que x86 tiene búferes de orden de carga (LOB) separados y búferes de orden de almacenamiento (SOB), por lo que las instrucciones de LFENCE / SFENCE se aplican al búfer respectivo, mientras que MFENCE se aplica a ambos.
Los SOB se utilizan para almacenar un valor de salida (del procesador al sistema de caché) mientras que el protocolo de coherencia de caché funciona para obtener permiso para escribir en la línea de caché. Los LOB se utilizan para almacenar solicitudes de invalidación para que la invalidación se pueda ejecutar de forma asincrónica (reduce el estancamiento en el lado de recepción con la esperanza de que el código que se ejecuta allí no necesite realmente ese valor).
Tiendas fuera de servicio y SFENCE
Supongamos que tiene un sistema de procesador dual con sus dos CPU, 0 y 1, que ejecuta las rutinas a continuación. Considere el caso en el que la failure
1 de la memoria caché es inicialmente propiedad de la CPU 1, mientras que la detención de la línea de caché es inicialmente propiedad de la CPU 0.
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
if (failure) { ...}
}
En ausencia de una cerca de la tienda, la CPU 0 puede indicar un apagado debido a una falla, pero la CPU 1 saldrá del bucle y NO entrará en el bloque de manejo de fallas.
Esto se debe a que CPU0 escribirá el valor 1 para el failure
en un búfer de orden de tienda, también enviando un mensaje de coherencia de caché para adquirir acceso exclusivo a la línea de caché. A continuación, pasará a la siguiente instrucción (mientras espera el acceso exclusivo) y actualizará el indicador de shutdown
inmediatamente (esta línea de caché es propiedad exclusiva de CPU0, por lo que no es necesario negociar con otros núcleos). Finalmente, cuando más tarde reciba un mensaje de confirmación de invalidación de la CPU1 (en relación con la failure
), procesará el SOB en busca de failure
y escribirá el valor en la memoria caché (pero el orden ya se ha revertido).
Insertar un storeFence () arreglará cosas:
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
SFENCE // next instruction will execute after all SOBs are processed
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
if (failure) { ...}
}
Un aspecto final que merece mención es que x86 tiene reenvío de tienda: cuando una CPU escribe un valor que se atasca en un SOB (debido a la coherencia del caché), puede intentar ejecutar una instrucción de carga para la misma dirección ANTES de que el SOB esté procesado y entregado a la memoria caché. Por lo tanto, las CPU consultarán los SOB ANTES de acceder al caché, por lo que el valor recuperado en este caso es el último valor escrito del SOB. esto significa que las tiendas de ESTE núcleo nunca se pueden reordenar con cargas posteriores de ESTE núcleo sin importar qué .
Cargas fuera de servicio y LFENCE
Ahora, suponga que tiene la valla de la tienda en su lugar y está feliz de que el shutdown
no pueda superar la failure
en su camino hacia la CPU 1, y se centre en el otro lado. Incluso en presencia de la valla de la tienda, hay escenarios en los que ocurre algo incorrecto. Considere el caso donde la failure
está en ambas memorias caché (compartidas) mientras que el shutdown
solo está presente y es propiedad exclusiva de la memoria caché de CPU0. Las cosas malas pueden suceder de la siguiente manera:
- CPU0 escribe 1 en la
failure
; También envía un mensaje a CPU1 para invalidar su copia de la línea de caché compartida como parte del protocolo de coherencia de caché . - CPU0 ejecuta el SFENCE y las paradas, esperando el SOB utilizado para la
failure
al confirmar. - La CPU1 comprueba el
shutdown
debido al ciclo while y (al darse cuenta de que le falta el valor) envía un mensaje de coherencia de caché para leer el valor. - La CPU1 recibe el mensaje de la CPU0 en el paso 1 para invalidar la
failure
, enviando un acuse de recibo inmediato. NOTA: esto se implementa utilizando la cola de invalidación, por lo que simplemente ingresa una nota (asigna una entrada en su LOB) para luego realizar la invalidación, pero en realidad no la realiza antes de enviar el acuse de recibo. - CPU0 recibe el acuse de recibo por
failure
y continúa pasando el SFENCE a la siguiente instrucción - CPU0 escribe 1 para apagar sin usar un SOB, porque ya posee la línea de caché exclusivamente. no se envía ningún mensaje adicional para la invalidación ya que la línea de caché es exclusiva de CPU0
- La CPU1 recibe el valor de
shutdown
y lo asigna a su caché local, avanzando a la siguiente línea. - La CPU1 verifica el valor de
failure
de la instrucción if, pero como la cola de invalidación (nota LOB) aún no se procesa, usa el valor 0 de su caché local (no ingresa si el bloque). - CPU1 procesa la cola de invalidación y la
failure
actualización a 1, pero ya es demasiado tarde ...
A lo que nos referimos como búferes de orden de carga, es actaully la cola de solicitudes de invalidación, y lo anterior se puede arreglar con:
// CPU 0:
void shutDownWithFailure(void)
{
failure = 1; // must use SOB as this is owned by CPU 1
SFENCE // next instruction will execute after all SOBs are processed
shutdown = 1; // can execute immediately as it is owned be CPU 0
}
// CPU1:
void workLoop(void)
{
while (shutdown == 0) { ... }
LFENCE // next instruction will execute after all LOBs are processed
if (failure) { ...}
}
Tu pregunta en x86
Ahora que sabe lo que hacen los SOB / LOB, piense en las combinaciones que ha mencionado:
loadFence() becomes load_loadstoreFence();
No, una valla de carga espera a que se procesen LOB, esencialmente vaciando la cola de invalidación. Esto significa que todas las cargas posteriores verán datos actualizados (sin reordenar), ya que se obtendrán del subsistema de caché (que es coherente). Las tiendas CANNNOT se reordenan con cargas posteriores, ya que no pasan por el LOB. (y además el reenvío de tiendas se encarga de las líneas de cachés modificadas localmente) Desde la perspectiva de ESTE núcleo en particular (el que ejecuta la valla de carga), una tienda que sigue la valla de carga se ejecutará DESPUÉS de que todos los registros tengan los datos cargados. No hay manera de evitarlo.
load_storeFence() becomes ???
No es necesario tener load_storeFence ya que no tiene sentido. Para almacenar algo debes calcularlo usando la entrada. Para obtener entrada, debe ejecutar cargas. Las tiendas se realizarán utilizando los datos obtenidos de las cargas. Si desea asegurarse de ver los valores actualizados de todos los demás procesadores al cargar, use un loadFence. Para cargas después de la valla, el reenvío a la tienda se encarga de un pedido consistente.
Todos los demás casos son similares.
SPARC
SPARC es aún más flexible y puede reordenar tiendas con cargas posteriores (y cargas con tiendas posteriores). No estaba tan familiarizado con SPARC, así que mi GUESS fue que no hay reenvío de tienda (no se consultan los SOB al volver a cargar una dirección) para que sean posibles "lecturas sucias". De hecho, estaba equivocado: encontré la arquitectura SPARC en [3] y la realidad es que el reenvío de tiendas está enhebrado. De la sección 5.3.4:
Todas las cargas verifican el buffer de la tienda (solo el mismo subproceso) para los riesgos de lectura tras escritura (RAW). Se produce un RAW completo cuando la dirección de dword de la carga coincide con la de un almacenamiento en el STB y todos los bytes de la carga son válidos en el búfer de tienda. Se produce un RAW parcial cuando las direcciones de dword coinciden, pero todos los bytes no son válidos en el búfer de tienda. (Ej., Un ST (almacén de palabras) seguido de un LDX (carga dword) a la misma dirección da como resultado un RAW parcial, porque el dword completo no está en la entrada del buffer de la tienda).
Por lo tanto, diferentes hilos consultan diferentes almacenamientos intermedios de orden de tienda, de ahí la posibilidad de lecturas sucias después de las tiendas.
Referencias
[1] Barreras de memoria: una vista de hardware para hackers de software, Centro de tecnología de Linux, IBM Beaverton http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.07.23a.pdf
[2] Intel® 64 y IA-32 ArchitecturesSoftware Developer''s Manual, Volumen 3A http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf
[3] Especificación de microarquitectura de OpenSPARC T2 Core http://www.oracle.com/technetwork/systems/opensparc/t2-06-opensparct2-core-microarch-1537749.html
El documento para storeFence () es incorrecto. Ver https://bugs.openjdk.java.net/browse/JDK-8038978
loadFence () es LoadLoad plus LoadStore, tan útil que a menudo se llama acquire fence.
storeFence () es StoreStore plus LoadStore, tan útil a menudo llamado release fence.
LoadLoad LoadStore StoreStore son vallas baratas (nop en x86 o Sparc, barato en Power, quizás caro en ARM).
IA64 tiene diferentes instrucciones para la semántica de adquisición y liberación.
fullFence () es LoadLoad LoadStore StoreStore más StoreLoad.
La valla StordLoad es costosa (en casi todas las CPU), casi tan cara como la valla completa.
Eso justifica el diseño de API.
Una buena fuente de información es source .
Razón fundamental:
Los tres métodos proporcionan los tres tipos diferentes de cercas de memoria que algunos compiladores y procesadores necesitan para garantizar que los accesos particulares (cargas y tiendas) no se reordenan.
Implementación (extracto):
para las versiones en tiempo de ejecución de C ++ (en prims / unsafe.cpp), implementando a través de los métodos de OrderAccess existentes:
loadFence: { OrderAccess::acquire(); }
storeFence: { OrderAccess::release(); }
fullFence: { OrderAccess::fence(); }
En otras palabras, los nuevos métodos se relacionan estrechamente con la forma en que las vallas de memoria se implementan en los niveles de JVM y CPU. También coinciden con las http://en.cppreference.com/w/cpp/atomic/memory_order , el idioma en el que se implementa el punto de acceso.
Un enfoque de grano más fino probablemente habría sido factible, pero los beneficios no son obvios.
Por ejemplo, si observa la tabla de instrucciones de la CPU en gee.cs.oswego.edu/dl/jmm/cookbook.html , verá que LoadStore y LoadLoad se asignan a las mismas instrucciones en la mayoría de las arquitecturas, es decir, que ambas son efectivamente instrucciones Load_LoadStore. Por lo tanto, tener una sola instrucción Load_LoadStore ( loadFence
) en el nivel de JVM parece una decisión de diseño razonable.