Skip to content

9 ReentrantReadWriteLock

ReentrantReadWriteLock

En algunas ocasiones, el recurso que estamos protegiendo mediante un cerrojo es accedido para lectura con una frecuencia mucho mayor que para escritura. Como ya sabemos, el acceso concurrente para lectura no es problemático, por lo que en estos casos, el uso de un objeto ReentrantLock para proteger el acceso al recurso supone un coste de ejecución excesivo teniendo en cuenta que el acceso para escritura es muy poco frecuente y que el acceso concurrente para lectura no debería bloquear a los hilos.

Cabría preguntarse los siguiente: ¿si varios hilos sólo van a leer el recurso y otros van a modificar el recurso, por qué es necesario que los hilos que sólo van a leer adquieran un cerrojo de lectura? ¿por qué no pueden simplemente leer el recurso?

La respuesta es que es necesario que adquieran un cerrojo de lectura porque de lo contrario puede que cuando un hilo lea el recurso otro hilo esté en medio de una operación de actualización del recurso, y entonces el hilo que lee el recurso obtendría un recurso inconsiste a medio actualizar.

Por ejemplo, supongamos que el recurso es un producto y el hilo escritor está actualizando su nombre y su precio, para lo que usa un cerrojo de escritura. Si los hilos lectores no usaran un cerrojo de lectura, y simplemente accedieran al recurso, podría ocurrir que recibieran una copia del producto en el que sólo estuviera actualizado el nombre del producto pero no el precio, si da la casualidad de que dicha lectura se produce justo entre el momento en el que el hilo de escritura actualiza el nombre y el momento en el que actualiza el precio.

ReentrantReadWriteLock

Cerrojo que proporciona dos modos distintos de adquisición independientes, uno para lectura y otro para escritura

Para estos casos concretos, la librería de concurrencia de Java proporciona la interfaz ReadWriteLock y la clase ReentrantReadWriteLock, la única que la implementa. La interfaz ReadWriteLock proporciona dos cerrojos Lock distintos, uno para operaciones de lectura y otro para operaciones de escritura.

Cuando un hilo adquiere el cerrojo de lectura, ningún otro hilo puede adquirir el cerrojo de escritura, pero si que es posible que otros hilos adquieran también el cerrojo de lectura, y por tanto ejecuten operaciones de lectura simultáneamente.

Lectores en un ReadWriteLock

Figura 8 - Lectores en un ReadWriteLock

Por otra parte, si un hilo adquiere el cerrojo de escritura, ningún otro hilo puede adquirir el cerrojo de escritura ni el de lectura.

Escritor en un ReadWriteLock

Figura 9 - Escritor en un ReadWriteLock

Si hemos activado el modo justo, el comportamiento es algo más complejo:

  • Si un hilo lector trata de adquirir el cerrojo de lectura (de forma no reentrante), será bloqueado si el cerrojo de escritura ha sido adquirido o hay algún hilo escritor más antiguo que él esperando adquirirlo.
  • Si un hilo escritor trata de adquirir el cerrojo de escritura (de forma no reentrante), será bloqueado si el cerrojo de escritura o el cerrojo de lectura han sido adquiridos, o si hay algún hilo esperando para adquirirlo. Debemos tener en cuenta que el método tryLock() no respeta el modo justo, por lo que el hilo adquiriría el cerrojo si no ha sido adquirido, independientemente de que haya algún hilo esperando.

En teoría, el incremento de la concurrencia proporcionado por el uso de un cerrojo ReadWriteLock conllevará una mejora del rendimiento frente al uso de un simple cerrojo de exclusión mutua Lock. Sin embargo esto dependerá de la frecuencia con la que se producen acceso de lectura frente a los accesos de escritura, la duración del acceso y el número de accesos concurrentes, por lo que sólo debemos usar cerrojos ReadWriteLock cuando se den las condiciones adecuadas.

La interfaz ReadWriteLock proporciona dos métodos, el método readLock(), que retorna el objeto Lock correspondiente al cerrojo de lectura, y el método writeLock(), que retorna el objeto Lock correspondiente al cerrojo de escritura.

Una vez obtenido el objeto Lock deseado, podremos usar sobre él los métodos que ya estudiamos para adquirir o liberar el cerrojo, como lock(), unlock(), tryLock(), lockInterruptibly() y newCondition().

Lock readLock = reentrantReadWriteLock.readLock();
readLock.lock();
try {
    // Acceso sólo para lectura.
    // ...
} finally {
    readLock.unlock();    
}

Warning

Es responsabilidad del programador usar el cerrojo adecuado, lectura o escritura, dependiendo de las operación que se quiera realizar

Un aspecto muy importante es que es responsabilidad del programador asegurar el uso correcto de los cerrojos, realizando con ellos las operaciones para las que han sido diseñados. Por ejemplo, cuando obtenemos un cerrojo de lectura, no deberíamos modificar el valor de la variable o estructura de datos protegida, o de lo contrario podemos tener problemas de inconsistencia de datos.

La clase principal que implementa la interfaz es ReadWriteLock es ReentrantReadWriteLock, que como su nombre indica implementa cerrojos de lectura y escritura reeentrantes, es decir que no es necesario readquirirlo si el hilo ya lo posee cuando llama a un determinado método que lo requiere, incluso recursivamente. El constructor de la clase ReentrantReadWriteLock(boolean)puede recibir un valor booleano indicativo de si hay que activar el modo justo.

