Python Async vs Threads: Cuándo usar asyncio
Por qué la concurrencia importa en Python
Tienes un script de Python que obtiene datos de múltiples APIs. Funciona, pero es lento: cada solicitud espera a que termine la anterior. Has oído hablar de asyncio y los threads, pero ¿cuál deberías usar? Elegir el modelo incorrecto puede llevar a un código innecesariamente complejo o dolorosamente lento.
Esta guía aclara la confusión. Compararemos los modelos async y threading de Python, te mostraremos exactamente cuándo usar cada uno y proporcionaremos ejemplos prácticos que puedes adaptar a tus propios proyectos.
Entendiendo el panorama de concurrencia en Python
Python ofrece tres modelos principales de concurrencia:
- Threads – Múltiples hilos de ejecución dentro de un solo proceso, gestionados por el SO. Limitados por el Global Interpreter Lock (GIL) para trabajo CPU-bound.
- Async (asyncio) – Bucle de eventos de un solo hilo que cambia entre tareas en puntos
await. Excelente para operaciones I/O-bound. - Multiprocessing – Procesos separados, cada uno con su propio intérprete de Python y espacio de memoria. Evita el GIL para verdadero paralelismo.
El GIL es un mutex que impide que múltiples hilos ejecuten bytecode de Python simultáneamente. Esto significa que los threads no aceleran tareas CPU-bound, pero sí ayudan con tareas I/O-bound porque el GIL se libera durante las operaciones de E/S.
Cuándo usar asyncio
asyncio brilla cuando tu programa pasa la mayor parte del tiempo esperando eventos externos: solicitudes de red, E/S de archivos, consultas a bases de datos o temporizadores. Debido a que usa un solo hilo, evitas la sobrecarga de creación de hilos y cambio de contexto. El bucle de eventos gestiona eficientemente miles de conexiones concurrentes.
Usa asyncio cuando:
- Estás construyendo un servidor o cliente web que maneja muchas conexiones simultáneas (por ejemplo, FastAPI, aiohttp).
- Tus tareas son I/O-bound y necesitas alta concurrencia (cientos o miles de tareas).
- Quieres un control detallado sobre la programación y cancelación de tareas.
- Ya estás usando un framework o librería async.
Aquí tienes un ejemplo simple que obtiene múltiples URLs concurrentemente:
import asyncio
import aiohttp
async def fetch(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
async def main():
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f'Fetched {len(results)} pages')
asyncio.run(main())
Cuándo usar threads
Los threads son una buena opción cuando tienes operaciones de E/S bloqueantes que no tienen equivalentes async, o cuando trabajas con código heredado que no es compatible con async. También son más simples de razonar para un pequeño número de tareas.
Usa threads cuando:
- Necesitas ejecutar E/S bloqueante en paralelo, como leer múltiples archivos o hacer llamadas API síncronas.
- Estás integrando con una librería que no soporta async.
- Tienes un número moderado de tareas concurrentes (decenas, no miles).
- Quieres mantener tu código síncrono pero aún así superponer E/S.
Ejemplo usando concurrent.futures.ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
print(f'Fetched {len(results)} pages')
Comparando asyncio y threads
Aquí tienes una comparación lado a lado para ayudarte a decidir:
| Aspecto | asyncio | Threads |
|---|---|---|
| Modelo de concurrencia | Bucle de eventos de un solo hilo | Múltiples hilos del SO |
| Mejor para | I/O-bound, alta concurrencia | I/O-bound, llamadas bloqueantes |
| Rendimiento CPU-bound | Pobre (GIL) | Pobre (GIL) |
| Escalabilidad | Miles de tareas | Cientos de hilos |
| Complejidad | Requiere sintaxis async/await | Estilo síncrono familiar |
| Depuración | Puede ser complicada con tracebacks | Herramientas de depuración estándar |
| Ecosistema | Soporte async creciente | Universal |
¿Qué pasa con las tareas CPU-bound?
Ni asyncio ni los threads te ayudarán a paralelizar trabajo intensivo en CPU en Python debido al GIL. Para tareas CPU-bound, como cálculo numérico, procesamiento de imágenes o compresión de datos, usa multiprocessing o concurrent.futures.ProcessPoolExecutor.
Ejemplo:
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**6, 10**6, 10**6]))
print(results)
Mejores prácticas y trampas
- No mezcles llamadas bloqueantes con asyncio. Si llamas a una función bloqueante dentro de una tarea async, bloqueará todo el bucle de eventos. Usa
asyncio.to_thread()para ejecutar código bloqueante en un pool de threads. - Limita el tamaño del pool de threads. Crear demasiados hilos conduce a sobrecarga por cambio de contexto. Usa
ThreadPoolExecutor(max_workers=N). - Maneja las excepciones adecuadamente. En asyncio, las excepciones no manejadas en tareas pueden pasar desapercibidas. Usa
asyncio.gather(..., return_exceptions=True)o añade callbacks. - Prefiere asyncio para nuevos proyectos con mucha E/S. El ecosistema es maduro, y frameworks como FastAPI y aiohttp lo hacen fácil.
- Usa threads para scripts heredados o simples. Si solo necesitas paralelizar algunas llamadas bloqueantes, los threads son menos invasivos.
Preguntas frecuentes
¿Puedo usar asyncio y threads juntos?
Sí. Puedes ejecutar código bloqueante en un pool de threads usando asyncio.to_thread() o loop.run_in_executor(). Esto es útil cuando necesitas llamar a una librería síncrona desde código async sin bloquear el bucle de eventos.
¿asyncio funciona con el GIL?
Sí, asyncio se ejecuta en un solo hilo y está sujeto al GIL. Sin embargo, como está diseñado para tareas I/O-bound, el GIL se libera durante las operaciones de E/S, permitiendo que otras tareas se ejecuten. Para trabajo CPU-bound, asyncio no ofrece ninguna aceleración.
¿Qué es más rápido: asyncio o threads?
Para tareas I/O-bound con muchas operaciones concurrentes, asyncio es generalmente más rápido y escalable porque evita la sobrecarga de los hilos. Para un pequeño número de tareas bloqueantes, los threads pueden ser más simples y rendir de manera similar. Ninguno ayuda con tareas CPU-bound.
Tomando la decisión correcta
Comienza identificando si tu cuello de botella es E/S o CPU. Si es E/S y necesitas alta concurrencia, recurre a asyncio. Si es E/S pero estás lidiando con librerías bloqueantes o scripts más simples, los threads son una opción sólida. Para trabajo CPU-bound, usa multiprocessing.
Recuerda que a menudo puedes combinar estos modelos, por ejemplo, usando asyncio para operaciones de red y un pool de threads para E/S de archivos. La clave es entender las ventajas y desventajas y elegir la herramienta que se ajuste a tu problema específico.
Cuando necesites formatear o validar rápidamente datos JSON devueltos por tus llamadas API concurrentes, prueba nuestro JSON Formatter para imprimir y depurar con facilidad.