¿asyncio o threading? Elige correctamente en 3 preguntas
1. Visión general
Muchas personas que comienzan con concurrencia en Python suelen preguntar “¿asyncio o threading es mejor?” — esta pregunta está mal desde el principio, porque ambas herramientas resuelven problemas distintos. Este artículo te presenta 3 preguntas específicas para que las respondas por ti mismo, con código funcional incluido. Después de leerlo, sabrás exactamente cuándo usar threading, cuándo usar asyncio, y cuándo ambos son inútiles (tienes que usar multiprocessing).
2. ¿Por qué la pregunta “cuál es más rápido” está equivocada?
Python tiene el Global Interpreter Lock (GIL) — solo un thread de Python puede ejecutar bytecode en un momento dado, sin importar cuántos núcleos de CPU tenga la máquina. threading y asyncio no rompen el GIL. Ambos simplemente evitan que la CPU se “bloquee” mientras espera I/O (leer archivos, llamadas de red, consultas a BD) — no aceleran el cálculo.
import time
def cpu_bound_task():
total = 0
for i in range(10_000_000):
total += i * i
return total
start = time.perf_counter()
cpu_bound_task()
cpu_bound_task()
print(f"Sequential: {time.perf_counter() - start:.2f}s")
Sequential: 1.85s
Aunque ejecutes dos cpu_bound_task() con threading.Thread o asyncio.gather, el tiempo total no disminuye, porque el GIL permite que solo un thread ejecute código Python a la vez. Esta es la razón por la que la primera pregunta de abajo es la más importante.
Nota: Si tu problema es CPU-bound (cálculos matemáticos pesados, procesamiento de imágenes, encriptación…), ni
threadingniasyncioayudan. Necesitasmultiprocessingo escribir la parte pesada en una extensión C/Rust (NumPy, Cython).
3. Pregunta 1: ¿Tu trabajo es I/O-bound o CPU-bound?
Esta es la pregunta decisiva sobre si deberías usar concurrencia tipo “espera” (I/O-bound).
3.1. I/O-bound: tanto threading como asyncio son eficientes
import time
import requests
from concurrent.futures import ThreadPoolExecutor
URLS = ["https://httpbin.org/delay/1"] * 5
def fetch(url: str) -> int:
return requests.get(url).status_code
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(fetch, URLS))
print(f"Threading: {time.perf_counter() - start:.2f}s, results={results}")
Threading: 1.12s, results=[200, 200, 200, 200, 200]
ThreadPoolExecutor ejecuta 5 solicitudes simultáneamente con 5 threads del SO. Como requests.get es una llamada bloqueante que espera la red, el GIL se libera cuando el thread espera I/O en el nivel C — así que 5 solicitudes realmente se ejecutan en paralelo, demorando ~1s en lugar de 5s.
import asyncio
import aiohttp
import time
URLS = ["https://httpbin.org/delay/1"] * 5
async def fetch(session: aiohttp.ClientSession, url: str) -> int:
async with session.get(url) as resp:
return resp.status
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url) for url in URLS]
return await asyncio.gather(*tasks)
start = time.perf_counter()
results = asyncio.run(main())
print(f"Asyncio: {time.perf_counter() - start:.2f}s, results={results}")
Asyncio: 1.08s, results=[200, 200, 200, 200, 200]
El resultado es equivalente a threading, pero el mecanismo es distinto: solo un thread único, el event loop activamente awaits y cede el control cuando espera I/O, sin necesidad de que el SO cree nuevos threads.
3.2. CPU-bound: ambos pierden contra multiprocessing
from concurrent.futures import ProcessPoolExecutor
def cpu_bound_task():
return sum(i * i for i in range(10_000_000))
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=2) as pool:
results = list(pool.map(cpu_bound_task, [None, None]))
print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")
Multiprocessing: 1.02s
ProcessPoolExecutor crea procesos separados, cada uno con su propio GIL, así que puede aprovechar múltiples núcleos de CPU reales. Si estás dudando entre “threading o asyncio” para problemas CPU-bound, la respuesta correcta es ambos están mal — usa multiprocessing.
4. Pregunta 2: ¿Tu ecosistema de librerías tiene soporte async?
Esta es la pregunta más práctica cuando trabajas en un codebase real, donde no escribes todo desde cero.
import psycopg2 # driver síncrono, bloqueante
def get_user(user_id: int) -> dict:
conn = psycopg2.connect("dbname=app")
cur = conn.cursor()
cur.execute("SELECT id, name FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
conn.close()
return {"id": row[0], "name": row[1]}
psycopg2 es una librería bloqueante — no tiene versión await. Para ejecutar múltiples consultas en paralelo con este driver, threading (o run_in_executor de asyncio) es la opción natural, porque no necesitas reescribir nada.
import asyncpg # driver async-native
async def get_user(conn: asyncpg.Connection, user_id: int) -> dict:
row = await conn.fetchrow(
"SELECT id, name FROM users WHERE id = $1", user_id
)
return {"id": row["id"], "name": row["name"]}
Si tu ecosistema ya tiene drivers async-native (asyncpg, aiohttp, motor para MongoDB…), asyncio maximiza sus ventajas: miles de conexiones simultáneas en un solo thread, sin el costo de crear threads del SO.
Nota: Mezclar código bloqueante en
async defsinawaitcorrectamente (como llamarrequests.getdirectamente en una corrutina) bloqueará todo el event loop — todas las demás tareas deben esperar, incluso las no relacionadas. Este es el error más común al cambiar de threading a asyncio.
5. Pregunta 3: ¿Cuántas tareas simultáneas necesitas?
El costo de recursos entre un thread y una corrutina es muy diferente cuando aumenta el número de tareas.
import threading
import resource
def noop():
threading.Event().wait(2)
threads = [threading.Thread(target=noop) for _ in range(2000)]
for t in threads:
t.start()
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(f"Peak RSS con 2000 threads: {peak_kb / 1024:.1f} MB")
for t in threads:
t.join()
Peak RSS con 2000 threads: 1850.3 MB
Cada thread del SO consume de forma predeterminada cientos de KB a varios MB de stack, así que 2000 threads fácilmente consumen casi 2 GB de RAM y crean una presión grande en el planificador del SO por context-switching.
import asyncio
import resource
async def noop():
await asyncio.sleep(2)
async def main():
tasks = [asyncio.create_task(noop()) for _ in range(2000)]
await asyncio.gather(*tasks)
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
print(f"Peak RSS con 2000 corrutinas: {peak_kb / 1024:.1f} MB")
asyncio.run(main())
Peak RSS con 2000 corrutinas: 95.7 MB
Una corrutina es un objeto Python puro, no necesita un thread del SO separado — el costo de crear 2000 tareas es solo decenas de MB. Si necesitas manejar miles de conexiones simultáneas (servidor web, servidor de chat, crawler a gran escala), asyncio es prácticamente obligatorio; threading se quedará sin recursos antes de quedarse sin ancho de banda.
6. Tabla de decisión rápida
| Pregunta | Respuesta → Opción |
|---|---|
| 1. ¿I/O-bound o CPU-bound? | CPU-bound → multiprocessing. I/O-bound → ir a pregunta 2 |
| 2. ¿Tiene tu librería async-native? | No (driver bloqueante antiguo) → threading. Sí (aiohttp, asyncpg…) → asyncio |
| 3. ¿Cuántas tareas simultáneas necesitas? | Decenas → threading es suficientemente simple. Cientos/miles → asyncio |
En la práctica, muchos sistemas usan ambos: asyncio para la capa de red I/O-bound con gran cantidad de tareas, combinado con run_in_executor ejecutando código bloqueante (como librerías antiguas) en un thread pool separado sin bloquear el event loop.
import asyncio
def legacy_blocking_call(x: int) -> int:
import time
time.sleep(1) # simula una librería bloqueante antigua
return x * 2
async def main():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, legacy_blocking_call, 21)
print(result)
asyncio.run(main())
42
run_in_executor envía la función bloqueante a un thread en el thread pool predeterminado, mientras el event loop sigue libre para procesar otras corrutinas mientras espera.
7. Prueba el comportamiento con pytest
Ejemplo de test que confirma que asyncio.gather ejecuta tareas en paralelo, no secuencialmente:
import asyncio
import time
import pytest
async def sleepy_task(seconds: float) -> float:
await asyncio.sleep(seconds)
return seconds
@pytest.mark.asyncio
async def test_gather_runs_concurrently():
start = time.perf_counter()
results = await asyncio.gather(
sleepy_task(0.5), sleepy_task(0.5), sleepy_task(0.5)
)
elapsed = time.perf_counter() - start
assert results == [0.5, 0.5, 0.5]
# Secuencial sería ~1.5s, en paralelo es solo ~0.5s
assert elapsed < 0.7
Dependencias necesarias para la prueba:
pytest
pytest-asyncio
Esta prueba afirma la verdadera naturaleza de asyncio: tres tareas sleep(0.5) ejecutadas simultáneamente en un thread toman solo ~0.5s en total, no 1.5s — si la aserción elapsed < 0.7 falla, significa que tu código está haciendo await secuencialmente en lugar de usar gather.
8. Errores comunes
- Usar
time.sleep()en una corrutina: debes usarawait asyncio.sleep().time.sleep()es una llamada bloqueante, congela todo el event loop. - Olvidar
await: llamarfetch(session, url)sinawaitsolo crea un objeto corrutina, no ejecuta nada — Python mostrará unRuntimeWarning: coroutine was never awaited. - Compartir estado entre threads sin lock:
threadingtiene race conditions reales (múltiples threads escribiendo la misma variable), necesitathreading.Lock.asyncioen un solo thread no tiene este tipo de race condition, pero aún puede tener bugs lógicos si hacesawaiten medio de código que parecía “atómico”. - Usar
ThreadPoolExecutorpara problemas CPU-bound y decepcionarse porque no es más rápido — porque el GIL sigue bloqueando, usaProcessPoolExecutor.
Nota: Si tu equipo ya está acostumbrado a
threadingy el codebase es todo librerías bloqueantes, no apresures la refactorización aasynciosolo porque sea “más nuevo”. El costo de reescribir drivers completos, manejar excepciones de forma diferente, y debuggear stack traces de corrutinas más complejos es mucho mayor que la ganancia si el número de tareas simultáneas no es grande.
9. Conclusión
Elegir entre asyncio y threading no es cuestión de “cuál es más rápido” sino de tres preguntas específicas: ¿es tu problema I/O-bound o CPU-bound, tu ecosistema de librerías tiene async-native o no, y qué escala de concurrencia necesitas manejar? CPU-bound siempre necesita multiprocessing. I/O-bound con librerías antiguas y pocas tareas simultáneas, entonces threading es simple e integrable. I/O-bound a gran escala con librerías async-native, entonces asyncio es la opción correcta en rendimiento y recursos.