Exclusión mutua en JAVA

  • Java no tiene semáforos a nivel del lenguaje (sí en la biblioteca estándar).

  • Proporciona primitivas para gestionar la concurrencia integradas en la propia sintaxis.

  • Utiliza monitores (intrinsic locks) asociados a cada objeto.

  • En un enfoque orientado a objetos, la concurrencia implica que varios hilos pueden ejecutar métodos del mismo objeto.

  • Conseguimos la exclusión mutua mediante la palabra reservada synchronized.

  • Un método con el modificador synchronized se ejecuta en exclusión mutua respecto de otros métodos/bloques synchronized del mismo objeto.

  • Es posible sincronizar bloques (synchronized(this) u otro candado) y también a nivel de clase (static / NombreDeClase.class).

  • Los candados son reentrantes: el mismo hilo puede adquirir el mismo cerrojo varias veces.

  • synchronized también establece orden y visibilidad (relación happens-before) entre hilos: al salir de un bloque synchronized, todas las escrituras realizadas dentro quedan garantizadas como visibles para el hilo que entre después con el mismo cerrojo.

Utilización de synchronized

  • En este ejemplo tenemos una clase con 2 bloques synchronized (m2 y m4).

    public class Sincronizados {
        public              void m1() {}
        public synchronized void m2() {}
        public              void m3() {}
        public synchronized void m4() {}
        public              void m5() {}
    }
    

    syncexample.png

    Figure 1: Ejemplo de sincronización en Java.

    Estas funciones serán accedidas por exclusión mutua.

  • Un bloque de código también puede ser sincronizado. En este caso necesitamos hacer referencia a un objeto:

    public class Sincronizada {
        public void metodo1() {
              // instrucciones
    
              synchronized(this) {
                 // instrucciones que se ejecutarán en exclusión mutua con
                 // otro bloque synchronized
              }
    
              // instrucciones
         }
    }
    
  • El caso anterior lo podemos generalizar a cualquier objeto, no sólo this:

    public class Sincronizada {
        public void metodo1() {
              //instrucciones
    
              synchronized(otroObjeto) {
                 //instrucciones que se ejecutarán en exclusión mutua con
                 //el mutex de otroObjeto
              }
    
              //instrucciones
         }
    }
    
  • Podemos sincronizar a nivel de métodos de clase también:

    public class Sincronizada {
        public synchronized static void metodo1() {
              //instrucciones
         }
    }
    // O tambien
    ...
    synchronized (nombreClase.class) {
      //instrucciones
    }
    

Variables volatile

  • Las asignaciones de tipos primitivos y de referencias son atómicas; no hace falta synchronized si únicamente se escribe/lee un valor simple.
    (Nota: en algunas JVM/arquitecturas antiguas long y double podían no ser atómicos si no eran volatile; con volatile sí lo son).

  • Las dos clases siguientes son equivalentes cuando solo hay una asignación simple:

    public class S {               
        public void f() {             
            synchronized (this) {       
                boolean b = true;         
            }                           
        }
    }
    
    public class S1 {
        public void f() {
            boolean b = true;
        }
    }
    

    Usar synchronized no aporta nada si solo hay una escritura atómica y no se mantienen invariantes.

  • Las optimizaciones del compilador/JIT pueden provocar reordenamientos o problemas de visibilidad si varios hilos acceden a la misma variable sin una relación happens-before. Ejemplo:

    public class C {
        private boolean v = false;
    
        public void cambiaV() {
            v = true;
        }
    
        public synchronized void esperaVCierto() {
            v = false;
            if (v)
                // Código que no será ejecutado
        }
    }
    
    • synchronized garantiza que no se ejecuten a la vez otros métodos sincronizados del mismo objeto, pero cambiaV no lo está, así que puede ejecutarse concurrentemente con esperaVCierto.
    • El compilador/JIT puede eliminar el cuerpo del if porque dentro de esperaVCierto se asigna v = false y, sin una relación de orden/visibilidad con cambiaV, “asume” que v seguirá siendo false en ese punto.
  • Para evitar esas optimizaciones y asegurar visibilidad, declara v como volatile. Indicar que una variable es volatile comunica a la JVM/JIT que puede ser modificada por otros hilos y garantiza que la escritura volatile ocurre antes (happens-before) de la lectura volatile posterior de esa misma variable.

Sincronización

  • Además de la exclusión mutua (proteger el estado con synchronized), necesitamos coordinar cuándo los hilos deben esperar a que una condición del programa se cumpla.
  • Esta coordinación se realiza con las primitivas del monitor del objeto: wait(), notify() y notifyAll(), que siempre se usan dentro de una sección sincronizada con el mismo cerrojo.
  • En Java, cada objeto funciona como monitor y tiene un wait set que actúa como variable de condición asociada a su candado:
    • wait(): el hilo espera en la variable de condición (libera el cerrojo y se suspende).
    • notify() / notifyAll(): señalizan la variable de condición (despiertan uno o a todos; al reanudar, deben recompetir el candado).

Sincronización, guarda boolena

