Diferentes Formas de Implementar un Mutex en Java

Introducción

En esta entrada de blog, exploraremos diferentes maneras de implementar un mutex en Java. A medida que las aplicaciones se vuelven más concurrentes y se aprovechan múltiples hilos, la necesidad de administrar el acceso a recursos compartidos se vuelve crítica. Sin una gestión adecuada, los hilos pueden interferir entre sí, creando condiciones indeseadas o, lo que se conoce como condiciones de carrera. A través del uso de mutex, podemos synchronizar el acceso a secciones críticas de nuestro código, garantizando un funcionamiento correcto y predecible.

1. Overview

En este tutorial, discutiremos detalladamente qué es un mutex, los problemas asociados con el acceso concurrente a recursos compartidos y cómo se pueden implementar diversas técnicas en Java para evitar condiciones de carrera. Comenzaremos con una explicación básica de los mutex y las condiciones de carrera.

2. Mutex

En una aplicación multihilo, dos o más hilos pueden necesitar acceder a un recurso compartido al mismo tiempo, lo que resulta en un comportamiento inesperado. Ejemplos de esos recursos compartidos son estructuras de datos, dispositivos de entrada-salida, archivos y conexiones de red.

A esta situación la llamamos condición de carrera. Y la parte del programa que accede al recurso compartido se conoce como sección crítica. Por lo tanto, para evitar una condición de carrera, es necesario sincronizar el acceso a la sección crítica.

Un mutex (o mutua exclusión) es el tipo más simple de sincronizador: asegura que solo un hilo pueda ejecutar la sección crítica de un programa informático a la vez.

Para acceder a una sección crítica, un hilo adquiere el mutex, luego accede a la sección crítica y finalmente libera el mutex. En el entretanto, todos los demás hilos se bloquean hasta que el mutex sea liberado. Tan pronto como un hilo sale de la sección crítica, otro hilo puede entrar.

3. ¿Por qué utilizar un mutex?

Primero, tomemos un ejemplo de una clase SequenceGenerator, que genera la siguiente secuencia incrementando el currentValue en uno cada vez:

public class SequenceGenerator {
    
    private int currentValue = 0;

    public int getNextSequence() {
        currentValue = currentValue + 1;
        return currentValue;
    }
}

Ahora, creemos un caso de prueba para ver cómo se comporta este método cuando varios hilos intentan acceder a él de manera concurrente:

@Test
public void givenUnsafeSequenceGenerator_whenRaceCondition_thenUnexpectedBehavior() throws Exception {
    int count = 1000;
    Set<Integer> uniqueSequences = getUniqueSequences(new SequenceGenerator(), count);
    Assert.assertEquals(count, uniqueSequences.size());
}

private Set<Integer> getUniqueSequences(SequenceGenerator generator, int count) throws Exception {
    ExecutorService executor = Executors.newFixedThreadPool(3);
    Set<Integer> uniqueSequences = new LinkedHashSet<>();
    List<Future<Integer>> futures = new ArrayList<>();

    for (int i = 0; i < count; i++) {
        futures.add(executor.submit(generator::getNextSequence));
    }

    for (Future<Integer> future : futures) {
        uniqueSequences.add(future.get());
    }

    executor.awaitTermination(1, TimeUnit.SECONDS);
    executor.shutdown();

    return uniqueSequences;
}

Una vez que ejecutamos este caso de prueba, podemos ver que falla la mayoría de las veces, con un motivo similar a:

java.lang.AssertionError: expected:<1000> but was:<989>
  at org.junit.Assert.fail(Assert.java:88)
  ...

El conjunto uniqueSequences se supone que tiene un tamaño igual al número de veces que hemos ejecutado el método getNextSequence en nuestro caso de prueba. Sin embargo, este no es el caso debido a la condición de carrera. Obviamente, no queremos este comportamiento.

Por ello, para evitar tales condiciones de carrera, necesitamos garantizar que solo un hilo pueda ejecutar el método getNextSequence a la vez. En estos escenarios, podemos utilizar un mutex para sincronizar los hilos.

4. Usando la palabra clave synchronized

Primero, discutiremos la palabra clave synchronized, que es la forma más sencilla de implementar un mutex en Java.

Cada objeto en Java tiene un bloqueo intrínseco asociado a él. El método synchronized y el bloque synchronized utilizan este bloqueo intrínseco para restringir el acceso a la sección crítica a un solo hilo a la vez.

