Condición de carrera
En resumen: Una condición de carrera es un error en el que el resultado depende de cómo se entrelazan los hilos: left -= 1 son tres pasos incluso con el GIL, así que dos hilos pueden perder una venta.
Problema
La venta se cuenta mal
Viernes, venta relámpago. Los auriculares están a mitad de precio y los pedidos llegan a raudales. Dos cajas venden de un mismo almacén: cada venta resta 1 de las existencias.
Las cajas son dos hilos. Cada uno vende 10 000 artículos y llama asell() por cada uno. sell lee las existencias, llama a time.sleep(0) (deja correr un momento a los demás hilos) y solo entonces escribe. Por qué hace falta time.sleep(0) se explica más abajo:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. leer time.sleep(0) # aquí entra el otro hilo after = seen - 1 # 2. restar 1 self.left = after # 3. escribirstock = 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)Los clientes han pagado 20 000 pedidos. Estas son las ventas que cuenta el almacén en cinco ejecuciones:
Vendidos según el almacén: 10000Vendidos según el almacén: 10000Vendidos según el almacén: 10000Vendidos según el almacén: 10000Vendidos según el almacén: 10000La idea
self.left -= 1 son tres pasos
self.left -= 1 es una línea, pero Python lo hace en tres pasos. En sell están escritos uno por uno, y time.sleep(0) queda justo entre la lectura y la escritura:class Stock: def __init__(self, left): self.left = left
def sell(self): seen = self.left # 1. leer time.sleep(0) # aquí entra el otro hilo after = seen - 1 # 2. restar 1 self.left = after # 3. escribirPaso a paso, en palabras
- Quedan 5 artículos. Caja A y caja B venden uno cada una en el mismo momento.
- Caja A lee las existencias: 5.
- time.sleep(0) deja entrar al otro hilo. Caja B lee antes de que A haya escrito: también 5.
- Caja A resta 1 en su propia variable: 4.
- Caja B hace lo mismo a partir de su 5: también 4.
- Caja A escribe 4. Una venta, quedan 4: hasta aquí es correcto.
- Caja B también escribe 4, encima del 4 de A. Dos ventas, y las existencias son 4, no 3.
- Se pierde una venta: las dos leyeron el mismo número antes de que alguna escribiera. Eso es una condición de carrera, y el almacén muestra ahora un artículo que ya está vendido.
- Leer las existencias en una variable propia.
- Restar 1 a tu copia.
- Escribir la copia de vuelta.
Dos hilos pueden turnarse entre cualquier par de pasos. Si los dos leen 5 antes de que escriba alguno, los dos escriben 4. Dos ventas, y las existencias bajan en uno.
Cuando el resultado depende de qué hilo hace primero cada paso, eso es una condición de carrera. Los tres pasos «leer, cambiar, escribir» sobre datos compartidos son su forma más común.
El GIL no te salva. Deja que un solo hilo ejecute código Python a la vez, pero puede cambiar de hilo entre los dos pasos. time.sleep(0) hace que el cambio ocurra justo ahí. Sin él el cambio sigue siendo posible, solo que más raro (mira más abajo).
Más precisamente, para curiosos
El módulo dis muestra las instrucciones que Python ejecuta para self.left -= 1. Esto es Python 3.12, con el que se comprueba el código:
import dis
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # leer, restar 1, escribir
for step in dis.get_instructions(Stock.sell): print(f"{step.opname} {step.argrepr}".strip())RESUMELOAD_FAST selfCOPYLOAD_ATTR leftLOAD_CONST 1BINARY_OP -=SWAPSTORE_ATTR leftRETURN_CONST NoneLOAD_ATTR left lee, BINARY_OP -= resta, STORE_ATTR left escribe. Tres instrucciones separadas: entre cualquier par el GIL puede ceder el turno a otro hilo.
Pruébalo
Tres cajas, 5 en existencias
Aquí hay tres cajas, entrelazadas a mano en uno de los órdenes posibles. Cada una lee en su propia variable y luego escribe 1 menos:
left = 5seen_a = left # A leeseen_b = left # B leeleft = seen_a - 1 # A escribeseen_c = left # C leeleft = seen_b - 1 # B escribeleft = seen_c - 1 # C escribeprint("Ventas: 3")print(f"Existencias: {left}")A y B leen 5. A escribe 4. C lee el 4 que acaba de escribirse. B escribe 4 (su propio 5 menos 1), y C escribe 3. Tres ventas, y las existencias son 3 en vez de 2: solo se perdió la venta de B, porque B leyó antes de que A escribiera.
La idea
«En mi máquina funciona»
El mismo programa, con menos ventas por caja:
En Python, pruébalo sin time.sleep(0): sell es solo self.left -= 1, y cada caja vende 100 000:
class Stock: def __init__(self, left): self.left = left
def sell(self): self.left -= 1 # leer, restar 1, escribirstock = Stock(200_000)
def checkout(): for _ in range(100_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 * 100_000}")print("Vendidos según el almacén:")print(200_000 - stock.left)Vendidos según el almacén: 200000Vendidos según el almacén: 200000Vendidos según el almacén: 200000Vendidos según el almacén: 200000Vendidos según el almacén: 200000sell o Python sin GIL (3.13) pierden ventas.Eso es lo que hace traicionera a una condición de carrera:
- la prueba pasa en el portátil y falla en el servidor con 64 núcleos y miles de clientes;
- el error aparece «a veces» y no se repite cuando lo buscas;
- añade una línea de impresión, el orden de los hilos cambia y el error desaparece.
Una prueba sin pérdida no demuestra nada. La seguridad se demuestra por cómo está escrito el código: qué datos se comparten y quién los protege.
Problema
El último artículo
Queda un artículo. Dos compradores pulsan «Comprar» en el mismo momento. buy comprueba si queda uno, cobra la tarjeta (unos 100 ms) y solo entonces se lleva el artículo:
class Stock: def __init__(self, left): self.left = left
def buy(self): if self.left > 0: # comprobar: ¿queda uno? time.sleep(0.1) # se cobra la tarjeta, ~100 ms self.left -= 1 # actuar: tomar el artículo return True return Falsestock = Stock(1)bought = []
def buyer(): bought.append(stock.buy())
first = threading.Thread(target=buyer)second = threading.Thread(target=buyer)first.start()second.start()first.join()second.join()print("Existencias antes: 1")print("Vendidos:")print(bought.count(True))print("Existencias después:")print(stock.left)Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Vendidos: 2, existencias después: -1Los dos pasan la comprobación: mientras el primero paga, el segundo ve el mismo 1. Los dos pagaron, y las existencias están en -1. Aquí la condición de carrera ocurre siempre, porque entre la comprobación y la acción pasan unos buenos 100 ms.
Esta es la segunda forma de una condición de carrera: comprobar y luego actuar (check-then-act). La decisión se toma sobre un valor que otro puede cambiar antes de que la decisión se lleve a cabo. Está en todas partes: «¿hay una habitación libre?», «¿está libre el nombre de usuario?», «¿hay dinero en la cuenta?».
Pruébalo
Números de pedido
Cada pedido recibe un número: 1001, 1002, 1003… Las cajas son hilos y llaman a take() al mismo tiempo. A veces dos pedidos reciben el mismo número. ¿Después de qué línea tiene que entrar el otro hilo para que eso ocurra?
Elige la línea con el error.
Después de la lectura. Si los dos hilos leen 1001 antes de que alguno escriba 1002, los dos devuelven 1001. Dos pedidos con un número: uno sobrescribirá al otro en la base de datos o llegará al cliente equivocado. Es el mismo «leer, cambiar, escribir».
La idea
Cuándo puede haber una condición de carrera
Hacen falta tres cosas a la vez:
- Datos compartidos: algo que varios hilos pueden ver (las existencias, el contador, el número).
- Al menos un hilo los cambia. Si todos solo leen, no hay nada que pueda salir mal.
- Nada ordena el acceso: los hilos entran en sus pasos cuando les toca el turno.
Quita una de las tres y no hay condición de carrera:
- No compartir. Cada hilo cuenta en su propia variable, y al final se suman los resultados. Así funcionaron las 1000 facturas en «Por qué hilos»: cada parte calcula la suya.
- No cambiar. Un objeto que nadie cambia puede ser leído por tantos hilos como quieras. Esa es la lección «Operaciones atómicas y objetos inmutables».
- Ordenar el acceso. Un solo hilo a la vez en los tres pasos. Eso es el cerrojo, la lección siguiente: «Bloqueo».
Más precisamente, para curiosos
Hay también un problema más silencioso: la visibilidad. En Python con el GIL cada hilo ve el último valor escrito, así que este problema apenas aparece aquí. En Python sin GIL las reglas son como en Java. El cerrojo resuelve también esto: lo que un hilo escribió antes de soltarlo, el siguiente lo ve después de tomarlo.
Cómo es en otros lenguajes: en Java y C# count++ pierde igual que aquí. En Python pierde con menos frecuencia por el GIL, pero pierde. En JavaScript pierde entre Workers sobre un SharedArrayBuffer, y con un solo hilo una condición de carrera existe solo entre dos await.
Ejercicio
Los ingresos de la venta
Resuelve la tarea en tu editor.
Los ingresos. Dos cajas venden 10 000 auriculares cada una a 3 €. Cada caja cuenta sus ventas en su propia variable, y los ingresos son compartidos: un objeto Revenue con un método add(euros) que hace total += euros. En add, lee, llama a time.sleep(0) y solo entonces escribe. Imprime las ventas de cada caja y los ingresos. Ejecuta el programa varias veces.
Caja 1: 10000 ventasCaja 2: 10000 ventasIngresos (€):60000Las ventas de las cajas salen bien en cada ejecución: cada una cuenta en su propia variable. Los ingresos salen menores y distintos cada vez. La solución está en la lección siguiente.
Más tareas
- Cuánto se pierde. Ejecuta la venta con 2, 4 y 8 cajas. ¿Se pierde más con más cajas?
- Encuentra lo compartido. Toma un programa con hilos que hayas escrito y enumera los datos que toca más de un hilo. ¿Cuáles de ellos cambia algún hilo? - Habitación libre. Escribe un hotel con un método «reservar la habitación 12 si está libre» y ejecuta a dos huéspedes a la vez, con una pausa de 100 ms entre la comprobación y la escritura. ¿Cuántos huéspedes se quedan con la habitación?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas: append, remove y sort · Lambdas · Hilos · Por qué hilos
- Continúa en: Bloqueo · Operaciones atómicas y objetos inmutables · Deadlock · Productor y consumidor
- Ejercicios sobre el tema: Concurrencia y condiciones de carrera