Requisito previo: subprocesamiento , bloque sincronizado y palabra clave volátil
Sucede antes es un concepto, un fenómeno o simplemente un conjunto de reglas que definen la base para el reordenamiento de instrucciones por parte de un compilador o CPU. Happens-before no es una palabra clave u objeto en el lenguaje Java, es simplemente una disciplina implementada para que en un entorno de subprocesos múltiples, el reordenamiento de las instrucciones circundantes no resulte en un código que produzca una salida incorrecta.
La definición puede parecer un poco abrumadora si es la primera vez que te encuentras con este concepto. Para entenderlo, primero aprendamos de dónde se origina la necesidad.
El modelo de memoria de Java, también conocido como modelo JMM, define cómo se lleva a cabo el almacenamiento y el intercambio de datos entre subprocesos y el hardware en un entorno de uno o varios subprocesos.
Algunos puntos a tener en cuenta son los siguientes:
- Cada núcleo de CPU tiene su propio conjunto de registros.
- Cada núcleo de CPU puede ejecutar más de un subproceso a la vez.
- Cada núcleo de CPU tiene su propio conjunto de caché.
- Un subproceso se ejecuta en un núcleo de CPU, pero sus datos se almacenan y se accede a ellos desde la RAM, donde las variables locales se encuentran dentro de la «Pila de subprocesos» y los objetos se encuentran dentro del «Montón».
Las variables locales y las referencias a objetos dentro de un subproceso se almacenan en Thread Stack, mientras que los objetos mismos se almacenan dentro de Heap. La solicitud de una variable por parte del subproceso que se ejecuta en la CPU sigue esta ruta RAM -> Caché -> Registros de la CPU. De manera similar, cuando ocurre algún procesamiento en la variable y su valor se actualiza, los cambios pasan por el Registro de la CPU -> Caché -> RAM.Por lo tanto, mientras se trabaja con varios subprocesos que comparten una variable, cuando un subproceso actualiza el valor de una variable compartida, la actualización debe realizarse en el registro, luego en la caché y finalmente en la RAM. Y cuando otro hilo requiere leer esa variable compartida, lee el valor presente en la RAM que viaja a través del caché y se registra. Si lo observa en el nivel básico, si las operaciones de lectura y escritura se retrasan de tal manera que el valor correcto no se almacena en la memoria antes de que se realice otra lectura y escritura, puede generar errores de consistencia en la memoria.
Cuando se trabaja con varios subprocesos, este procedimiento de almacenamiento y recuperación puede plantear algunos problemas, como:
- Condición de carrera : la condición en la que dos subprocesos comparten alguna variable, leen y escriben en ella pero no lo hacen de manera sincronizada, lo que da como resultado valores inconsistentes.
- Visibilidad de actualización : donde las actualizaciones realizadas por un subproceso en una variable compartida pueden no ser visibles para el otro subproceso porque el valor aún no se ha actualizado en la RAM.
Estos problemas se resuelven mediante el uso de bloques sincronizados y variables volátiles.
Reordenación de instrucciones
Durante la compilación o durante el procesamiento, el compilador o la CPU pueden reordenar las instrucciones para ejecutarlas en paralelo para aumentar el rendimiento y el rendimiento. Por ejemplo, tenemos 3 instrucciones:
FullName = FirstName + LastName // Statement 1 UniqueId = FullName + TokenNo // Statement 2 Age = CurrentYear - BirthYear // Statement 3
El compilador no puede ejecutar 1 y 2 en paralelo porque 2 necesita la salida de 1, pero 1 y 3 pueden ejecutarse en paralelo porque son independientes entre sí. Entonces el compilador o la CPU pueden reordenar estas instrucciones de esta manera:
FullName = FirstName + LastName // Statement 1 Age = CurrentYear - BirthYear // Statement 3 UniqueId = FullName + TokenNo // Statement 2
Sin embargo, si el reordenamiento se realiza en una aplicación de subprocesos múltiples donde los subprocesos comparten algunas variables, entonces puede costarnos la corrección de nuestro programa.
Ahora recuerda los dos problemas de los que hablamos en la sección anterior, la condición de carrera y la visibilidad actualizada. Java nos proporciona algunas soluciones para manejar este tipo de situaciones. Vamos a aprender cuáles son, y finalmente sucederá antes de que se presente en esa sección.
Volátil
Para un campo/variable declarado como volátil,
private volatile count;
- Cada escritura en el campo se escribirá/vaciará directamente a la memoria principal (es decir, sin pasar por el caché).
- Cada lectura de ese campo se lee directamente desde la memoria principal.
Esto significa que el recuento de variables compartidas , cada vez que un subproceso escribe o lee, siempre corresponderá a su valor escrito más reciente. Esto evitará la condición de carrera porque ahora los subprocesos siempre usarán el valor correcto de una variable compartida. Además, las actualizaciones de la variable compartida también serán visibles para todos los subprocesos que la lean, evitando así el problema de visibilidad de la actualización.
Hay algunos puntos más importantes que dicta el volátil:
- En el momento de escribir en una variable volátil, todas las variables no volátiles que son visibles para ese subproceso también se escribirán/vaciarán en la memoria principal, es decir, sus valores más recientes se almacenarán en la RAM junto con la variable volátil.
- En el momento de leer una variable volátil, todas las variables no volátiles que son visibles para ese subproceso también se actualizarán desde la memoria principal, es decir, se les asignarán sus valores más recientes.
Esto se llama la garantía de visibilidad de una variable volátil .
Todo esto se ve y funciona bien, a menos que la CPU decida reordenar sus instrucciones, lo que resulta en una ejecución incorrecta de su aplicación. Entendamos lo que queremos decir. Considere esta pieza de un programa:
Implementación:
El siguiente código en la ilustración se muestra como se transmite en palabras más simples de la siguiente manera:
- Ingresa una tarea nueva enviada por un estudiante
- Y luego recoge esa nueva asignación.
Nuestro objetivo es que cada vez “solo se recopile una tarea recién preparada. Entonces proponiendo el código de muestra para lo mismo de la siguiente manera:
ilustración:
// Sample class class ClassRoom { // Declaring and initializing variables // of this class private int numOfAssgnSubmitted = 0; private int numOfAssgnCollected = 0; private Assignment assgn = null; // Volatile shared variable private volatile boolean newAssignment = false; // Methods of this class // Method 1 // Used by Thread 1 public void submitAssignment(Assignment assgn) { // This keyword refers to current instance itself // 1 this.assgn = assgn; // 2 this.numOfAssgnSubmitted++; // 3 this.newAssignment = true; } // Method 2 // Used by Thread 2 public Assignment collectAssignment() { while (!newAssignment) { // Wait until a new assignment is submitted } Assignment collectedAssgn = this.assgn; this.numOfAssgnCollected++; this.newAssignment = false; return collectedAssgn; } }
- El método submitAssignment() es utilizado por un subproceso Thread1, que acepta una tarea enviada por un estudiante en el campo de asignación, luego aumenta la cantidad de tareas enviadas y luego cambia la variable newAssignment a verdadera.
- El método collectAssignment() es utilizado por un subproceso Thread2, que espera hasta que se haya enviado una nueva tarea, cuando el valor de newAssignment se vuelve verdadero, almacena la tarea enviada en una variable ‘collectedAssgn’, lo que aumenta la cantidad de tareas recopiladas y voltea la nueva Asignación a falso, ya que no quedan asignaciones pendientes. Finalmente, devuelve la tarea recopilada.
Ahora, la variable volátil newAssignment actúa como una variable compartida entre Thread1 y Thread2 que se ejecutan simultáneamente. Y dado que todas las demás variables son visibles para cada uno de los subprocesos junto con newAssignment, las operaciones de lectura y escritura se realizarán directamente utilizando la memoria principal.
Si nos enfocamos en el método submitAssignment(), las declaraciones 1, 2 y 3 son independientes entre sí, ya que ninguna declaración utiliza la otra declaración, por lo que su CPU podría pensar «¿Por qué no reordenarlas?» por cualquier razón que pueda proporcionar un mejor rendimiento. Entonces, supongamos que la CPU reordenó las tres declaraciones de esta manera:
this.newAssignment = true; // 3 this.assgn = assgn; // 1 this.numOfAssgnSubmitted++; // 2
Ahora piense por un segundo, cuál era nuestro objetivo, era recopilar una nueva asignación nueva cada vez, pero ahora debido a la declaración 3 que actualiza newAssignment a true incluso antes de que la nueva asignación se haya almacenado en la asignación, el bucle while en el Thread2 ahora se cerrará y existe la posibilidad de que las instrucciones de Thread2 se ejecuten antes que las instrucciones restantes de Thread1, lo que dará como resultado que se envíe un objeto de valor más antiguo de Asignación. Aunque los valores se recuperan directamente de la memoria principal, es inútil si las instrucciones se ejecutan en el orden incorrecto en este caso.
Este es el punto donde si bien la visibilidad de las variables está garantizada, el reordenamiento de las instrucciones puede conducir a una ejecución incorrecta. Y por lo tanto, Java introdujo la garantía de que suceda antes, con respecto a la visibilidad de las variables volátiles.
Sucede-Antes en Volatile
Ocurre antes de los estados sobre el reordenamiento. Es como sigue:
- Al reordenar cualquier escritura en una variable que haya ocurrido antes de escribir en una variable volátil, permanecerá antes de escribir en la variable volátil.
- Al reordenar cualquier lectura de una variable volátil que se encuentra antes de la lectura de alguna variable no volátil o volátil, se garantiza que sucederá antes de cualquiera de las lecturas posteriores.
En el contexto del ejemplo anterior, el primer punto es relevante. Cualquier escritura en una variable (Declaraciones 1 y 2) que ocurrió antes de una escritura en un volátil (Declaración 3), permanecerá antes de la escritura en la variable volátil. Esto significa que está prohibido reordenar la Declaración 3 antes de la 1 y la 2. Esto, a su vez, garantiza que newAssignment solo se establece en true una vez que el nuevo valor de Assignment se asigna a ‘ assgn’ . Esto se denomina garantía de visibilidad de volatile antes de que suceda . Además, las declaraciones 1 y 2 pueden reordenarse entre sí siempre que no se reordenen después de la declaración 3.
Bloque de sincronización
En el caso de un bloque de sincronización en Java:
- Cuando un subproceso ingresa a un bloque de sincronización, el subproceso actualizará los valores de todas las variables que son visibles para el subproceso en ese momento desde la memoria principal.
- Cuando un subproceso sale de un bloque de sincronización, los valores de todas esas variables se escribirán en la memoria principal.
Ocurre antes en el bloque de sincronización
En caso de bloqueo de sincronización, sucede antes de los estados que para reordenar:
- Se garantiza que cualquier escritura en una variable que ocurra antes de la salida de un bloque de sincronización permanecerá antes de la salida de un bloque de sincronización.
- Se garantiza que la entrada a un bloque de sincronización que ocurre antes de la lectura de una variable permanecerá antes de cualquiera de las lecturas de las variables que siguen a la entrada de un bloque sincronizado.
Ahora profundizando en las raíces de la relación «sucede antes» en Java. Consideremos un escenario para entenderlo en mejores términos.
Ilustración:
Si una acción ‘x’ es visible y se ordena antes que otra acción ‘y’, entonces existe una relación que sucede antes entre las dos acciones indicadas por hb(x, y).
- Si x e y son acciones del mismo hilo y x viene antes que y en el orden del programa, entonces hb(x, y).
- Hay un borde que sucede antes desde el final de un constructor de un objeto hasta el comienzo de un finalizador para ese objeto.
- Si una acción x se sincroniza con la siguiente acción y, entonces también tenemos hb(x, y).
- Si hb(x, y) y hb(y, z), entonces hb(x, z).
Nota: Es importante saber que si tenemos hb(x, y) entonces no necesariamente significa que x siempre ocurre en la implementación antes que y, siempre y cuando la ejecución produzca resultados correctos, el reordenamiento de tales acciones es legal.
Algunas reglas más establecidas con respecto al estado de sincronización son las siguientes:
- Se produce un desbloqueo en un monitor , antes de cada bloqueo posterior en ese monitor.
- Se produce una escritura en un campo volátil, antes de cada lectura posterior de ese campo.
- Se produce una llamada a start() en un subproceso, antes de cualquier acción en el subproceso iniciado.
- Todas las acciones en un subproceso suceden antes de que cualquier otro subproceso regrese con éxito de un join() en ese subproceso.
- La inicialización predeterminada de cualquier objeto ocurre antes de cualquier otra acción (aparte de las escrituras predeterminadas) de un programa.
- Cuando una declaración invoca a Thread.start, cada declaración que tiene una relación de suceso anterior con esa sentencia también tiene una relación de suceso anterior con cada sentencia ejecutada por el nuevo subproceso. Los efectos del código que condujo a la creación del nuevo subproceso son visibles para el nuevo subproceso.
- Cuando un subproceso termina y hace que se devuelva un Thread.join en otro subproceso, entonces todas las declaraciones ejecutadas por el subproceso terminado tienen una relación anterior con todas las declaraciones que siguen a la unión exitosa. Los efectos del código en el subproceso ahora son visibles para el subproceso que realizó la combinación.
Publicación traducida automáticamente
Artículo escrito por yuvrajjoshi31 y traducido por Barcelona Geeks. The original can be accessed here. Licence: CCBY-SA