synchronized void hacerCuandoCondicion() {
    while(!c)
        try {
            wait();
        } catch(InterruptedException e) {}

    // Aquí irá código que se ejecutará
    // cuando la condicion 'c' sea cierta
}

synchronized void hacerCondicionVerdadera() {
    c = true;
    notify();  // o notifyAll()
}
  • Vamos a tener dos conjuntos de hilos en espera:

    • los que quieren acceder a la zona de exclusión mutua
    • los que están dormidos esperando a ser despertados por un notify o notifyAll
  • Cuando un hilo hace wait, primero se suspende el hilo y luego se libera el candado (synchronized), con lo cual otro hilo puede entrar.

  • El while es necesario, ya que que se haya despertado un hilo no nos garantiza que la variable c pase a valer true

  • Un hilo despertado por notify tendrá que volver a luchar por conseguir el cerrojo.

    boolguard.png

    Figure 2: Ejemplo de guarda booleana.

  • Puedes ver un ejemplo completo en este vídeo.

Problemas clásicos

Productor-Consumidor:

  • Buffer limitado:

    public class Buffer {
        private int cima, capacidad, vector[];
    
        public Buffer(int i) {
            cima = 0;
            capacidad = i;
            vector = new int[i];
        }
    
        synchronized public int extraer(int id) {
            System.out.println(id+": espera para leer");
            while (cima == 0)
                try {
                    wait();
                } catch (InterruptedException e){}
            notifyAll();
            return vector[--cima];
        }
    
        synchronized public void insertar(int elem, int id) {
            System.out.println(id+": espera para insertar");
            while (cima == capacidad)
                try {
                    wait();
                } catch (InterruptedException e){}
            vector[cima] = elem;
            cima++;
            notifyAll();
        }
    }
    
  • Consumidor:

    public class Consumidor extends Thread {
        int elem, id;
        Buffer buffer;
    
        Consumidor(Buffer b, int numHilo) {
            buffer = b;
            id = numHilo;
            System.out.println("CONSUMIDOR " + id +": entra");
        }
    
        public void run () {
            try {
                elem = buffer.extraer(id);
                System.out.println(id + ": he leido " + elem);
            } catch (Exception e) {}
        }
    }
    
  • Productor:

    public class Productor extends Thread {
        Buffer buffer;
        int elem, id;
    
        Productor(Buffer b, int i, int numHilo) {
            elem = i;
            buffer = b;
            id = numHilo;
            System.out.println("PRODUCTOR " + id + ": entra");
        }
    
        public void run () {
            try {
                buffer.insertar(elem, id);
                System.out.println(id + ": inserta " + elem);
            } catch (Exception e) {}
        }
    }
    

  • Clase Principal:

    public class ProductorConsumidor {
        static Buffer buf = new Buffer(3);
        static int numcons = 7;
        static int numprods = 5;
    
        public static void main(String[] args) {
            for(int i = 1; i <= numprods; i++)
                new Productor(buf,i,i).start();
    
            for(int k = 1; k <= 1_000_000_000; k++);
                System.out.println();
    
            for(int j = 1; j <= numcons; j++)
                new Consumidor(buf, j).start();
    
            System.out.println("Fin del hilo main");
        }
    }
    
  • Traza:

    | PRODUCTOR 1: entra      | CONSUMIDOR 1: entra | CONSUMIDOR 5: entra |
    | PRODUCTOR 2: entra      | CONSUMIDOR 2: entra | 4: espera para leer |
    | 1: espera para insertar | 1: espera para leer | 4: he leido 2       |
    | 1: inserta 1            | 4: inserta 4        | CONSUMIDOR 6: entra |
    | PRODUCTOR 3: entra      | 1: he leido 3       | 5: espera para leer |
    | 2: espera para insertar | CONSUMIDOR 3: entra | 5: he leido 1       |
    | 2: inserta 2            | 2: espera para leer | 6: espera para leer |
    | PRODUCTOR 4: entra      | 5: inserta 5        | CONSUMIDOR 7: entra |
    | 3: espera para insertar | 2: he leido 4       | Fin del hilo main   |
    | 3: inserta 3            | CONSUMIDOR 4: entra | 7: espera para leer |
    | PRODUCTOR 5: entra      | 3: espera para leer |                     |
    | 4: espera para insertar | 3: he leido 5       |                     |
    | 5: espera para insertar |                     |                     |
    

Otros problemas clásicos

Semáforos

  • Java no tiene semáforos en el lenguaje, pero sí en la biblioteca estándar (java.util.concurrent.Semaphore). Aquí los definimos con monitores para ver su funcionamiento básico.

Semáforo binario

public class SemaforoBinario {
    protected int contador = 0;

    public SemaforoBinario(int valorInicial) {
        contador = valorInicial; // 1 o 0
    }

    synchronized public void wait() {
        while (contador == 0 )
            try {
                wait();
            } catch (Exception e) {}
        contador--;
    }

    synchronized public void signal() {
        contador = 1;
        notify();
    }
}

Semáforo general

public class SemaforoGeneral {

    public SemaforoGeneral(int valorInicial) {
        super(valorInicial); // numero natural
    }

