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