Python Threading
Aprende Python threading desde cero: crea hilos, sincroniza con locks, usa colas, ThreadPoolExecutor y entiende cuándo importa el GIL.
El módulo threading de Python te permite ejecutar múltiples tareas en el mismo proceso al mismo tiempo. Cada tarea se ejecuta en su propio hilo — una unidad de ejecución ligera que comparte el espacio de memoria del proceso. El threading es la herramienta adecuada cuando tu programa pasa la mayor parte del tiempo esperando (leyendo un archivo, realizando una solicitud HTTP, consultando una base de datos) y quieres hacer trabajo útil durante esa espera en lugar de bloquearte.
Este capítulo cubre:
- Crear e iniciar hilos con
threading.Thread - Esperar a que los hilos terminen con
join - Hilos daemon y tareas en segundo plano
- Prevenir condiciones de carrera con
Lockywith - Coordinar hilos con
EventySemaphore - Comunicación segura entre hilos usando
queue.Queue - El
ThreadPoolExecutorpara grupos de hilos administrados - El Global Interpreter Lock (GIL) y por qué los hilos no aceleran el código ligado a CPU
- Cuándo elegir threading vs. asyncio
Crear e iniciar un hilo
Importa threading y crea un objeto Thread, pasando la función a ejecutar como target. Llama a .start() para lanzar el hilo:
import threading
import time
def greet(name):
time.sleep(0.5) # simulate some work
print(f'Hello, {name}!')
t = threading.Thread(target=greet, args=('Alice',))
t.start()
print('Thread started — main continues running')
t.join() # wait for the thread to finish
print('Thread finished')
# Thread started — main continues running
# Hello, Alice!
# Thread finishedPuntos clave:
argses una tupla de argumentos posicionales pasados atarget. Usakwargspara argumentos por nombre.- Sin
.join(), el hilo principal puede salir antes de que el hilo creado termine. .start()retorna inmediatamente; el nuevo hilo se ejecuta de forma concurrente.
Pasar argumentos por nombre
import threading
def connect(host, port=80):
print(f'Connecting to {host}:{port}')
t = threading.Thread(target=connect, kwargs={'host': 'example.com', 'port': 443})
t.start()
t.join()
# Connecting to example.com:443Ejecutar múltiples hilos a la vez
El verdadero beneficio del threading es ejecutar varias tareas en paralelo. Lanza todos los hilos primero y luego espera a todos:
import threading
import time
def download(url):
time.sleep(1) # simulate a 1-second network request
print(f'Downloaded: {url}')
urls = [
'https://example.com/data1',
'https://example.com/data2',
'https://example.com/data3',
]
start = time.perf_counter()
threads = [threading.Thread(target=download, args=(url,)) for url in urls]
for t in threads:
t.start()
for t in threads:
t.join()
elapsed = time.perf_counter() - start
print(f'All downloads finished in {elapsed:.1f}s')
# Downloaded: https://example.com/data1
# Downloaded: https://example.com/data2
# Downloaded: https://example.com/data3
# All downloads finished in 1.0sSin hilos esto tomaría 3 segundos (secuencial). Con tres hilos tarda aproximadamente 1 segundo porque las esperas se superponen.
Crear subclases de Thread
Para lógica más compleja, crea una subclase de threading.Thread y sobreescribe run(). Guarda los resultados como atributos de instancia para que el código que llama pueda leerlos después de join():
import threading
import time
class DownloadThread(threading.Thread):
def __init__(self, url):
super().__init__()
self.url = url
self.result = None
def run(self):
time.sleep(0.5) # simulate download
self.result = f'Data from {self.url}'
threads = [DownloadThread(f'https://example.com/page{i}') for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
for t in threads:
print(t.result)
# Data from https://example.com/page0
# Data from https://example.com/page1
# Data from https://example.com/page2Hilos daemon
Un hilo daemon es un hilo en segundo plano que el intérprete termina automáticamente cuando todos los hilos no-daemon han salido. Marca un hilo como daemon pasando daemon=True (o configurando t.daemon = True antes de .start()):
import threading
import time
def heartbeat():
while True:
print('♥ still running')
time.sleep(1)
t = threading.Thread(target=heartbeat, daemon=True)
t.start()
time.sleep(2.5)
print('Main thread exiting — daemon will be killed')
# ♥ still running
# ♥ still running
# Main thread exiting — daemon will be killedUsa hilos daemon para tareas de monitoreo en segundo plano o registro que no deben impedir que el programa salga. Nunca los uses para tareas que deben completarse limpiamente (escrituras de archivos, commits de base de datos) — se terminan sin ninguna limpieza.
Nombres de hilos e introspección
Cada hilo tiene un nombre. Puedes establecerlo explícitamente o dejar que Python asigne uno automáticamente. Usa threading.current_thread() para inspeccionar el hilo en ejecución y threading.active_count() para contar los hilos activos:
import threading
def worker():
t = threading.current_thread()
print(f'Running in thread: {t.name}')
t = threading.Thread(target=worker, name='WorkerThread-1')
t.start()
t.join()
print(f'Active threads: {threading.active_count()}')
# Running in thread: WorkerThread-1
# Active threads: 1Sincronización: prevenir condiciones de carrera
Los hilos comparten la memoria del proceso. Cuando dos hilos leen y escriben la misma variable simultáneamente, el resultado es una condición de carrera — comportamiento no determinista que es difícil de reproducir o depurar.
El siguiente ejemplo sin un lock produce un conteo final impredecible porque los incrementos de diferentes hilos pueden superponerse:
import threading
counter = 0
def unsafe_increment():
global counter
for _ in range(100_000):
counter += 1 # read-modify-write: not atomic!
threads = [threading.Thread(target=unsafe_increment) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# counter is somewhere between 100000 and 500000 — unpredictable
print('Final counter:', counter)Lock
Un threading.Lock garantiza que solo un hilo ejecute la sección protegida a la vez. Úsalo como gestor de contexto con with para que el lock siempre se libere, incluso si se lanza una excepción:
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(100_000):
with lock: # acquire before read-modify-write
counter += 1 # now only one thread at a time can run this
threads = [threading.Thread(target=safe_increment) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print('Final counter:', counter) # always 500000RLock (lock re-entrante)
Si un hilo necesita adquirir el mismo lock dos veces (por ejemplo, un método llama a otro método que también adquiere el lock), usa threading.RLock. Permite que el mismo hilo adquiera el lock de nuevo sin causar un deadlock:
import threading
lock = threading.RLock()
def outer():
with lock:
print('Outer acquired')
inner() # inner also acquires the same lock
def inner():
with lock: # works because RLock counts acquisitions
print('Inner acquired')
t = threading.Thread(target=outer)
t.start()
t.join()
# Outer acquired
# Inner acquiredCoordinar hilos: Event y Semaphore
Event
threading.Event es una señal simple. Un hilo llama a .set() para señalizar; otros hilos llaman a .wait() para bloquearse hasta que llegue la señal:
import threading
import time
ready = threading.Event()
def worker():
print('Worker: waiting for signal...')
ready.wait() # blocks here until ready.set() is called
print('Worker: signal received, starting work')
t = threading.Thread(target=worker)
t.start()
time.sleep(0.5)
print('Main: sending signal')
ready.set()
t.join()
# Worker: waiting for signal...
# Main: sending signal
# Worker: signal received, starting workUsa un Event para coordinar el orden de inicio — por ejemplo, para retrasar los hilos trabajadores hasta que se establezca una conexión a la base de datos.
Semaphore
Un threading.Semaphore limita el número de hilos que pueden estar dentro de una sección simultáneamente. Es útil para limitar la tasa de acceso a un recurso compartido como un pool de conexiones:
import threading
import time
# Allow at most 2 threads to enter the critical section at once
semaphore = threading.Semaphore(2)
def use_connection(name):
with semaphore:
print(f'{name}: using connection')
time.sleep(0.5)
print(f'{name}: releasing connection')
threads = [threading.Thread(target=use_connection, args=(f'T{i}',)) for i in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
# T0: using connection
# T1: using connection <- only 2 at a time
# T0: releasing connection
# T2: using connection
# T1: releasing connection
# T3: using connection
# T2: releasing connection
# T3: releasing connectionDatos locales por hilo
threading.local() crea un object que almacena valores separados por hilo. Es útil para cachés o cursores de base de datos por hilo:
import threading
local_data = threading.local()
def set_user(name):
local_data.user = name # each thread writes its own copy
print(f'{threading.current_thread().name}: user = {local_data.user}')
threads = [
threading.Thread(target=set_user, args=(f'user{i}',), name=f'Thread-{i}')
for i in range(3)
]
for t in threads:
t.start()
for t in threads:
t.join()
# Thread-0: user = user0
# Thread-1: user = user1
# Thread-2: user = user2Leer local_data.user en un hilo donde nunca se ha establecido lanza AttributeError, igual que cualquier otro acceso a un atributo.
Colas seguras para hilos
La clase queue.Queue (del módulo queue de la biblioteca estándar, no de asyncio) es una FIFO segura para hilos. Los hilos pueden usar put y get en elementos sin un lock — toda la sincronización se maneja internamente.
El patrón clásico es productor-consumidor: uno o más hilos productores generan trabajo, los hilos consumidores lo procesan:
import threading
import queue
import time
q = queue.Queue(maxsize=5)
def producer():
for i in range(1, 5):
q.put(f'item-{i}')
print(f'Produced item-{i}')
time.sleep(0.05)
def consumer():
while True:
item = q.get()
if item is None: # sentinel: stop when None is received
break
print(f'Consumed {item}')
q.task_done()
prod = threading.Thread(target=producer)
cons = threading.Thread(target=consumer)
cons.start()
prod.start()
prod.join()
q.put(None) # signal consumer to stop
cons.join()
# Produced item-1
# Consumed item-1
# Produced item-2
# Consumed item-2
# Produced item-3
# Consumed item-3
# Produced item-4
# Consumed item-4queue.Queue también ofrece task_done() y join() para rastrear cuándo se han procesado todos los elementos encolados, y queue.LifoQueue / queue.PriorityQueue para ordenaciones alternativas.
ThreadPoolExecutor: grupos de hilos administrados
Crear un nuevo objeto Thread para cada tarea es costoso cuando tienes muchas tareas de corta duración. concurrent.futures.ThreadPoolExecutor administra un pool de hilos trabajadores reutilizables y devuelve objetos Future por cada tarea enviada:
import concurrent.futures
import time
def fetch_url(url):
time.sleep(0.5) # simulate network I/O
return f'Response from {url}'
urls = [
'https://api.example.com/users',
'https://api.example.com/posts',
'https://api.example.com/comments',
]
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
# submit all tasks and get Future objects
futures = {executor.submit(fetch_url, url): url for url in urls}
for future in concurrent.futures.as_completed(futures):
url = futures[future]
print(future.result())
# Response from https://api.example.com/users (order may vary)
# Response from https://api.example.com/comments
# Response from https://api.example.com/postsexecutor.map(fn, iterable) es una forma más corta cuando no necesitas objetos Future individuales:
import concurrent.futures
import time
def square(n):
time.sleep(0.01)
return n * n
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(square, range(10)))
print(results)
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]executor.map preserva el orden de entrada en su salida, a diferencia de as_completed que produce resultados en orden de finalización.
El Global Interpreter Lock (GIL)
CPython (el intérprete estándar de Python) tiene un Global Interpreter Lock — un mutex que permite que solo un hilo ejecute bytecode de Python a la vez. Esto significa que los hilos en CPython no pueden ejecutar código Python en verdadero paralelo en múltiples núcleos de CPU.
La implicación práctica:
- Tareas ligadas a I/O: los hilos genuinamente aceleran el programa. Mientras un hilo espera una respuesta de red, el GIL se libera y otro hilo se ejecuta. Los ejemplos anteriores demuestran este comportamiento.
- Tareas ligadas a CPU: los hilos no aceleran las cosas y pueden incluso ser ligeramente más lentos debido al costo de cambio de contexto.
import threading
import time
def cpu_bound(n):
total = 0
for i in range(n):
total += i
return total
# Sequential
start = time.perf_counter()
cpu_bound(5_000_000)
cpu_bound(5_000_000)
single = time.perf_counter() - start
# Two threads — GIL prevents true parallelism
start = time.perf_counter()
t1 = threading.Thread(target=cpu_bound, args=(5_000_000,))
t2 = threading.Thread(target=cpu_bound, args=(5_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
threaded = time.perf_counter() - start
print(f'Single-threaded: {single:.2f}s')
print(f'Two threads: {threaded:.2f}s')
# Two threads are NOT faster (similar elapsed time)Para verdadero paralelismo de CPU en Python, usa multiprocessing o concurrent.futures.ProcessPoolExecutor en su lugar — cada proceso tiene su propio GIL.
Threading vs. asyncio
Tanto threading como asyncio hacen más rápidos los programas ligados a I/O, pero funcionan de forma diferente:
threading | asyncio | |
|---|---|---|
| Modelo de concurrencia | Preventivo — el SO cambia entre hilos | Cooperativo — las corrutinas ceden en await |
| Mejor para | Bibliotecas de terceros bloqueantes | Bibliotecas con soporte async (aiohttp, asyncpg) |
| Estado compartido | Requiere locks explícitos | Seguro dentro de un único event loop |
| Sobrecarga | Un hilo del SO por tarea | Muy baja — miles de corrutinas en un hilo |
| Curva de aprendizaje | Familiar (código de estilo síncrono) | Requiere async/await en todo el código |
Regla general: si estás usando una biblioteca que tiene una versión compatible con async (por ejemplo, aiohttp en lugar de requests), usa asyncio. Si estás atascado con bibliotecas bloqueantes síncronas, usa threading. Para trabajo ligado a CPU, usa multiprocessing.
Errores comunes
Iniciar un hilo dos veces. Llamar a .start() en el mismo objeto Thread más de una vez lanza RuntimeError. Crea una nueva instancia de Thread para cada ejecución.
Olvidar hacer join. Un hilo al que no se le hace join puede seguir ejecutándose cuando el programa termina. Siempre llama a join en los hilos cuya finalización importa, o hazlos daemons si verdaderamente son de tipo "lanzar y olvidar".
Mantener un lock demasiado tiempo. Bloquear un bloque grande de código derrota el propósito de la concurrencia. Mantén las secciones bloqueadas lo más cortas posible — solo protege la operación real de leer-modificar-escribir.
Deadlock. Un deadlock ocurre cuando dos hilos cada uno mantiene un lock que el otro está esperando. Prevenlo adquiriendo siempre múltiples locks en el mismo orden en todos los hilos.
import threading
lock_a = threading.Lock()
lock_b = threading.Lock()
# DEADLOCK: Thread 1 holds lock_a, waits for lock_b
# Thread 2 holds lock_b, waits for lock_a
# FIX: always acquire locks in the same order (lock_a then lock_b) in every threadModificar una lista mientras se itera en otro hilo. Envuelve todo acceso (lectura y escritura) a colecciones compartidas con un lock para evitar RuntimeError: list changed size during iteration.
Resumen de referencia rápida
| Herramienta | Propósito |
|---|---|
threading.Thread(target=fn, args=(...)) | Crear un nuevo hilo |
t.start() | Lanzar el hilo |
t.join() | Esperar a que el hilo termine |
t.daemon = True | Marcar como hilo en segundo plano (se termina al salir) |
threading.Lock() | Exclusión mutua — solo un hilo a la vez |
threading.RLock() | Lock re-entrante — el mismo hilo puede adquirirlo varias veces |
threading.Event() | Señal de un solo uso entre hilos |
threading.Semaphore(n) | Limitar a n hilos concurrentes en una sección |
threading.local() | Almacenamiento por hilo |
queue.Queue | FIFO segura para hilos para patrones productor-consumidor |
ThreadPoolExecutor(max_workers=n) | Pool administrado de hilos trabajadores reutilizables |