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 esthreading.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 - 1stock = 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)Pedidos: 20000Vendidos según el almacén:20000Exacto, 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 TruePaso a paso, en palabras
- Quedan 3 artículos, y 4 compradores llaman a reserve() en el mismo momento.
- Por with self.lock pasa solo uno, los otros tres esperan delante. Entra el comprador 1, ve 3 y toma uno: quedan 2.
- El comprador 1 sale y suelta el cerrojo. Entra el comprador 2, ve 2 y toma uno: quedan 1.
- El comprador 2 sale y suelta el cerrojo. Entra el comprador 3, ve 1 y toma uno: quedan 0.
- El comprador 4 entra el último y ve 0: rechazado. Nadie vio un número que otro estaba cambiando justo en ese momento.
- 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}")Vendidos: 3Rechazados: 1Existencias: 0Y 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}")Existencias antes: 1Vendidos: 1Rechazados: 1Existencias: 0Uno 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:
Sin ello el cerrojo se queda tomado, y el siguiente hilo que lo pida espera para siempre. Por eso normalmente se escribe with: suelta solo.
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.
threading.Lock() crea un cerrojo nuevo en cada llamada. Cada hilo toma su propio cerrojo libre y nadie espera a nadie. El cerrojo es un campo creado una sola vez en __init__: self.lock = threading.Lock().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)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}")Pedidos: 4, existencias: 6Rondas de espera: 4Cuatro 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}")Pedidos: 4, existencias: 6Rondas de espera: 1Las 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.Lockno es reentrante: si el hilo que lo tiene lo pide otra vez, se espera a sí mismo para siempre.threading.RLockes el reentrante.self.lock.acquire(timeout=0.1)toma el cerrojo solo si lo consigue en 100 ms y devuelveTrueoFalse.- En
asyncioel cerrojo esasyncio.Lockconasync 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.
Caja 1: 10000 ventasCaja 2: 10000 ventasIngresos (€):60000Un 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.
Pedidos: 3Aceptados: 2Rechazados: 1Existencias: 1Má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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Lambdas · Hilos · Por qué hilos · Condición de carrera
- Continúa en: Operaciones atómicas y objetos inmutables · Deadlock · Productor y consumidor · async no es un hilo
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera