Concurrencia en Java: la palabra clave volátil

Los subprocesos múltiples a menudo conducen a dolores de cabeza. En este artículo, echaremos un vistazo a la palabra clave volátil, a menudo mal entendida, en Java y cómo se usa en un entorno de subprocesos múltiples.

Introducción

Multithreading 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", el diseño de un programa de subprocesos múltiples se vuelve mucho menos sencillo que escribir software con un solo subproceso de ejecución.

En este artículo, veremos algunos problemas comunes de subprocesos múltiples que podemos superar usando la palabra clave volátil.

También veremos algunos problemas más complejos en los que “volátil” no es suficiente para solucionar la situación, lo que significa que es necesario actualizar otros mecanismos de seguridad.

Visibilidad variable

Hay un problema común con la visibilidad de las variables en entornos de subprocesos múltiples. Supongamos que tenemos una variable (u objeto) compartida a la que acceden dos subprocesos diferentes (cada subproceso en su propio procesador).

Si un subproceso actualiza la variable/objeto, no podemos saber con certeza cuándo exactamente este cambio será visible para el otro subproceso. La razón por la que esto sucede es por el almacenamiento en caché de la CPU.

Cada subproceso 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 un caché local:

Caché de CPU en Java
[Crédito de la imagen: Tutoriales de Jenkov]{.small}

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 de Thread 2 puede depender del resultado de Thread 1, por lo que trabajar en el valor obsoleto producirá un resultado completamente diferente.

Finalmente, cuando les gustaría enviar los cambios a 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 incoherente grave. No podría confiar en los resultados y su sistema tendría que realizar comprobaciones costosas para intentar obtener el valor actualizado, posiblemente sin garantía.

En resumen, su aplicación se rompería.

La palabra clave volátil

La palabra clave volátil marca una variable como volátil. Al hacerlo, la JVM garantiza que el resultado de cada operación de escritura no se escribe en la memoria local sino en la memoria principal.

Esto significa que cualquier subproceso en el entorno puede acceder a la variable compartida con el valor más reciente y actualizado sin preocupaciones.

Un comportamiento similar, pero no idéntico, se puede lograr con la palabra clave sincronizado.

Ejemplos

Echemos un vistazo a algunos ejemplos del uso de la palabra clave volátil.

Variable compartida simple

En el siguiente ejemplo de código, 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 para cohetes representa un recurso/variable compartido (algo que se puede cambiar desde el "exterior") mientras que las naves espaciales representan hilos (cosas que cambian la variable).

Avancemos ahora y definamos una RocketFuelStation. Cada Spaceship tendrá una RocketFuelStation como campo, ya que están asignados a ella y, como se esperaba, fuelAmount es static. Si una nave espacial toma algo de combustible de la estación, también debería reflejarse en la instancia que pertenece a otro objeto:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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 la “cantidad” que deseamos verter en un barco es mayor que la “cantidad de combustible” que queda en el tanque, notificamos al usuario que no es posible rellenar tanto. Si no, felizmente recargamos el barco y reducimos la cantidad que queda en el tanque.

Ahora, dado que cada ‘Spaceship’ se ejecutará en un ‘Thread’ diferente, tendremos que ’extender’ la clase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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í:

  • El RocketFuelStation se pasa al constructor, este es un objeto compartido.
  • La clase Spaceship extiende Thread, lo que significa que tenemos que implementar el método run().
  • Una vez que creamos una instancia de la clase Spaceship y llamamos a start(), también se ejecutará el método run().

Lo que esto significa es que una vez que creamos una nave espacial y la ponemos en marcha, se recargará desde la ‘RocketFuelStation’ compartida con 50 litros de combustible.

Y finalmente, ejecutemos este código para probarlo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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é subproceso se ejecutará primero en Java, las sentencias System.out.println() se ubican después de ejecutar los métodos join() en los subprocesos. El método join() espera a que el subproceso muera, por lo que sabemos que imprimimos los resultados después de que los subprocesos realmente finalicen. De lo contrario, podemos encontrarnos con un comportamiento inesperado. No siempre, pero es una posibilidad.

Una nueva RocketFuelStation() se fabrica con 100 litros de combustible. Una vez que arranquemos ambos barcos, ambos deberían tener 50 litros de combustible y la estación debería tener 0 litros de combustible.

Veamos qué sucede cuando ejecutamos el código:

1
2
3
4
5
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:

1
2
3
4
5
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 estación de combustible. Intentémoslo de nuevo:

1
2
3
4
5
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.

Avancemos y actualicemos la clase RocketFuelStation:

1
2
3
4
5
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 fuelAmount es volátil y que debe omitir el paso de guardar el valor en caché y enviarlo directamente a la memoria principal.

También cambiaremos la clase Spaceship:

1
2
3
4
public class Spaceship extends Thread {
    private volatile int fuel;

    // ...

Dado que el combustible también puede almacenarse en caché y actualizarse incorrectamente.

Cuando ejecutamos el código anterior ahora, obtenemos:

1
2
3
4
5
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:

1
2
3
4
5
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:

1
2
3
4
5
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 "Rocket Fuel Station tiene 0l de combustible restante", el segundo subproceso llegó a la línea fuelAmount -= cantidad antes de que el primer subproceso llegara a System. out.println() en esta declaración if:

1
2
3
4
5
if (amount <= fuelAmount) {
    ship.refill(amount);
    fuelAmount -= amount;
    System.out.println("Rocket Fuel Station has " + fuelAmount + "l of fuel left");
}

Si bien aparentemente produce una salida incorrecta, esto es inevitable cuando trabajamos en paralelo con esta implementación. Esto sucede debido a la falta de Exclusión Mutua cuando se usa la palabra clave “volátil”. Más sobre eso en Insuficiencia de volátiles.

Lo importante es que el resultado final: 50 litros de combustible en cada nave espacial y 0 litros de combustible en la estación.

Ocurre antes de la garantía

Supongamos ahora que nuestra estación de carga es un poco más grande y que tiene dos dispensadores 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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 ahora decide recargar ambos tipos de combustible, puede hacerlo así:

1
2
station.refillFuel1(spaceship1, 41);
station.refillFuel2(spaceship1, 42);

Las variables de combustible se actualizarán internamente como:

1
2
fuelAmount1 -= 41; // Non-volatile write
fuelAmount2 -= 42; // Volatile write

En este caso, aunque solo ‘cantidadcombustible2’ es volátil, ‘cantidadcombustible1’ también se escribirá en la memoria principal, justo después de la escritura volátil. Así, ambas variables serán inmediatamente visibles para la segunda nave espacial.

La Garantía antes de que suceda se asegurará de 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 las variables no volátiles se actualizan antes que las volátiles. Si la situación se invierte, entonces no se hacen garantías.

Insuficiencia de volátiles

Hasta ahora, hemos mencionado algunas formas en las que “volátil” 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 de subprocesos múltiples llamado Exclusión mutua. La presencia de exclusión mutua garantiza que solo se puede acceder a una variable/objeto compartido por un subproceso a la vez. El primero en acceder lo bloquea y hasta que termina con la ejecución y lo desbloquea, otros subprocesos tienen que esperar.

Al hacerlo, evitamos una condición de carrera entre varios subprocesos, lo que puede causar 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 están compartiendo un contador. El subproceso A lee el valor actual del contador (41), agrega 1 y luego escribe el nuevo valor (42) en la memoria principal. Mientras tanto (es decir, mientras Thread A agrega 1 al contador), Thread B hace lo mismo: lee el valor (antiguo) del contador, agrega 1 y luego lo vuelve a escribir en la memoria principal.

Dado que ambos subprocesos leen el mismo valor inicial (41), el valor final del contador será 42 en lugar de 43.

En casos como este, usar volátil no es suficiente porque no garantiza la Exclusión Mutua. Este es exactamente el caso resaltado anteriormente: cuando ambos subprocesos alcanzan la instrucción cantidad de combustible -= cantidad antes de que el primer subproceso alcance la declaración System.out.println().

En cambio, la palabra clave sincronizado se puede usar aquí porque asegura tanto visibilidad como exclusión mutua, a diferencia de volátil que solo asegura visibilidad.

¿Por qué no usar synchronized siempre entonces?

Debido al impacto en el rendimiento, no se exceda. Si necesita ambos, use sincronizado. Si solo necesita visibilidad, use volátil.

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 antiguo.

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 corto de tiempo 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 de subprocesos, pero no estaba destinado a serlo.

volátil puede verse como una versión más ligera de sincronizado, ya que no garantiza la exclusión mutua, por lo que no debe usarse como su reemplazo.

Sin embargo, dado que ofrece menos protección que “sincronizado”, “volátil” también genera menos gastos generales, por lo que se puede utilizar con mayor libertad.

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 “sincronizado” no duele. Pero si la aplicación necesita tiempos de respuesta rápidos y poca sobrecarga, entonces es necesario tomarse un tiempo y definir las partes críticas del programa que deben ser más seguras y aquellas que no requieren medidas tan estrictas.