Ir al contenido
wannacode

Bloqueo

En resumen: Un cerrojo (mutex) deja entrar a la sección crítica a un solo hilo cada vez; en Java es synchronized o ReentrantLock, y bajo el cerrojo va solo lo poco que tiene que ir.

Problema

Cómo detener a la otra caja

En la lección «Condición de carrera» la venta perdía ventas: dos hilos hacían a la vez «leer, restar, escribir» y uno pisaba al otro. Los tres pasos tienen que ser un todo: mientras un hilo está dentro, el otro tiene que esperar fuera.

Eso es exactamente lo que hace un cerrojo.

La idea

El cerrojo

Un cerrojo (mutex, de «mutual exclusion») es un objeto con un dueño. Un hilo lo toma, hace su trabajo y lo suelta. Otro hilo que lo pida mientras tanto espera. El código entre tomarlo y soltarlo es la sección crítica: como máximo un hilo está dentro.

En Java cualquier objeto puede ser un cerrojo. synchronized (lock) { … } lo toma al entrar en el bloque y lo suelta al salir, incluso con una excepción:
class Stock {
private final Object lock = new Object();
private int left;
Stock(int left) {
this.left = left;
}
void sell() {
synchronized (lock) {
left--;
}
}
int left() {
synchronized (lock) {
return left;
}
}
}
Las dos cajas de la lección anterior, con las mismas 100 000 ventas cada una:
Stock stock = new Stock(200_000);
Runnable checkout = () -> {
for (int i = 0; i < 100_000; i++) {
stock.sell();
}
};
Thread a = new Thread(checkout);
Thread b = new Thread(checkout);
a.start();
b.start();
a.join();
b.join();
System.out.println("Pedidos: " + 2 * 100_000);
System.out.println("Vendidos según el almacén:");
System.out.println(200_000 - stock.left());
Salida
Pedidos: 200000
Vendidos según el almacén:
200000

Exacto, en cada ejecución. El cerrojo es un campo creado una sola vez: las dos cajas bloquean el mismo.

El objeto cerrojo es un campo privado aparte y no this. Así ningún código de fuera puede bloquear el mismo objeto y detener tus cajas.

Míralo

El último artículo, arreglado

La comprobación «queda uno» y tomarlo están en la misma sección crítica. Si alguien está dentro comprobando, el otro no puede comprobar antes de que el primero haya tomado:

class Stock {
private final Object lock = new Object();
private int left;
Stock(int left) {
this.left = left;
}
boolean reserve() {
synchronized (lock) {
if (left == 0) {
return false; // no queda ninguno: rechazar
}
left--; // queda uno: tomarlo
return true;
}
}
int left() {
synchronized (lock) {
return left;
}
}
}
Paso a paso, en palabras
  1. Quedan 3 artículos, y 4 compradores llaman a reserve() en el mismo momento.
  2. Por synchronized pasa solo uno, los otros tres esperan delante. Entra el comprador 1, ve 3 y toma uno: quedan 2.
  3. El comprador 1 sale y suelta el cerrojo. Entra el comprador 2, ve 2 y toma uno: quedan 1.
  4. El comprador 2 sale y suelta el cerrojo. Entra el comprador 3, ve 1 y toma uno: quedan 0.
  5. El comprador 4 entra el último y ve 0: rechazado. Nadie vio un número que otro estaba cambiando justo en ese momento.
  6. 3 vendidos, 1 rechazado, 0 en existencias: correcto. El precio: los compradores se esperaron unos a otros en el torniquete.

Cuatro compradores, tres artículos, todos a la vez:

Stock stock = new Stock(3);
boolean[] ok = new boolean[4];
List<Thread> buyers = new ArrayList<>();
for (int b = 0; b < 4; b++) {
int buyer = b;
Thread t = new Thread(() -> ok[buyer] = stock.reserve());
buyers.add(t);
t.start();
}
for (Thread t : buyers) {
t.join();
}
int sold = 0;
for (boolean o : ok) {
if (o) sold++;
}
System.out.println("Vendidos: " + sold);
System.out.println("Rechazados: " + (4 - sold));
System.out.println("Existencias: " + stock.left());
Salida
Vendidos: 3
Rechazados: 1
Existencias: 0

