Los threads no arreglan tu Python limitado por CPU
Los threads no van a acelerar tu Python limitado por CPU. Nunca. En CPython, el global interpreter lock (GIL) permite que solo un thread ejecute bytecode de Python a la vez — así que diez threads haciendo cálculos se turnan en un solo núcleo mientras los demás quedan ociosos. Y aun así, si le pides a un asistente de IA "paraleliza esto", agarra ThreadPoolExecutor por defecto, sin importar la carga de trabajo. La solución es una sola decisión: identifica el límite y elige el modelo.
La tabla que zanja la discusión
| Tu trabajo es… | Ejemplos | Usa | Por qué funciona |
|---|---|---|---|
| Limitado por I/O, pocas llamadas | algunas peticiones HTTP, lectura de archivos | ThreadPoolExecutor | El GIL siempre se libera durante I/O, así que los threads sí solapan las esperas |
| Limitado por I/O, miles de conexiones | scrapers, websockets, fan-out de APIs | asyncio | Un solo thread con event loop; la documentación lo llama "un ajuste perfecto para código de red estructurado y limitado por IO" |
| Limitado por CPU | procesamiento de imágenes, bucles de hashing, matemática pesada en Python puro | ProcessPoolExecutor | Procesos separados, cada uno con su propio intérprete: "esquiva el Global Interpreter Lock" |
La propia biblioteca estándar codifica esta división en los valores por defecto: ThreadPoolExecutor usa min(32, os.process_cpu_count() + 4) — más workers que núcleos, porque se espera que estén esperando — mientras que ProcessPoolExecutor usa exactamente os.process_cpu_count(), un proceso ocupado por núcleo.
El cambio de dos líneas
Ambos executors comparten la interfaz de concurrent.futures, así que corregir una mala elección suele ser editar una palabra:
from concurrent.futures import ProcessPoolExecutor # was ThreadPoolExecutor
with ProcessPoolExecutor() as pool: # one process per core
results = list(pool.map(hash_block, blocks))
Mismo submit(), mismo map(), mismos futures. La advertencia: los pools de procesos serializan (pickle) argumentos y resultados entre procesos, así que pasa datos simples (bytes, dicts, dataclasses), no sockets abiertos ni lambdas.
Cómo saber qué límite estás enfrentando
No adivines: mide. Ejecuta la versión secuencial una vez:
- Tiempo de reloj ≫ tiempo de CPU (el proceso estuvo esperando) → limitado por I/O → threads o asyncio.
- Tiempo de reloj ≈ tiempo de CPU (un núcleo estuvo al 100% toda la ejecución) → limitado por CPU → procesos.
time.process_time() frente a time.perf_counter() alrededor del bucle caliente lo responde en cuatro líneas. Esa es también la pregunta de code review cuando un asistente te entrega código con threads: "¿qué dijo el profiler sobre el límite?"
Notas para usuarios avanzados
- Mezclar código síncrono en asyncio: una llamada bloqueante dentro de una corrutina congela todo el event loop. Envuélvela con
await asyncio.to_thread(blocking_fn, arg)— corre en un thread mientras el loop sigue atendiendo. - Trabajo de CPU desde asyncio:
await loop.run_in_executor(process_pool, cpu_fn, arg)permite que un servidor async delegue la matemática pesada a un pool de procesos sin bloquearse. - Las extensiones en C doblan la regla: NumPy y las rutinas de compresión y hashing liberan el GIL durante su trabajo en C, así que los threads sí pueden acelerarlas. La regla aplica al bytecode de Python puro.
- Al GIL le quedan pocos días: desde Python 3.13 existe un build free-threaded (PEP 703) que puede desactivar el GIL por completo (build con
--disable-gil, ejecutar conPYTHON_GIL=0). Hasta que ese sea tu intérprete de producción, planifica alrededor del GIL.
Recursos
- concurrent.futures — ThreadPoolExecutor y ProcessPoolExecutor
- asyncio — el event loop para código de red limitado por I/O
- Global interpreter lock — glosario de Python
- asyncio.to_thread — ejecutar código bloqueante sin congelar el loop
- PEP 703 — hacer opcional el GIL
¿Construyendo una funcionalidad con IA? Yeda AI diseña, audita y entrega sistemas LLM de producción.