Ir al contenido
wannacode

Hilos

En resumen: Un hilo es una línea de ejecución separada dentro del programa; en Python el GIL deja que un solo hilo ejecute código a la vez, por eso los hilos ayudan cuando el programa espera.

Problema

Tres informes, uno tras otro

El programa descarga tres informes mensuales del servidor. Aquí la descarga es una pausa: time.sleep(segundos) detiene el programa durante esos segundos.
reports.py
import time
def download(file, seconds):
time.sleep(seconds)
print(f"Descargué {file}")
download("marzo.pdf", 0.6)
download("abril.pdf", 0.2)
download("mayo.pdf", 0.4)
print("Listo")
Salida
Descargué marzo.pdf
Descargué abril.pdf
Descargué mayo.pdf
Listo
Las pausas son de 0,6, 0,2 y 0,4 segundos, 1,2 segundos en total. Todo ese tiempo el programa solo espera a la red. Si hubiera un botón «Cancelar», no respondería. Y los tres informes no dependen unos de otros, así que se podrían descargar perfectamente al mismo tiempo.

La idea

Proceso e hilo

Cuando ejecutas el programa, el sistema operativo le da un proceso: su propia memoria, sus propios archivos abiertos. Dentro del proceso trabajan uno o más hilos (threads). Un hilo es una secuencia de instrucciones que se ejecutan una tras otra. Hasta ahora, cada uno de tus programas tenía un solo hilo: el que ejecuta el archivo de arriba abajo.

Los hilos de un mismo proceso comparten su memoria: ven los mismos objetos. Eso los hace prácticos (se pasan datos con facilidad) y peligrosos (pueden estorbarse entre sí, como verás en un momento).

  • En paralelo significa de verdad al mismo tiempo, en distintos núcleos del procesador.
  • Concurrente significa en un solo núcleo, turnándose tan rápido que parece al mismo tiempo.

Para quien programa, la diferencia rara vez importa: en los dos casos no sabes qué hilo continuará y cuándo.

Python (CPython, el de python.org) tiene una trampa: el GIL (global interpreter lock). En cada momento solo un hilo ejecuta código Python, aunque el procesador tenga muchos núcleos. Por eso los hilos ayudan cuando el programa espera (red, archivos, sleep): mientras uno espera, otro trabaja. Para cálculos pesados los hilos no aceleran nada. Ahí se usan procesos, que verás en el ejercicio.

Más precisamente, para curiosos
  • El navegador Chrome lanza un proceso aparte para cada pestaña. Por eso un sitio que se cuelga no detiene a los demás, y también por eso el navegador usa mucha memoria.
  • Todo programa que hace varias cosas a la vez usa hilos: un juego reproduce el sonido en un hilo y ejecuta la lógica en otro; un servidor web atiende a cada cliente en su propio hilo.
  • El precio: cada hilo tiene su propia pila, y la memoria para ella se reserva por separado. Miles de hilos cuestan mucho. Por eso los programas reales usan un grupo de hilos (la última idea de la lección). - Python 3.13 tiene además una versión aparte sin GIL (free-threaded). Todavía es experimental, así que aquí hablamos del Python normal.

Míralo

El primer hilo

La clase Thread del módulo threading es un hilo. Con target le dices qué función ejecutar, y con args qué argumentos pasarle (una tupla):

worker = threading.Thread(target=download, args=("abril.pdf", 0.2))
worker.start()
print("El programa no espera")
worker.join()
print("El informe está descargado")
Salida
El programa no espera
Descargué abril.pdf
El informe está descargado
  • start() lanza el hilo nuevo y vuelve enseguida. El programa sigue sin esperar, por eso «El programa no espera» se imprime primero.
  • join() hace que el programa espere a que el hilo termine.

download es la función de la primera tarjeta. Python no tiene excepciones comprobadas, así que aquí no hay nada que declarar.

Más precisamente, para curiosos
target es la forma más corta, pero no la única. También te encontrarás una subclase de Thread que escribe su propio run(), y una lambda:
class ReportThread(threading.Thread):
def run(self):
print("1. una subclase de Thread")
def report_job():
print("2. una función como target")
a = ReportThread()
b = threading.Thread(target=report_job)
c = threading.Thread(target=lambda: print("3. una lambda"))
for t in [a, b, c]:
t.start()
t.join()
Salida
1. una subclase de Thread
2. una función como target
3. una lambda
Python no tiene clases anónimas. Es más flexible separar la tarea (la función) del hilo: así la misma función se puede dar a un grupo de hilos.

Míralo

Tres a la vez