    synchronized public void wait() {
        while (contador == 0 )
            try {
                wait();
            } catch (Exception e) {}
        contador--;
    }
    synchronized public void signal() {
        contador++;
        notify();
    }
}

ThreadPools

Conceptos básicos

  • En Java, los hilos de la JVM se mapean a hilos del S.O., por lo que son un recurso del sistema.

  • Si creamos hilos “sin control”:

    • Podemos agotar recursos del S.O. (demasiados hilos).
    • Aunque crear hilos es más barato que procesos, sigue habiendo sobrecarga para el S.O..
  • Más hilos ≠ más rendimiento: el S.O. hace cambios de contexto y con muchos hilos una parte relevante del tiempo se va en planificación, no en trabajo útil.

  • Un ThreadPool aparece para ahorrar recursos y contener el paralelismo dentro de unos límites.

    • En lugar de crear y destruir un hilo para cada tarea, un ThreadPool mantiene un grupo fijo o dinámico de hilos que se reutilizan para ejecutar varias tareas.
    • Controla cuántos hilos crea la aplicación, planifica tareas y mantiene una cola hasta que puedan atenderse.
    • Reutiliza hilos para múltiples tareas cortas y optimiza el rendimiento en situaciones de alta carga o alta concurrencia.

Implementación en Java

  • La clase auxiliar Executors ofrece factorías para crear ThreadPools preconfigurados (útiles si no necesitamos ajustes finos).

  • Trabajamos contra las interfaces Executor y ExecutorService para desacoplar el código de la implementación concreta del pool.

Ejemplo en Java

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // Crear un ThreadPool con 4 hilos
        ExecutorService executor = Executors.newFixedThreadPool(4);

        // Enviar tareas al ThreadPool
        for (int i = 1; i <= 10; i++) {
            int taskNumber = i;
            executor.submit(() -> {
                System.out.println("Ejecutando tarea " + taskNumber + " en " + Thread.currentThread().getName());
            });
        }

        // Apagar el pool
        executor.shutdown();
    }
}

ThreadPool en otros lenguajes

Ejemplo en Python

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def trabajo(i):
    time.sleep(0.2)
    return i * i

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=4) as pool:
        futures = [pool.submit(trabajo, i) for i in range(10)]
        for f in as_completed(futures):
            print("Resultado:", f.result())

También se suele usar concurrent.futures.ProcessPoolExecutor para evitar el bloqueo del GIL.

Ejemplo en Rust

// Cargo.toml
// [dependencies]
// threadpool = "1.8.1"

use threadpool::ThreadPool;
use std::sync::mpsc::channel;

fn main() {
    let pool = ThreadPool::new(4);
    let (tx, rx) = channel();

    for i in 0..10 {
        let tx = tx.clone();
        pool.execute(move || {
            let out = i * i;           // realizar trabajo 
            tx.send(out).unwrap();     // publicar resultado
        });
    }

    drop(tx); // cerrar emisor para terminar el bucle
    for r in rx {
        println!("Resultado: {r}");
    }
}

Más sobre concurrencia en Java

  • El API de Java 10:
    • La documentación general la puedes consultar en: java.util.concurrent
    • Allí encontrarás documentación sobre los cierres de exclusión mutua, barreras, semáforos, colecciones concurrentes (vectores, conjuntos, tablas, colas).

Candados (Locks) y Variables de Condición (Condition)

  • java.util.concurrent.locks
  • Interfaces Lock y Condition
    • Lock permite gestionar los cierres de forma explícita
      • lock()
      • unlock()
      • tryLock()
      • newCondition()
    • Condition permite establecer más de una cola asociada a un Lock (ejemplo BoundedBuffer)
      • await()
      • awaitUntil()
      • signal()
      • signalAll()

Colecciones seguras

  • BlockingQueue: Estructura FIFO que se bloquea (o espera un tiempo máximo) al insertar en cola llena o retirar de cola vacía. Varias implementaciones, p. ej. ArrayBlockingQueue
  • ConcurrentMap: Tabla hash con operaciones atómicas. Implementaciones como ConcurrentHashMap.

Tipos de datos atómicos

  • java.util.concurrent.atomic

  • Conjunto de clases que permiten realizar operaciones atómicas con variables de tipos básicos

    • AtomicBoolean
    • AtomicInteger
    • AtomicIntegerArray
    • AtomicLong
    • AtomicLongArray
  • Por ejemplo, AtomicInteger dispone de:

    • addAndGet
    • compareAndSet
    • decrementAndGet
    • getAndAdd
    • getAndDecrement
    • getAndIncrement
    • getAndSet
    • incrementAndGet
    • intValue
    • lazySet
    • set
  • Ver código de PhilosopherConditions.java

Executors

  • Representan el concepto de asincronía (async/await, promesas, futuros) en Java.
  • Echa un vistazo aquí y a este vídeo.
  • También se emplean en la implementación de ThreadPools.

Para saber más

Aclaraciones

  • En ningún caso estas transparencias son la bibliografía de la asignatura, por lo tanto debes estudiar, aclarar y ampliar los conceptos que en ellas encuentres empleando los enlaces web y bibliografía recomendada que puedes consultar en la página web de la ficha de la asignatura y en la web propia de la asignatura.