Por lo tanto, cuando un hilo invoca un método synchronized o entra en un bloque synchronized, automáticamente adquiere el bloqueo. El bloqueo se libera cuando el método o bloque se completa o se lanza una excepción desde ellos.

Cambiemos getNextSequence para que tenga un mutex, simplemente añadiendo la palabra clave synchronized:

public class SequenceGeneratorUsingSynchronizedMethod extends SequenceGenerator {
    
    @Override
    public synchronized int getNextSequence() {
        return super.getNextSequence();
    }
}

El bloque synchronized es similar al método synchronized, con más control sobre la sección crítica y el objeto que podemos usar para el bloqueo.

Veamos ahora cómo usar un bloque synchronized para sincronizarnos en un objeto mutex personalizado:

public class SequenceGeneratorUsingSynchronizedBlock extends SequenceGenerator {
    
    private Object mutex = new Object();

    @Override
    public int getNextSequence() {
        synchronized (mutex) {
            return super.getNextSequence();
        }
    }
}

5. Usando ReentrantLock

La clase ReentrantLock fue introducida en Java 1.5. Proporciona más flexibilidad y control que el enfoque de la palabra clave synchronized.

Veamos cómo podemos utilizar ReentrantLock para lograr la exclusión mutua:

public class SequenceGeneratorUsingReentrantLock extends SequenceGenerator {
    
    private ReentrantLock mutex = new ReentrantLock();

    @Override
    public int getNextSequence() {
        try {
            mutex.lock();
            return super.getNextSequence();
        } finally {
            mutex.unlock();
        }
    }
}

6. Usando Semaphore

Al igual que ReentrantLock, la clase Semaphore también fue introducida en Java 1.5.

Mientras que en el caso de un mutex solo un hilo puede acceder a una sección crítica, Semaphore permite que un número fijo de hilos acceda a la sección crítica. Por lo tanto, también podemos implementar un mutex configurando el número de hilos permitidos en un Semaphore a uno.

Creemos otra versión thread-safe de SequenceGenerator usando Semaphore:

public class SequenceGeneratorUsingSemaphore extends SequenceGenerator {
    
    private Semaphore mutex = new Semaphore(1);

    @Override
    public int getNextSequence() {
        try {
            mutex.acquire();
            return super.getNextSequence();
        } catch (InterruptedException e) {
            // Manejo de excepciones
        } finally {
            mutex.release();
        }
    }
}

7. Usando la clase Monitor de Guava

Hasta ahora, hemos visto opciones para implementar mutex usando las características proporcionadas por Java. Sin embargo, la clase Monitor de la biblioteca Guava de Google es una mejor alternativa a la clase ReentrantLock. Según su documentación, el código que utiliza Monitor es más legible y menos propenso a errores que aquel que utiliza ReentrantLock.

Primero, añadiremos la dependencia de Maven para Guava:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
</dependency>

Ahora escribiremos otra subclase de SequenceGenerator utilizando la clase Monitor:

public class SequenceGeneratorUsingMonitor extends SequenceGenerator {
    
    private Monitor mutex = new Monitor();

    @Override
    public int getNextSequence() {
        mutex.enter();
        try {
            return super.getNextSequence();
        } finally {
            mutex.leave();
        }
    }
}

8. Conclusión

En este tutorial, hemos abordado el concepto de mutex y hemos examinado las diferentes maneras de implementarlo en Java. A medida que las aplicaciones se vuelven más complejas y se utilizan múltiples hilos, es fundamental garantizar la coherencia y la integridad de los datos compartidos. Las implementaciones ofrecidas, ya sea a través de la palabra clave synchronized, ReentrantLock, Semaphore, o usando bibliotecas externas como Guava, son valiosas herramientas en el arsenal de un programador.

Consejos Prácticos

  • Siempre utiliza un mutex si accedes a recursos compartidos: Si tienes que compartir datos entre hilos, utiliza un mutex para evitar condiciones no deseadas.
  • Minimiza el trabajo dentro de secciones críticas: Mantén el código dentro de los bloques sincronizados lo más ligero posible para mejorar la concurrencia.
  • Usa ReentrantLock para mayor flexibilidad: Si necesitas más características o control sobre las condiciones de espera y la interrupción, ReentrantLock es la mejor opción.
  • Revisa Guava para un enfoque más limpio: La clase Monitor puede ayudarte a mejorar la legibilidad de tu código y reducir errores.

Al implementar correctamente un mutex en Java, no solo mejorarás la estabilidad y el rendimiento de tu aplicación, sino que también te protegerás contra errores difíciles de detectar relacionados con la concurrencia.