Y aquí están los dos compradores de la lección anterior que compraban el último artículo. El cobro de la tarjeta (~100 ms) va después del cerrojo: el artículo queda reservado, y la tarjeta se cobra sin que el otro espere:

Stock stock = new Stock(1);
boolean[] bought = new boolean[2];
Runnable[] buyers = new Runnable[2];
for (int b = 0; b < 2; b++) {
int buyer = b;
buyers[b] = () -> {
if (stock.reserve()) {
pause(100); // se cobra la tarjeta, ~100 ms
bought[buyer] = true;
}
};
}
Thread first = new Thread(buyers[0]);
Thread second = new Thread(buyers[1]);
first.start();
second.start();
first.join();
second.join();
int sold = (bought[0] ? 1 : 0) + (bought[1] ? 1 : 0);
System.out.println("Existencias antes: 1");
System.out.println("Vendidos: " + sold);
System.out.println("Rechazados: " + (2 - sold));
System.out.println("Existencias: " + stock.left());
Salida
Existencias antes: 1
Vendidos: 1
Rechazados: 1
Existencias: 0

Uno vendido, uno rechazado, 0 en existencias. En cada ejecución.

Pruébalo

No olvides soltarlo

Además de synchronized, Java tiene un objeto cerrojo, ReentrantLock: lock() lo toma, unlock() lo suelta. La liberación va en finally, así que ocurre incluso con una excepción:
class Stock {
private final ReentrantLock lock = new ReentrantLock();
private int left;
Stock(int left) {
this.left = left;
}
void sell() {
lock.lock();
try {
left--;
} finally {
lock.unlock();
}
}
int left() {
lock.lock();
try {
return left;
} finally {
lock.unlock();
}
}
}

¿Qué suelta el cerrojo?

void sell() {
    lock.lock();
    try {
        left--;
    } finally {
        
    }
}

Pruébalo

El cerrojo que no bloquea

Un compañero añadió un cerrojo, pero las cajas siguen perdiendo ventas. ¿Qué línea está mal?

Elige la línea con el error.

La idea

Lo que cuesta un cerrojo

Un cerrojo funciona haciendo que los hilos se esperen unos a otros. Cuanto más tiempo se queda un hilo dentro, más esperan los demás. Eso es la contención por el cerrojo.

Una tienda con 10 artículos y cuatro pedidos a la vez. Cada pedido toma un artículo y envía un correo (~200 ms), y Network de la lección «Por qué hilos» cuenta las rondas de espera:

class Network {
private int inFlight = 0;
private int rounds = 0;
// Consulta un servicio por la red: la respuesta llega después de ~200 ms.
String call(String service, String answer) {
synchronized (this) {
if (inFlight == 0) rounds++; // nadie espera: una ronda nueva
inFlight++;
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
synchronized (this) {
inFlight--;
}
return service + ": " + answer;
}
synchronized int rounds() {
return rounds;
}
}
class Shop {
private final Object lock = new Object();
private final Network net;
private int left = 10;
Shop(Network net) {
this.net = net;
}
// Mal: el correo sale mientras el cerrojo está tomado.
void orderSlow(String customer) {
synchronized (lock) {
left--;
net.call("correo", customer);
}
}
// Bien: bajo el cerrojo solo están las existencias.
void order(String customer) {
synchronized (lock) {
left--;
}
net.call("correo", customer);
}
int left() {
synchronized (lock) {
return left;
}
}
}
Con orderSlow el correo está bajo el cerrojo:
Network net = new Network();
Shop shop = new Shop(net);
List<Thread> orders = new ArrayList<>();
for (int c = 1; c <= 4; c++) {
String customer = "cliente " + c;
Thread t = new Thread(() -> shop.orderSlow(customer));
orders.add(t);
t.start();
}
for (Thread t : orders) {
t.join();
}
System.out.println("Pedidos: 4, existencias: " + shop.left());
System.out.println("Rondas de espera: " + net.rounds());
Salida
Pedidos: 4, existencias: 6
Rondas de espera: 4

Cuatro rondas: los correos salen uno a uno, y el último cliente espera unos 800 ms. Con order solo las existencias están bajo el cerrojo:

Network net = new Network();
Shop shop = new Shop(net);
List<Thread> orders = new ArrayList<>();
for (int c = 1; c <= 4; c++) {
String customer = "cliente " + c;
Thread t = new Thread(() -> shop.order(customer));
orders.add(t);
t.start();
}
for (Thread t : orders) {
t.join();
}
System.out.println("Pedidos: 4, existencias: " + shop.left());
System.out.println("Rondas de espera: " + net.rounds());
Salida
Pedidos: 4, existencias: 6
Rondas de espera: 1

Las mismas existencias, una ronda. Las reglas:

