51 · Threading: Thread, Lock, Queue
Crea y une hilos del OS con threading.Thread, protege el estado compartido con Lock y conecta productores con consumidores de forma segura con queue.Queue — los tres primitivos que todo programa concurrente I/O-bound necesita.
`threading.Thread` es el bloque de construcción básico para I/O concurrente en Python. Tres idiomas cubren el 90% de los casos de uso reales: `Thread(target=fn).start()` + `.join()` para lanzar y esperar; `with lock:` para proteger cualquier estado mutable compartido; y `queue.Queue` para pasar trabajo entre hilos sin tocar directamente una variable compartida. Domina estos tres y entenderás el modelo de seguridad de hilos sobre el que están construidos PyTorch DataLoader, Gunicorn y todo servidor web async.
Sin esto:
Sin `Lock` alrededor del estado mutable compartido obtienes condiciones de carrera — dos hilos leyendo e incrementando un contador simultáneamente pueden producir silenciosamente un total incorrecto. Sin `queue.Queue` terminas haciendo polling de una lista compartida con un bucle busy-wait, desperdiciando CPU e introduciendo bugs sutiles de ordenamiento. Ambos modos de fallo son invisibles hasta que la carga o el timing cambia.
threading.Thread — lo básico
La API de hilo más simple:
from threading import Thread
def work(n):
print(f"hilo {n} corriendo")
t = Thread(target=work, args=(42,))
t.start() # lanza el hilo
t.join() # bloquea al llamador hasta que t termine
Thread(target=fn, args=(...))— suministra un callable y sus args posicionales..start()— entrega el hilo al scheduler del OS; retorna inmediatamente..join()— bloquea el hilo llamador hasta que el hilo objetivo complete. Olvidar.join()significa que tu hilo principal sale (y mata todos los hilos daemon) antes de que los workers terminen.daemon=True— un hilo daemon se mata automáticamente cuando no quedan hilos no-daemon. Útil para tareas en segundo plano que no deben impedir la salida del programa.
Condiciones de carrera y Lock
Una condición de carrera ocurre cuando dos o más hilos leen una variable compartida, calculan un nuevo valor y escriben de vuelta — y el OS interrumpe a un hilo entre la lectura y la escritura. El ejemplo canónico son dos hilos incrementando un contador:
Hilo 1 lee contador=100
Hilo 2 lee contador=100 ← la interrupción ocurrió aquí
Hilo 1 escribe contador=101
Hilo 2 escribe contador=101 ← perdió el +1 del Hilo 1
La solución: un threading.Lock (exclusión mutua). Como máximo un hilo mantiene el lock a la vez; cualquier otro hilo que lo solicite se bloquea hasta que se libere.
from threading import Lock
lock = Lock()
with lock: # adquiere el lock; lo libera al salir (incluso con excepción)
counter += 1 # ahora esto es atómico — solo un hilo a la vez
RLock (re-entrant lock) permite que el mismo hilo adquiera el lock múltiples veces sin causar un deadlock — útil en código recursivo.
Python (in browser)
En el sandbox del navegador verás `200 000` porque los hilos de Pyodide se ejecutan secuencialmente. Ejecuta el mismo código en CPython local con múltiples hilos del OS y verás un número menor — los incrementos se perdieron silenciosamente cuando el OS interrumpió un hilo en mitad de la operación.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`with lock:` garantiza que solo un hilo puede estar dentro del bloque a la vez. La compensación: la contención del lock añade latencia — si cada incremento requiere adquirir un lock, los hilos pasan tiempo esperándose mutuamente. Para un contador simple, `threading.Lock` es la herramienta correcta. Para flujos de trabajo de nivel superior, prefiere `queue.Queue` que internaliza su propio locking.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
Python (in browser)
`queue.Queue.get()` bloquea hasta que haya un elemento disponible — no se necesita un bucle busy-wait polling. `queue.Queue.put()` bloquea si la cola está llena (puedes fijar `maxsize=N` para backpressure). El patrón sentinel (`put(None)`) es la forma idiomática de señalar a un consumidor que se detenga. `task_done()` + `Queue.join()` te permite esperar hasta que cada elemento haya sido procesado — útil para pipelines de lotes.
Python runs entirely in your browser via Pyodide (~6 MB on first Run, cached after).
`threading.local()` es la herramienta correcta cuando necesitas estado que es *lógicamente global dentro de un hilo* pero no debe filtrarse a otros hilos. El scoping de sesión de SQLAlchemy y el objeto `g` de Flask usan este mecanismo internamente.
¿Qué hace `thread.join()`?
- `Thread(target=fn, args=(...)).start()` lanza un hilo; `.join()` bloquea al llamador hasta que termine. Siempre haz `.join()` a menos que el hilo sea `daemon=True`.
- Una **condición de carrera** ocurre cuando dos hilos hacen lectura-modificación-escritura sobre una variable compartida sin sincronización. Usa `with lock:` (un `threading.Lock`) para hacer la operación atómica.
- `queue.Queue` es thread-safe por diseño — no se necesita locking manual. Usa el patrón sentinel (`queue.put(None)`) para señalar a un hilo consumidor que se detenga.
- `threading.local()` proporciona almacenamiento por hilo — cada hilo ve su propia copia de los atributos. Ideal para conexiones DB, contextos de solicitud y semillas RNG por worker.
- Prefiere `queue.Queue` sobre estado compartido. Si debes compartir estado, cada acceso (también las lecturas) debe estar dentro de `with lock:`.
El `DataLoader(num_workers=N)` de PyTorch crea N hilos workers que leen y aumentan batches de datos del disco mientras la GPU computa el batch anterior — una carga de trabajo clásica I/O-bound para hilos. Los web scrapers que recopilan datos de entrenamiento de APIs REST son territorio perfecto para hilos: cada hilo envía una solicitud y se bloquea esperando la respuesta (liberando el GIL), mientras otros hilos ya están enviando sus solicitudes. `threading.local()` se usa dentro de muchos frameworks de serving de ML para mantener estado por solicitud sin un lock global.
Si lo quitas: Sin conocimiento de `Lock` y `Queue`, los bugs de estado compartido en data loaders y servidores de inferencia son casi imposibles de reproducir — aparecen solo bajo carga y producen resultados sutilmente incorrectos (índices de batch corruptos, tensores dañados, agregados de pérdida incorrectos) en lugar de crashes. Estos bugs pueden degradar silenciosamente la precisión o el rendimiento del modelo durante días antes de que alguien se dé cuenta.