Ir al contenido
wannacode

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 a sell() 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. escribir
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)

Los clientes han pagado 20 000 pedidos. Estas son las ventas que cuenta el almacén en cinco ejecuciones:

Salida de cinco ejecuciones (la tercera línea)
Vendidos según el almacén: 10000
Vendidos según el almacén: 10000
Vendidos según el almacén: 10000
Vendidos según el almacén: 10000
Vendidos según el almacén: 10000
El almacén cuenta la mitad: se pierde una de cada dos ventas. Esos artículos ya no están, pero el almacén cree que los «tiene»: mañana la tienda los venderá a alguien y luego devolverá el dinero.

La 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. escribir
Paso a paso, en palabras
  1. Quedan 5 artículos. Caja A y caja B venden uno cada una en el mismo momento.
  2. Caja A lee las existencias: 5.
  3. time.sleep(0) deja entrar al otro hilo. Caja B lee antes de que A haya escrito: también 5.
  4. Caja A resta 1 en su propia variable: 4.
  5. Caja B hace lo mismo a partir de su 5: también 4.
  6. Caja A escribe 4. Una venta, quedan 4: hasta aquí es correcto.
  7. Caja B también escribe 4, encima del 4 de A. Dos ventas, y las existencias son 4, no 3.
  8. 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.
  1. Leer las existencias en una variable propia.
  2. Restar 1 a tu copia.
  3. 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())
Salida
RESUME
LOAD_FAST self
COPY
LOAD_ATTR left
LOAD_CONST 1
BINARY_OP -=
SWAP
STORE_ATTR left
RETURN_CONST None

LOAD_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 = 5
seen_a = left # A lee
seen_b = left # B lee
left = seen_a - 1 # A escribe
seen_c = left # C lee
left = seen_b - 1 # B escribe
left = seen_c - 1 # C escribe
print("Ventas: 3")
print(f"Existencias: {left}")
Respuesta

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, escribir
stock = 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)
Salida de cinco ejecuciones (la tercera línea)
Vendidos según el almacén: 200000
Vendidos según el almacén: 200000
Vendidos según el almacén: 200000
Vendidos según el almacén: 200000
Vendidos según el almacén: 200000
Bien cinco veces. El GIL cambia de hilo solo en ciertos puntos de la ejecución, y aquí resulta que no cae entre la lectura y la escritura. Python no lo promete: otra versión, un poco más de código en sell 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 False
stock = 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)
Salida de cinco ejecuciones
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1
Vendidos: 2, existencias después: -1

Los 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.

La idea

Cuándo puede haber una condición de carrera

Hacen falta tres cosas a la vez:

  1. Datos compartidos: algo que varios hilos pueden ver (las existencias, el contador, el número).
  2. Al menos un hilo los cambia. Si todos solo leen, no hay nada que pueda salir mal.
  3. 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.

Se espera
Caja 1: 10000 ventas
Caja 2: 10000 ventas
Ingresos (€):
60000

Las 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

1. Dos hilos llaman cada uno una vez a left-- sobre un campo compartido que vale 5. ¿Qué puede valer al final?
2. Sin time.sleep(0), self.left -= 1 con dos hilos de 100 000 cada uno da 200 000 cinco veces de cinco. ¿Es seguro?
3. Queda 1 artículo. El código comprueba «left > 0», cobra la tarjeta durante 100 ms y solo entonces resta 1. Dos personas compran a la vez. ¿Qué pasa?
4. ¿Cuál de estas situaciones NO puede tener una condición de carrera?

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