  • Solo los datos compartidos bajo el cerrojo. Calcula, da formato y espera fuera. Dentro, solo leer y escribir.
  • Nunca esperes bajo un cerrojo: red, disco, base de datos, sleep. Toma lo que necesitas, suelta el cerrojo y luego espera.
  • Un cerrojo por cada dato. Las existencias y los ingresos son cosas separadas: cada uno con su cerrojo, para que las cajas que tocan solo uno de ellos no se esperen entre sí.
Más precisamente, para curiosos
  • synchronized es reentrante: un hilo que ya tiene el cerrojo puede entrar en otro bloque con el mismo cerrojo sin esperarse a sí mismo. ReentrantLock también, de ahí el nombre.
  • ReentrantLock puede más que synchronized: tryLock() (tómalo si está libre, si no haz otra cosa), tryLock(100, TimeUnit.MILLISECONDS) y una cola justa con new ReentrantLock(true).
  • Sin el cerrojo, left() también podría devolver un número viejo: el cerrojo garantiza además la visibilidad de la lección anterior.

Un cerrojo no es el único camino. Para un contador hay operaciones atómicas, que no hacen esperar a los hilos, y los datos que nadie cambia no necesitan cerrojo: la lección «Operaciones atómicas y objetos inmutables». Y dos cerrojos que dos hilos toman en distinto orden pueden detener el programa para siempre: la lección «Deadlock».

Ejercicio

Los ingresos y un pedido de varios artículos

Resuelve las tareas en tu editor.

Los ingresos. Arregla la tarea de la lección anterior: dos cajas con 50 000 ventas cada una a 3 €, y Revenue protege total con un cerrojo. Ejecuta el programa varias veces: los ingresos tienen que ser los mismos cada vez.

Se espera
Caja 1: 50000 ventas
Caja 2: 50000 ventas
Ingresos (€):
300000

Un pedido de varios artículos. Hay 5 artículos en existencias. Escribe reserve(count): si quedan al menos count, los toma y devuelve true; si no, no toma nada y devuelve false. Tres clientes quieren 2 artículos cada uno a la vez, cada uno en su propio hilo.

Se espera
Pedidos: 3
Aceptados: 2
Rechazados: 1
Existencias: 1

Más tareas

  • El precio de un cerrojo. Ejecuta las dos cajas con 1 000 000 de ventas cada una, con y sin cerrojo, y mide el tiempo. ¿Cuánto más lento es con el cerrojo? ¿Y con 8 cajas?
  • Dos cerrojos. Añade a las existencias unos ingresos con su propio cerrojo. Una venta resta de las existencias y suma a los ingresos. ¿Siguen juntos los dos si alguien lee las existencias y los ingresos entre los dos cerrojos? - tryLock. Haz una venta que espere al cerrojo como máximo 50 ms con tryLock y, si no lo consigue, devuelva «inténtalo de nuevo».

Test

Ponte a prueba

1. Dos hilos llaman a sell(), que hace left-- bajo un mismo cerrojo. ¿Qué pasa cuando el segundo llega al cerrojo mientras el primero está dentro?
2. La comprobación «hay artículos» está bajo el cerrojo, y la resta está en un bloque aparte bajo el mismo cerrojo. Dos personas compran el último artículo. ¿Qué pasa?
3. Bajo el cerrojo están la resta de las existencias y el envío de un correo (~200 ms). Llegan cuatro pedidos a la vez. ¿Cuánto espera el último?
4. ¿Qué tiene de malo synchronized (new Object()) { left--; }? (C#: lock (new object()), Python: with threading.Lock())

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·