Introducción
Contenido
El multiproceso es una causa común de dolores de cabeza para los programadores. Dado que los humanos, naturalmente, no están acostumbrados a este tipo de pensamiento «paralelo», diseñar un programa multiproceso se vuelve mucho menos sencillo que escribir software con un solo hilo de ejecución.
En este artículo, veremos algunos problemas comunes de subprocesos múltiples que podemos solucionar usando el volatile
palabra clave.
También echaremos un vistazo a algunos problemas más complejos donde volatile
no es suficiente para solucionar la situación, lo que significa que es necesario actualizar otros mecanismos de seguridad.
Visibilidad variable
Existe un problema común con la visibilidad de variables en entornos multiproceso. Supongamos que tenemos una variable (u objeto) compartida a la que acceden dos subprocesos diferentes (cada subproceso en su propio procesador).
Si un hilo actualiza la variable / objeto, no podemos saber con certeza cuándo exactamente este cambio será visible para el otro hilo. La razón por la que esto sucede se debe al almacenamiento en caché de la CPU.
Cada hilo que usa la variable hace una copia local (es decir, caché) de su valor en la propia CPU. Esto permite que las operaciones de lectura y escritura sean más eficientes, ya que el valor actualizado no necesita «viajar» hasta la memoria principal, sino que puede almacenarse temporalmente en una caché local:
Credito de imagen: Tutoriales de Jenkov
Si Thread 1 actualiza la variable, la actualiza en el caché y Thread 2 todavía tiene la copia desactualizada en su caché. La operación del subproceso 2 puede depender del resultado del subproceso 1, por lo que trabajar con el valor desactualizado producirá un resultado completamente diferente.
Finalmente, cuando les gustaría confirmar los cambios en la memoria principal, los valores son completamente diferentes y uno anula al otro.
Te puede interesar:¿Java «pasa por referencia» o «pasa por valor»?En un entorno de subprocesos múltiples, esto puede ser un problema costoso porque puede conducir a un comportamiento inconsistente grave. No podría confiar en los resultados y su sistema tendría que tener costosos controles para intentar obtener el valor actualizado, posiblemente sin una garantía.
En resumen, su aplicación fallaría.
La palabra clave volátil
los volatile
la palabra clave marca una variable como, bueno, volátil. Al hacerlo, la JVM garantiza que el resultado de cada operación de escritura no se escriba en la memoria local sino en la memoria principal.
Esto significa que cualquier hilo del entorno puede acceder a la variable compartida con el valor más reciente y actualizado sin ninguna preocupación.
Se puede lograr un comportamiento similar, pero no idéntico, con la palabra clave sincronizada.
Ejemplos
Echemos un vistazo a algunos ejemplos de volatile
palabra clave en uso.
Variable compartida simple
En el ejemplo de código a continuación, podemos ver una clase que representa una estación de carga de combustible para cohetes que puede ser compartida por varias naves espaciales. El combustible de cohetes representa un recurso / variable compartido (algo que se puede cambiar desde «afuera») mientras que las naves espaciales representan hilos (cosas que cambian la variable).
Sigamos ahora y definamos un RocketFuelStation
. Cada Spaceship
tendrá un RocketFuelStation
como un campo, ya que están asignados a él y, como se esperaba, el fuelAmount
es static
. Si una nave espacial toma algo de combustible de la estación, también debe reflejarse en la instancia que pertenece a otro objeto:
public class RocketFuelStation {
// The amount of rocket fuel, in liters
private static int fuelAmount;
public void refillShip(Spaceship ship, int amount) {
if (amount <= fuelAmount) {
ship.refill(amount);
this.fuelAmount -= amount;
} else {
System.out.println("Not enough fuel in the tank!");
}
}
// Constructor, Getters and Setters
}
Si el amount
deseamos verter en un barco es más alto que el fuelAmount
dejado en el tanque, notificamos al usuario que no es posible recargar tanto. Si no, rellenamos el barco con mucho gusto y reducimos la cantidad que queda en el tanque.
Ahora, dado que cada Spaceship
se ejecutará en una diferente Thread
, tendremos que extend
la clase:
public class Spaceship extends Thread {
private int fuel;
private RocketFuelStation rfs;
public Spaceship(RocketFuelStation rfs) {
this.rfs = rfs;
}
public void refill(int amount) {
fuel += amount;
}
// Getters and Setters
public void run() {
rfs.refillShip(this, 50);
}
Hay un par de cosas a tener en cuenta aquí:
- los
RocketFuelStation
se pasa al constructor, este es un objeto compartido. - los
Spaceship
la clase se extiendeThread
, lo que significa que tenemos que implementarrun()
método. - Una vez que instanciamos el
Spaceship
clase y llamadastart()
, larun()
también se ejecutará el método.
Lo que esto significa es que una vez que creamos una nave espacial y la ponemos en marcha, se reabastecerá de combustible compartido. RocketFuelStation
con 50 litros de combustible.
Y finalmente, ejecutemos este código para probarlo:
RocketFuelStation rfs = new RocketFuelStation(100);
Spaceship ship = new Spaceship(rfs);
Spaceship ship2 = new Spaceship(rfs);
ship.start();
ship2.start();
ship.join();
ship2.join();
System.out.println("Ship 1 fueled up and now has: " + ship.getFuel() + "l of fuel");
System.out.println("Ship 2 fueled up and now has: " + ship2.getFuel() + "l of fuel");
System.out.println("Rocket Fuel Station has " + rfs.getFuelAmount() + "l of fuel left in the end.");
Dado que no podemos garantizar qué hilo se ejecutará primero en Java, el System.out.println()
las declaraciones se encuentran después de ejecutar el join()
métodos en los hilos. los join()
espera a que el hilo muera, por lo que sabemos que imprimimos los resultados después de que los hilos realmente terminan. De lo contrario, podemos encontrarnos con un comportamiento inesperado. No siempre, pero es una posibilidad.
UN new RocketFuelStation()
está elaborado con 100 litros de combustible. Una vez que arrancamos los dos barcos, ambos deberían tener 50 litros de combustible y la estación debería tener 0 litros de combustible.
Veamos qué pasa cuando ejecutamos el código:
Ship 1 fueled up and now has: 0l of fuel
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 50l of fuel left
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Eso no está bien. Ejecutemos el código de nuevo:
Ship 1 fueled up and now has: 0l of fuel
Ship 2 fueled up and now has: 0l of fuel
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 100l of fuel left in the end.
Ahora ambos están vacíos, incluida la gasolinera. Intentemos eso de nuevo:
Ship 1 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 50l of fuel left
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Ahora ambos tienen 50 litros y la estación está vacía. Pero esto se debe a pura suerte.
Sigamos adelante y actualicemos el RocketFuelStation
clase:
public class RocketFuelStation {
// The amount of rocket fuel, in liters
private static volatile int fuelAmount;
// ...
Lo único que cambiamos es decirle a la JVM que el fuelAmount
es volátil y debe omitir el paso de guardar el valor en la caché y enviarlo directamente a la memoria principal.
También cambiaremos el Spaceship
clase:
public class Spaceship extends Thread {
private volatile int fuel;
// ...
Desde el fuel
también puede almacenarse en caché y actualizarse incorrectamente.
Cuando ejecutamos el código anterior ahora, obtenemos:
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
¡Perfecto! Ambos barcos tienen 50 litros de combustible y la estación está vacía. Intentémoslo de nuevo para verificar:
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Y otra vez:
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Si nos encontramos con una situación como esta, donde la declaración inicial es «A la estación de combustible de cohetes le quedan 0l de combustible», el segundo hilo llegó al fuelAmount -= amount
línea antes de que el primer hilo llegara al System.out.println()
línea en esto if
declaración:
if (amount <= fuelAmount) {
ship.refill(amount);
fuelAmount -= amount;
System.out.println("Rocket Fuel Station has " + fuelAmount + "l of fuel left");
}
Si bien aparentemente produce un resultado incorrecto, esto es inevitable cuando trabajamos en paralelo con esta implementación. Esto sucede debido a la falta de exclusión mutua al usar el volatile
palabra clave. Más sobre eso en Insuficiencia de volátiles.
Lo importante es el resultado final: 50 litros de combustible en cada nave espacial y 0 litros de combustible en la estación.
Garantía de sucede antes
Supongamos ahora que nuestra estación de carga es un poco más grande y que tiene dos surtidores de combustible en lugar de uno. Inteligentemente llamaremos a las cantidades de combustible en estos dos tanques fuelAmount1
y fuelAmount2
.
Supongamos también que las naves espaciales ahora llenan dos tipos de combustible en lugar de uno (es decir, algunas naves espaciales tienen dos motores diferentes que funcionan con dos tipos diferentes de combustible):
public class RocketFuelStation {
private static int fuelAmount1;
private static volatile int fuelAmount2;
public void refillFuel1(Spaceship ship, int amount) {
// Perform checks...
ship.refill(amount);
this.fuelAmount1 -= amount;
}
public void refillFuel2(Spaceship ship, int amount) {
// Perform checks...
ship.refill(amount);
this.fuelAmount2 -= amount;
}
// Constructor, Getters and Setters
}
Si la primera nave espacial decide ahora recargar ambos tipos de combustible, puede hacerlo así:
station.refillFuel1(spaceship1, 41);
station.refillFuel2(spaceship1, 42);
Las variables de combustible se actualizarán internamente como:
fuelAmount1 -= 41; // Non-volatile write
fuelAmount2 -= 42; // Volatile write
En este caso, aunque solo fuelAmount2
es volátil, fuelAmount1
también se escribirá en la memoria principal, justo después de la escritura volátil. Por lo tanto, ambas variables serán inmediatamente visibles para la segunda nave espacial.
La Garantía Happens-Before asegurará que todas las variables actualizadas (incluidas las no volátiles) se escriban en la memoria principal junto con las variables volátiles.
Sin embargo, vale la pena señalar que este tipo de comportamiento ocurre solo si se actualizan las variables no volátiles antes de los volátiles. Si la situación se invierte, no se ofrecen garantías.
Insuficiencia de volátiles
Hasta ahora hemos mencionado algunas formas en las que volatile
puede ser muy útil. Veamos ahora una situación en la que no es suficiente.
Exclusión mutua
Hay un concepto muy importante en la programación multiproceso llamado Exclusión Mutua. La presencia de exclusión mutua garantiza que solo un hilo a la vez pueda acceder a una variable / objeto compartido. El primero en acceder lo bloquea y hasta que termine con la ejecución y lo desbloquee, otros subprocesos tienen que esperar.
Al hacerlo, evitamos una condición de carrera entre varios subprocesos, lo que puede provocar que la variable se corrompa. Esta es una forma de resolver el problema con varios subprocesos que intentan acceder a una variable.
Ilustremos este problema con un ejemplo concreto para ver por qué las condiciones de carrera son indeseables:
Te puede interesar:Tutorial de Spring ReactorImagina que dos hilos comparten un contador. El hilo A lee el valor actual del contador (41
), agrega 1
y luego escribe el nuevo valor (42
) de regreso a la memoria principal. Mientras tanto (es decir, mientras el subproceso A agrega 1
al contador), el hilo B hace lo mismo: lee el valor (antiguo) del contador, añade 1
y luego vuelve a escribir esto en la memoria principal.
Dado que ambos hilos leen el mismo valor inicial (41
), el valor final del contador será 42
en vez de 43
.
En casos como este, usar volatile
no es suficiente porque no asegura la exclusión mutua. Este es exactamente el caso resaltado arriba, cuando ambos hilos alcanzan el fuelAmount -= amount
declaración antes de que el primer hilo llegue al System.out.println()
declaración.
En su lugar, la palabra clave sincronizada se puede utilizar aquí porque garantiza tanto la visibilidad como la exclusión mutua, a diferencia de volatile
lo que asegura solo visibilidad.
Por qué no usar synchronized
siempre entonces?
Debido al impacto en el rendimiento, no se exceda. Si necesita ambos, use synchronized
. Si solo necesita visibilidad, use volatile
.
Las condiciones de carrera ocurren en situaciones en las que dos o más subprocesos leen y escriben en una variable compartida cuyo nuevo valor depende del valor anterior.
En caso de que los subprocesos nunca necesiten leer el valor anterior de la variable para determinar el nuevo, este problema no ocurre porque no hay un período de tiempo corto en el que podría ocurrir la condición de carrera.
Conclusión
volatile
es una palabra clave de Java que se utiliza para garantizar la visibilidad de las variables en entornos multiproceso. Como hemos visto en la última sección, no es un mecanismo perfecto de seguridad para subprocesos, pero no estaba destinado a serlo.
volatile
puede verse como una versión más ligera de synchronized
ya que no garantiza la exclusión mutua, por lo que no debe utilizarse como reemplazo.
Sin embargo, dado que ofrece menos protección que synchronized
, volatile
también provoca menos gastos generales, por lo que se puede utilizar de forma más generosa.
Al final, todo se reduce a la situación exacta que debe manejarse. Si el rendimiento no es un problema, entonces tener un programa totalmente seguro para subprocesos con todo synchronized
no duele. Pero si la aplicación necesita tiempos de respuesta rápidos y poca sobrecarga, entonces es necesario tomarse un tiempo y definir partes críticas del programa que deben ser más seguras y aquellas que no requieren medidas tan estrictas.