Ahora los tres informes, cada uno en su propio hilo:
start = time.time()
march = threading.Thread(target=download, args=("marzo.pdf", 0.6))
april = threading.Thread(target=download, args=("abril.pdf", 0.2))
may = threading.Thread(target=download, args=("mayo.pdf", 0.4))
march.start()
april.start()
may.start()
march.join()
april.join()
may.join()
seconds = time.time() - start
print(f"Listo en menos de un segundo: {seconds < 1}")
Salida
Descargué abril.pdf
Descargué mayo.pdf
Descargué marzo.pdf
Listo en menos de un segundo: True

Los hilos arrancan en el orden marzo, abril, mayo, pero terminan en el orden de las pausas: abril (0,2 s), mayo (0,4 s), marzo (0,6 s). La espera completa dura lo que la más larga, 0,6 segundos en lugar de 1,2.

El sistema operativo decide en qué orden llegan los hilos a print. Aquí las pausas son lo bastante distintas, así que el orden es siempre el mismo. Si fueran iguales, podría cambiar en cada ejecución.

Pruébalo

start o run

¿Qué imprimirá el programa? (name es el nombre del hilo)

class Report(threading.Thread):
def run(self):
print(f"Se ejecuta en: {threading.current_thread().name}")
t = Report(name="descarga")
t.run()
t.start()
t.join()
Respuesta

La idea

La vida de un hilo

is_alive() te dice si el hilo está trabajando en este momento: desde start() hasta el final de su función:
t = threading.Thread(target=download, args=("mayo.pdf", 0.5))
print(t.is_alive())
t.start()
time.sleep(0.1)
print(t.is_alive())
t.join()
print(t.is_alive())
try:
t.start()
except RuntimeError as e:
print(f"RuntimeError: {e}")
Salida
False
True
Descargué mayo.pdf
False
RuntimeError: threads can only be started once

Python no te dice más que eso: no se ve si espera o calcula. Un hilo se lanza una sola vez. Un segundo start() sobre el mismo hilo lanza RuntimeError.

Más precisamente, para curiosos
  • Un hilo en Python no se puede detener desde fuera. Si tiene que detenerse, él mismo comprueba una señal, por ejemplo un threading.Event que otro hilo activa con set().
  • name y native_id son el nombre y el número del hilo. threading.current_thread() te da el hilo en el que se está ejecutando el código.
  • time.sleep(0) sugiere que el hilo puede ceder el paso a otro. Lo usaremos en un momento.

Problema

Las visitas perdidas

El sitio cuenta sus visitas. Dos hilos atienden a los visitantes, y cada uno llama a increment() 10 000 veces. Entre leer y escribir el contador, el hilo hace otra cosa. Aquí eso es time.sleep(0): «deja trabajar a los otros hilos un momento»:
visits.py
class Counter:
def __init__(self):
self.count = 0
def increment(self):
value = self.count
time.sleep(0) # aquí el hilo cede el paso al otro
self.count = value + 1
visits = Counter()
def work():
for i in range(10_000):
visits.increment()
first = threading.Thread(target=work)
second = threading.Thread(target=work)
first.start()
second.start()
first.join()
second.join()
print(f"Visitas: {visits.count}")

Esperas 20 000. Cinco ejecuciones:

Salida (distinta cada vez)
Visitas: 10000
Visitas: 10001
Visitas: 10001
Visitas: 10000
Visitas: 10000

Cada vez menos, casi la mitad. La razón es que el aumento no es un solo paso, sino tres:

value = self.count # 1. leer
value = value + 1 # 2. sumar 1
self.count = value # 3. escribir

class Counter:
def __init__(self):
self.count = 5
self.lock = threading.Lock()
def increment(self):
self.count += 1 # leer, sumar 1, escribir
def safe_increment(self):
with self.lock:
self.count += 1
Paso a paso, en palabras
  1. count vale 5. Los hilos A y B llaman a increment() al mismo tiempo.
  2. A lee count: 5.
  3. B lee count antes de que A haya escrito: también 5.
  4. A suma 1 en su propia copia y escribe 6.
  5. B también escribe 6. Dos llamadas, y count vale 6, no 7: una de las sumas se ha perdido.
  6. Otra vez desde el principio, con with self.lock: A toma el cerrojo, lee 5 y escribe 6.
  7. B llega a with self.lock y espera hasta que A suelte el cerrojo.
  8. B toma el cerrojo, lee 6 y escribe 7: no se pierde nada.

Cuando los dos hilos leen el mismo valor antes de que alguno haya escrito, una de las sumas se pierde. Esto se llama condición de carrera (race condition). Que ocurra o no depende de cuándo se cambia de hilo, por eso el resultado es distinto en cada ejecución.

Más precisamente, para curiosos

Sin time.sleep(0), con self.count += 1 y 100 000 aumentos por hilo, cinco ejecuciones en Python 3.12 dieron cinco veces 200 000:

class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
Salida
Visitas: 200000
Visitas: 200000
Visitas: 200000
Visitas: 200000
Visitas: 200000

No es porque sea seguro. El GIL cambia de hilo solo en ciertos puntos de la ejecución, y aquí, por casualidad, no es entre la lectura y la escritura. Python no lo promete: otra versión, otro código en el método o Python sin GIL pierden visitas. Por eso los datos compartidos se protegen siempre con un cerrojo.

La idea

Lock: de uno en uno

La solución es que estos tres pasos los haga un solo hilo a la vez. Eso hace el cerrojo threading.Lock: el bloque with self.lock: es para un solo hilo en cada momento:
class Counter:
def __init__(self):
self.count = 0
self.lock = threading.Lock()
def increment(self):
with self.lock:
value = self.count
time.sleep(0)
self.count = value + 1
Salida
Visitas: 20000

El hilo toma el cerrojo al entrar en with y lo suelta al salir, incluso si hay una excepción. El otro hilo espera delante del with. Ahora el resultado es 20 000 en cada ejecución. Python no tiene un contador atómico ya hecho como Java: el cerrojo es la forma.

Más precisamente, para curiosos
  • Condition: wait() y notify_all(): un hilo dentro del bloque with de una Condition puede soltar el cerrojo y dormirse hasta que otro hilo lo despierte. Así la cocina espera un pedido, y la caja espera a que la cocina recoja el anterior (el problema del «productor y consumidor»):
class OrderBox:
def __init__(self):
self.order = None
self.changed = threading.Condition()
def put(self, new_order):
with self.changed:
while self.order is not None:
self.changed.wait()
self.order = new_order
self.changed.notify_all()
def take(self):
with self.changed:
while self.order is None:
self.changed.wait()
result = self.order
self.order = None
self.changed.notify_all()
return result
box = OrderBox()
def kitchen():
for i in range(3):
print(f"La cocina recibió {box.take()}")
cook = threading.Thread(target=kitchen)
cook.start()
box.put("pizza")
box.put("ensalada")
box.put("sopa")
cook.join()
Salida
La cocina recibió pizza
La cocina recibió ensalada
La cocina recibió sopa

wait() está dentro de un while, no de un if: el hilo puede despertarse sin motivo, así que vuelve a comprobar la condición. queue.Queue(maxsize=1) hace lo mismo ya hecho: put espera si la cola está llena, y get espera si está vacía.

  • Interbloqueo (deadlock): dos hilos se esperan mutuamente para siempre. La oficina tiene la impresora y espera el escáner, y la recepción tiene el escáner y espera la impresora:
printer = threading.Lock()
scanner = threading.Lock()
def office():
with printer:
time.sleep(0.1)
with scanner:
print("La oficina está copiando")
def reception():
with scanner:
time.sleep(0.1)
with printer:
print("La recepción está copiando")

El programa se cuelga y nunca imprime nada. La forma de evitarlo es que todos los hilos tomen los cerrojos en el mismo orden.

  • Los hilos en Python no tienen prioridades. Hay hilos en segundo plano (daemon=True): el programa termina sin esperarlos:
report = threading.Thread(target=download, args=("anual.pdf", 0.1), daemon=True)
print(report.name, report.daemon)
Salida
Thread-1 (download) True

Pruébalo

Dos cajeros automáticos

Dos personas sacan 80 € cada una de una misma cuenta con 100 € en el mismo instante. A veces las dos reciben el dinero, y el saldo queda en -60 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Grupo de hilos y Future

Crear un hilo para cada tarea es caro, y con mil tareas también es peligroso. Un grupo de hilos (thread pool) son unos pocos hilos que toman tareas de una cola. En Python es ThreadPoolExecutor (de concurrent.futures):
def size_of(file, seconds):
time.sleep(seconds)
return len(file) * 100
files = ["marzo.pdf", "abril.pdf", "mayo.pdf", "junio.pdf"]
with ThreadPoolExecutor(max_workers=2) as pool:
sizes = [pool.submit(size_of, file, 0.3) for file in files]
for file, size in zip(files, sizes):
print(f"{file}: {size.result()} KB")
Salida
marzo.pdf: 900 KB
abril.pdf: 900 KB
mayo.pdf: 800 KB
junio.pdf: 900 KB
  • ThreadPoolExecutor(max_workers=2) crea un grupo de 2 hilos. Las cuatro tareas se turnan en ellos.
  • submit(función, argumentos) recibe una tarea y enseguida te da un Future: una promesa de que el resultado llegará.
  • result() espera a que la tarea termine y devuelve el resultado. Si la tarea lanzó una excepción, result() la vuelve a lanzar.
  • with detiene el grupo al final: espera a que terminen las tareas y detiene sus hilos.