El cerrojo de escritura de ReentrantReadWriteLock proporciona una implementación del método newCondition() similar a la de la clase ReentrantLock, por lo que podremos establecer condiciones para el cerrojo de escritura. Sin embargo, el cerrojo de lectura no permite el uso de objetos Condition y lanzará la excepción UnsupportedOperationException si llamamos a su método newCondition() sobre él.

Otra posibilidad es la operación conocida como degradado de cerrojo (lock downgrading), según la cuál en un cerrojo ReentrantReadWriteLock podemos adquirir el cerrojo de escritura y, sin haberlo liberado aún explícitamente, adquirir el cerrojo de lectura. Posteriormente, una vez adquirido el de lectura, podemos liberar el de escritura, leer el valor y finalmente liberar el de lectura. ¿Qué conseguimos con esta operación? Pasar de un cerrojo de escritura a uno de lectura sin tener que volver a competir por obtener el de lectura.

```java
// Adquire write lock from the readWriteLock.
Lock lock = readWriteLock.writeLock();
lock.lock();
try {
    // Perform write operation.
    // ...
    // Adquire the read lock from the readWriteLock.
    final readLock = readWriteLock.readLock();
    readLock.lock();
    try {
        // Try to release write lock. From this moment on, other threads can read the resource.
        lock.unlock();
        // Perform read operation.
        // ...
    } finally {
        // The lock to release eventually is the readLock
        lock = readLock;
    }
} finally {
    // Release the reaming lock.
    lock.unlock();
}
```

Sin embargo la operación contraria (lock upgrading) no está permitida, es decir, si obtenemos el cerrojo de lectura y después queremos obtener el de escritura deberemos antes liberar explícitamente el cerrojo de lectura, volviendo a competir para adquirir el de escritura.

Proyecto ReadWriteLock

En este proyecto crearemos una aplicación que simula la venta de un producto en una tienda online. Por un lado existirán hilos clientes que consultarán el precio del producto y por otra parte un hilo que representa la tienda vendedora que incrementa el precio del producto de vez en cuando. Usaremos la clase ReentrantReadWriteLock para permitir que varios hilos clientes consulten el precio a la vez.

import java.util.concurrent.TimeUnit;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Product product = new Product(100.00);
        Thread[] clientThreads= new Thread[4];
        for (int i = 0; i < 4; i++) {
            clientThreads[i] = new Thread(new Client(product), "Client " + i);
        }
        Thread shopThread = new Thread(new Shop(product), "Shop");
        shopThread.start();
        // Wait to start some clients.
        TimeUnit.SECONDS.sleep(1);
        for (int i = 0; i < 2; i++){
            clientThreads[i].start();
        }
        // Wait to start the rest of the clients.
        TimeUnit.SECONDS.sleep(3);
        for (int i = 2; i < 4; i++){
            clientThreads[i].start();
        }
        // Try to check a client thread blocks the shop thread but not other clients threads.
        // Try to check the shop thread blocks client threads.
    }

}
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Product {

    private double price;
    private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
    private final ReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = reentrantReadWriteLock.readLock();
    private final Lock writeLock = reentrantReadWriteLock.writeLock();

    public Product(double initialPrice) {
        this.price = initialPrice;
    }

    public double getPrice() throws InterruptedException {
        readLock.lock();
        try {
            return consultPrice();
        } finally {
            readLock.unlock();
        }
    }

    private double consultPrice() throws InterruptedException {
        System.out.printf("%s -> %s - Consulting price...\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName());
        TimeUnit.SECONDS.sleep(3);
        System.out.printf("%s -> %s - Price: %.2f\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName(),
                price);
        return price;
    }

    public void updatePrice(double increment) throws InterruptedException {
        writeLock.lock();
        try {
            incrementPrice(increment);
        } finally {
            writeLock.unlock();
        }
    }

    private void incrementPrice(double increment) throws InterruptedException {
        System.out.printf("%s -> %s - Updating price...\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(1);
        this.price += increment;
        System.out.printf("%s -> %s - New price: %.2f\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName(),
                this.price);
    }

}
import java.util.Objects;

public class Client implements Runnable {

    private final Product product;

    public Client(Product product) {
        Objects.requireNonNull(product);
        this.product = product;
    }

    @Override
    public void run() {
        try {
            @SuppressWarnings("unused")
            double price = product.getPrice();
        } catch (InterruptedException e) {
            System.out.println("I've been interrupted while consulting the price");
        }
    }

}
import java.util.Objects;
import java.util.concurrent.TimeUnit;

public class Shop implements Runnable {

    private final Product product;

    public Shop(Product product) {
        Objects.requireNonNull(product);
        this.product = product;
    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            try {
                product.updatePrice(20.0);
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                System.out.println("I've been interrupted while updating the price");
                return;
            }
        }
    }

}

Si ejecutamos la aplicación veremos que varios lectores pueden estar leyendo a la vez, pero sólo uno puede estar escribiendo. Cambia los tiempos de los sleep() para tratar que un hilo cliente bloquee a un hilo tienda pero no a otros hilos cliente. Después trata de que un hilo tienda bloquee a hilos clientes.