¿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 threading ni asyncio ayudan. Necesitas multiprocessing o 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 def sin await correctamente (como llamar requests.get directamente 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 usar await asyncio.sleep(). time.sleep() es una llamada bloqueante, congela todo el event loop.
  • Olvidar await: llamar fetch(session, url) sin await solo crea un objeto corrutina, no ejecuta nada — Python mostrará un RuntimeWarning: coroutine was never awaited.
  • Compartir estado entre threads sin lock: threading tiene race conditions reales (múltiples threads escribiendo la misma variable), necesita threading.Lock. asyncio en un solo thread no tiene este tipo de race condition, pero aún puede tener bugs lógicos si haces await en medio de código que parecía “atómico”.
  • Usar ThreadPoolExecutor para problemas CPU-bound y decepcionarse porque no es más rápido — porque el GIL sigue bloqueando, usa ProcessPoolExecutor.

Nota: Si tu equipo ya está acostumbrado a threading y el codebase es todo librerías bloqueantes, no apresures la refactorización a asyncio solo 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.

pythonasynciothreadingconcurrencyperformance