Más precisamente, para curiosos
Si la tarea no devuelve nada, simplemente no guardas el Future. El final del with espera a todas:
with ThreadPoolExecutor(max_workers=2) as pool:
for i in range(1, 6):
pool.submit(download, f"informe-{i}.pdf", 0.1)
print("Todos terminaron.")
Salida de una ejecución
Descargué informe-2.pdf
Descargué informe-1.pdf
Descargué informe-3.pdf
Descargué informe-4.pdf
Descargué informe-5.pdf
Todos terminaron.
Los dos primeros informes van al mismo tiempo, así que su orden cambia de una ejecución a otra. Sin with, escribes pool.shutdown(wait=True) al final.

Pruébalo

¿Qué resultado sale primero?

«archivo.zip» tarda 0,6 s en descargarse, y «nota.txt», 0,1 s. ¿Qué imprimirá el programa?

with ThreadPoolExecutor(max_workers=2) as pool:
big = pool.submit(size_of, "archivo.zip", 0.6)
small = pool.submit(size_of, "nota.txt", 0.1)
print(f"Grande: {big.result()}")
print(f"Pequeño: {small.result()}")
Respuesta

Ejercicio

Programas con hilos

Resuélvelos en tu editor.

1. Suma en paralelo. La lista sales tiene 1 000 000 de ventas, sales[i] = i % 100. Divídelo en 4 partes iguales y suma cada una en su propio hilo. Cada hilo escribe en su propio lugar de la lista partial, así que no necesitas sincronización. Espéralos con join y suma las partes:

Salida
Partes: [12375000, 12375000, 12375000, 12375000]
Total: 49500000
Más precisamente, para curiosos

Por el GIL, los cuatro hilos no calculan más rápido que uno. De verdad en paralelo es con procesos: ProcessPoolExecutor lanza copias separadas de Python, cada una con su propio GIL. Se les pasan las partes, y el resultado vuelve:

from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
sales = [i % 100 for i in range(1_000_000)]
size = len(sales) // 4
chunks = [sales[p * size:(p + 1) * size] for p in range(4)]
with ProcessPoolExecutor(max_workers=4) as pool:
partial = list(pool.map(sum, chunks))
print(f"Partes: {partial}")
print(f"Total: {sum(partial)}")
Salida
Partes: [12375000, 12375000, 12375000, 12375000]
Total: 49500000

if __name__ == "__main__": evita que el código principal se ejecute de nuevo en cada proceso: en Windows y macOS un proceso arranca cargando el archivo desde el principio.

2. Descarga con un grupo. Descarga seis informes (marzo.pdf, abril.pdf, mayo.pdf, junio.pdf, julio.pdf, agosto.pdf) con un grupo de 3 hilos. Usa size_of(file, 0.3) de la tarjeta del grupo. Imprime los tamaños en el orden de los archivos, el total, y si todo tardó menos de 1,5 segundos (uno tras otro serían 1,8 s):

Salida
marzo.pdf: 900 KB
abril.pdf: 900 KB
mayo.pdf: 800 KB
junio.pdf: 900 KB
julio.pdf: 900 KB
agosto.pdf: 1000 KB
Total: 5400 KB
Menos de 1,5 segundos: True

3. Un contador sin pérdidas. Cuatro hilos, cada uno con 50 000 visitas, aumentan un contador compartido. Escribe el contador de forma que el resultado sea siempre 200 000. Pruébalo primero sin Lock y con time.sleep(0) entre la lectura y la escritura y mira cuánto se pierde, luego corrígelo con un Lock.

Salida
Visitas: 200000

Más tareas

  • Una prueba para el contador. Escribe una prueba de unittest que lance 8 hilos con 10 000 aumentos cada uno y compruebe 80 000 con assertEqual. Ejecútala varias veces contra el contador con time.sleep(0) y sin Lock. Un test que falla solo a veces es la señal típica de una condición de carrera.
  • El interbloqueo. Ejecuta el ejemplo de la impresora y el escáner de «Más precisamente» (con dos hilos y join) y mira cómo se cuelga. Corrígelo de forma que los dos hilos tomen primero la impresora.

Test

Ponte a prueba

1. ¿Qué diferencia hay entre t.start() y t.run()?
2. ¿Por qué el programa llama a join() antes de imprimir el resultado?
3. Dos hilos aumentan un contador compartido sin Lock. ¿Qué promete Python sobre el resultado?
4. ¿Qué garantiza with self.lock: alrededor de unas líneas?
5. Lanzas tres tareas con pool.submit y guardas un Future para cada una. ¿Qué hace future.result()?

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