← Sistemas bajo presión ETAPA 06 · 30 MIN

Hilos y locks

Comparte datos entre hilos sin perder actualizaciones, protege con un lock lo que de verdad hay que compartir y evita las esperas que nunca terminan.

Abrir en el laboratorio ↗

AL TERMINAR, PODRÁS

  • Explicar por qué una lectura seguida de una escritura puede perder una actualización entre hilos
  • Proteger una sección crítica con un lock y ordenar los locks para evitar un deadlock
  • Elegir entre compartir con un lock, pasar mensajes o no usar hilos
Los dos hilos leen 5 antes de que alguno escriba, así que los dos escriben 6: se pierde un incremento. Con un lock alrededor de la lectura y la escritura, el segundo hilo leería 6 y escribiría 7.

Descripción del diagrama: 1. count → Hilo A: lee 5; 2. count → Hilo B: lee 5; 3. Hilo A → count: escribe 6; 4. Hilo B → count: escribe 6; 5. count: 6, no 7: se perdió una

Leer y escribir son dos pasos

Los hilos (threads) de un proceso comparten su memoria: las variables globales y todos los objetos a los que pueden llegar.

El intérprete puede pasar de un hilo a otro entre dos pasos cualesquiera de su bytecode, y count += 1 son varios pasos: leer el valor, sumar uno y guardar el resultado. Si otro hilo guarda entre tu lectura y tu escritura, una de las dos actualizaciones se pierde. La versión por defecto de CPython tiene un lock global del intérprete, el GIL, que deja que un solo hilo ejecute código Python a la vez.

No convierte una secuencia de pasos en uno solo; solo hace que la pérdida sea menos frecuente, y por eso más difícil de atrapar en una prueba. Las versiones sin GIL (free-threaded), opcionales desde Python 3.13 y con soporte oficial desde 3.14, ejecutan los hilos de verdad en paralelo, y ahí el mismo ciclo pierde actualizaciones con facilidad. Cualquier pausa entre la lectura y la escritura agranda la ventana.

En el ejemplo de abajo, ocho workers terminan mil trabajos y el contador suele quedar muy por debajo de mil.

Python
import time
from concurrent.futures import ThreadPoolExecutor

jobs_done = 0

def finish_job():
    global jobs_done
    current = jobs_done      # read
    time.sleep(0)            # any pause lets another thread run here
    jobs_done = current + 1  # write: may overwrite another thread's update

with ThreadPoolExecutor(max_workers=8) as pool:
    for _ in range(1_000):
        pool.submit(finish_job)
print(jobs_done)  # often far below 1000

Protege la sección crítica y mantenla pequeña

Un threading.Lock lo puede tener un solo hilo a la vez; cualquier otro hilo que lo pida espera.

Escrito como with lock:, se toma al empezar el bloque y se suelta al terminar, incluso cuando una excepción sale del bloque. Pon dentro toda la secuencia de leer, modificar y escribir, y haz que todo el código que toca los datos compartidos tome el mismo lock: un lock que solo usan algunos caminos no protege nada. Mantén corta la sección protegida y no hagas dentro entradas ni salidas lentas, porque todos los demás hilos esperan.

Dos locks traen una falla nueva. Si un hilo tiene el lock del trabajo y espera el de la cuenta mientras otro tiene el de la cuenta y espera el del trabajo, los dos esperan para siempre: un deadlock (bloqueo mutuo). Evítalo tomando los locks siempre en un mismo orden acordado, o no teniendo nunca dos a la vez.

Pasar un timeout a acquire convierte un cuelgue silencioso en un error visible. Un threading.Lock no es reentrante: un hilo que vuelve a pedir un lock que ya tiene se espera a sí mismo.

Python
import threading

lock = threading.Lock()

def finish_job_safely():
    global jobs_done
    with lock:                   # one thread at a time from here
        current = jobs_done
        time.sleep(0)
        jobs_done = current + 1  # to here

# Always the same order when two locks are needed: job lock, then account lock.

Mejor no compartir

Los datos compartidos más seguros son los que no compartes.

Dale a cada hilo su propio trabajo y devuelve los resultados en lugar de actualizar una variable global: queue.Queue se puede usar con seguridad desde muchos hilos, y un ThreadPoolExecutor devuelve el resultado de cada tarea a través de un future. Los tipos integrados como dict y list hacen una sola operación de forma segura, pero la lógica de varios pasos, como leer un valor y luego escribir uno nuevo, sigue necesitando un lock. Elige hilos para el trabajo que sobre todo espera entradas y salidas, como las llamadas a un proveedor de correo o las lecturas de disco, donde las esperas se superponen.

Para trabajo que mantiene ocupado el procesador en Python puro, la versión por defecto ejecuta un hilo a la vez, así que usa procesos, por ejemplo un ProcessPoolExecutor. Para muchas esperas de red en un solo programa, asyncio cambia de tarea solo en cada await, lo que deja a la vista los puntos de cambio. Un lock en un proceso no coordina otros procesos ni otras máquinas; entre los workers de Relay, usa la base de datos o una concesión temporal (lease).

Python
from concurrent.futures import ThreadPoolExecutor

def send(job):
    return provider.send(job["to"], job["subject"])  # waits on the network

with ThreadPoolExecutor(max_workers=8) as pool:
    results = list(pool.map(send, jobs))  # no shared counter to protect
sent = sum(1 for result in results if result.ok)

ERROR FRECUENTE

Decidir que el código es seguro entre hilos porque sus pruebas pasan. En la versión por defecto una condición de carrera puede esconderse mucho tiempo; razona qué datos se comparten y qué lock los protege.

INTÉNTALO TÚ

Enumera cada dato del worker de Relay que puede tocar más de un hilo. Para cada uno, elige un lock (y cuál), una cola o no compartirlo. Si algún trabajo necesita dos locks, escribe el orden que deben usar todos los hilos.

COMPROBACIÓN DE RECUERDO / APLICA LA IDEA

Decide en el laboratorio.

El checkpoint de dos preguntas, tus notas y tu progreso guardado están en el laboratorio. Gratis y sin cuenta.

Abrir en el laboratorio ↗

CONÉCTALO CON RELAY

Ejecuta los envíos de correo de Relay en un ThreadPoolExecutor y cuenta los resultados desde los futures en lugar de con un contador compartido. Donde haya que compartir estado, protégelo con un lock que se tome en un orden documentado, y agrega una prueba que ejecute muchos hilos a la vez.