Concurrencia en Java: la palabra clave volátil

C

Introducción

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.

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 extiende Thread, lo que significa que tenemos que implementar run() método.
  • Una vez que instanciamos el Spaceship clase y llamada start(), la run() 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:

Imagina que dos hilos comparten un contador. El hilo A lee el valor actual del contador (41), agrega 1y 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 1y 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.

About the author

Ramiro de la Vega

Bienvenido a Pharos.sh

Soy Ramiro de la Vega, Estadounidense con raíces Españolas. Empecé a programar hace casi 20 años cuando era muy jovencito.

Espero que en mi web encuentres la inspiración y ayuda que necesitas para adentrarte en el fantástico mundo de la programación y conseguir tus objetivos por difíciles que sean.

Add comment

Sobre mi

Últimos Post

Etiquetas

Esta web utiliza cookies propias para su correcto funcionamiento. Al hacer clic en el botón Aceptar, aceptas el uso de estas tecnologías y el procesamiento de tus datos para estos propósitos. Más información
Privacidad