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 Python es threading.Lock con with, 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 Python el cerrojo es threading.Lock(). with self.lock: lo toma al entrar en el bloque y lo suelta al salir, incluso con una excepción. time.sleep(0) sigue donde estaba: el otro hilo todavía puede llegar justo aquí, pero espera delante del cerrojo.
class Stock:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def sell(self):
with self.lock:
seen = self.left
time.sleep(0) # el otro hilo puede entrar aquí, pero espera ante el cerrojo
self.left = seen - 1
Las dos cajas de la lección anterior, con las mismas 10 000 ventas cada una:
stock = Stock(20_000)
def checkout():
for _ in range(10_000):
stock.sell()
a = threading.Thread(target=checkout)
b = threading.Thread(target=checkout)
a.start()
b.start()
a.join()
b.join()
print(f"Pedidos: {2 * 10_000}")
print("Vendidos según el almacén:")
print(20_000 - stock.left)
Salida
Pedidos: 20000
Vendidos según el almacén:
20000

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

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:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def reserve(self):
with self.lock:
if self.left == 0:
return False # no queda ninguno: rechazar
self.left -= 1 # queda uno: tomarlo
return True
Paso a paso, en palabras
  1. Quedan 3 artículos, y 4 compradores llaman a reserve() en el mismo momento.
  2. Por with self.lock 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(3)
results = []
def buyer():
results.append(stock.reserve())
buyers = [threading.Thread(target=buyer) for _ in range(4)]
for t in buyers:
t.start()
for t in buyers:
t.join()
print(f"Vendidos: {results.count(True)}")
print(f"Rechazados: {results.count(False)}")
print(f"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(1)
bought = []
def buyer():
if stock.reserve():
time.sleep(0.1) # se cobra la tarjeta, ~100 ms
bought.append(True)
buyers = [threading.Thread(target=buyer) for _ in range(2)]
for t in buyers:
t.start()
for t in buyers:
t.join()
print("Existencias antes: 1")
print(f"Vendidos: {len(bought)}")
print(f"Rechazados: {2 - len(bought)}")
print(f"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

with self.lock: es la forma corta de acquire() y release(). La liberación va en finally, así que ocurre incluso con una excepción:
class Stock:
def __init__(self, left):
self.left = left
self.lock = threading.Lock()
def sell(self):
self.lock.acquire()
try:
seen = self.left
time.sleep(0)
self.left = seen - 1
finally:
self.lock.release()

¿Qué suelta el cerrojo?

def sell(self):
    self.lock.acquire()
    try:
        seen = self.left
        time.sleep(0)
        self.left = seen - 1
    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:
def __init__(self):
self.lock = threading.Lock()
self.in_flight = 0
self.rounds = 0
def call(self, service, answer):
"""Consulta un servicio por la red: la respuesta llega después de ~200 ms."""
with self.lock:
if self.in_flight == 0:
self.rounds += 1 # nadie espera: una ronda nueva
self.in_flight += 1
time.sleep(0.2)
with self.lock:
self.in_flight -= 1
return f"{service}: {answer}"
class Shop:
def __init__(self, net):
self.net = net
self.left = 10
self.lock = threading.Lock()
def order_slow(self, customer):
"""Mal: el correo sale mientras el cerrojo está tomado."""
with self.lock:
self.left -= 1
self.net.call("correo", customer)
def order(self, customer):
"""Bien: bajo el cerrojo solo están las existencias."""
with self.lock:
self.left -= 1
self.net.call("correo", customer)
Con order_slow el correo está bajo el cerrojo:
net = Network()
shop = Shop(net)
orders = [
threading.Thread(target=shop.order_slow, args=(f"cliente {c}",))
for c in range(1, 5)
]
for t in orders:
t.start()
for t in orders:
t.join()
print(f"Pedidos: 4, existencias: {shop.left}")
print(f"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:

net = Network()
shop = Shop(net)
orders = [
threading.Thread(target=shop.order, args=(f"cliente {c}",))
for c in range(1, 5)
]
for t in orders:
t.start()
for t in orders:
t.join()
print(f"Pedidos: 4, existencias: {shop.left}")
print(f"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
  • threading.Lock no es reentrante: si el hilo que lo tiene lo pide otra vez, se espera a sí mismo para siempre. threading.RLock es el reentrante.
  • self.lock.acquire(timeout=0.1) toma el cerrojo solo si lo consigue en 100 ms y devuelve True o False.
  • En asyncio el cerrojo es asyncio.Lock con async with: esa es la lección «async no es un hilo».

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 10 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: 10000 ventas
Caja 2: 10000 ventas
Ingresos (€):
60000

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? - Timeout. Haz una venta que espere al cerrojo como máximo 50 ms con acquire(timeout=0.05) 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 ·