Concurrencia en Java: la palabra clave vol谩til

    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.

    Etiquetas:

    Deja una respuesta

    Tu direcci贸n de correo electr贸nico no ser谩 publicada. Los campos obligatorios est谩n marcados con *