▷ Asyncio en Python desde cero — Concurrencia sin sufrir 2026

Asyncio en Python desde cero — Concurrencia sin sufrir

Si tu script Python pasa el tiempo esperando: peticiones HTTP, consultas a BBDD, lecturas de fichero remotas… te puede aprovechar asyncio para acelerarlo dramáticamente. La diferencia: hacer 1000 peticiones HTTP en serie tarda minutos. Hacerlas con asyncio tarda segundos.

asyncio tiene fama de complicado. Y lo es, si lo intentas aprender entero de golpe. Pero el 90% de los casos que vas a encontrar caben en cuatro patrones: async def, await, asyncio.run(), asyncio.gather(). Todo lo demás (loops, tasks, semáforos, queues) es para cosas más raras.

En esta entrada te enseño esos cuatro patrones con ejemplos copy-paste, cuándo te conviene asyncio (y cuándo NO), y los errores típicos que confunden cuando empiezas.

La idea en 30 segundos

Sin asyncio (síncrono):

import requests

def descargar(url):
    return requests.get(url).text

resultados = [descargar(u) for u in urls]   # uno tras otro

Si cada petición tarda 1 segundo y tienes 100 URLs, tardas 100 segundos. Estás esperando 99 segundos sin hacer nada útil.

Con asyncio:

import asyncio
import aiohttp

async def descargar(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as r:
            return await r.text()

async def main():
    tareas = [descargar(u) for u in urls]
    return await asyncio.gather(*tareas)

resultados = asyncio.run(main())   # 100 URLs en ~1-2 segundos

Misma cantidad de trabajo, pero las 100 peticiones se lanzan a la vez y el programa va atendiendo respuestas según vienen. Aprovechas el tiempo de espera.

Las cuatro piezas que necesitas entender

1. async def — define una corutina

async def saludar():
    print("Hola")

Una async def no es una función normal — es una corutina. Cuando la llamas, no se ejecuta: te devuelve un objeto que representa ejecución pendiente. Para que se ejecute, hace falta un event loop.

saludar()
# <coroutine object saludar>   ← no ejecuta nada

2. await — pausar hasta que algo termine

Dentro de una async def puedes usar await para esperar a otra corutina:

import asyncio

async def saludar(nombre):
    await asyncio.sleep(1)
    print(f"Hola, {nombre}")

async def main():
    await saludar("Ana")

await asyncio.sleep(1) significa: “duerme un segundo, pero suelta el control mientras esperas para que el event loop ejecute otras cosas”.

Tip-friki: await solo funciona dentro de funciones async def. Si lo usas fuera, SyntaxError. Esa es la “regla del color de las funciones”: una vez declaras async, todo lo de dentro tiene que ser async-aware.

3. asyncio.run() — arranca el event loop

El event loop es lo que ejecuta corutinas. La forma simple de arrancarlo:

asyncio.run(main())

Esto crea un loop, ejecuta main() hasta el final, y cierra. Una vez por programa. No anides asyncio.run() dentro de otro.

4. asyncio.gather() — ejecutar varias en paralelo

Aquí es donde está la magia:

async def main():
    resultados = await asyncio.gather(
        descargar("url1"),
        descargar("url2"),
        descargar("url3"),
    )
    # las 3 corutinas se ejecutan EN PARALELO

gather(*coros) ejecuta las corutinas a la vez y devuelve una lista con los resultados en el mismo orden. Si una falla, propaga la excepción.

Patrón canónico

El template que vas a copiar 90 veces:

import asyncio
import aiohttp

async def descargar(session, url):
    async with session.get(url) as r:
        return await r.text()

async def main():
    urls = [
        "https://example.com/1",
        "https://example.com/2",
        "https://example.com/3",
    ]
    async with aiohttp.ClientSession() as session:
        tareas = [descargar(session, u) for u in urls]
        resultados = await asyncio.gather(*tareas)
    return resultados

if __name__ == "__main__":
    datos = asyncio.run(main())
    print(f"Descargadas {len(datos)} respuestas")

💡 Consejo: una sola aiohttp.ClientSession para todas las peticiones. Crear una nueva por petición es ineficiente y pierdes connection pooling.

📥 Llévate el cheatsheet de Python (gratis)

PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.

Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.

Cuándo asyncio te ayuda (y cuándo NO)

asyncio brilla cuando tu programa espera mucho: red, disco, BBDD, websockets, llamadas a APIs.

asyncio no acelera CPU-bound: bucles numéricos, parseo intensivo, ML training. Para eso usa multiprocessing o librerías compiladas.

Escenario ¿Asyncio?
Descargar 1000 URLs (es el caso canónico)
Procesar millones de números ❌ No (CPU-bound, usa NumPy/multiprocessing)
Webserver con miles de conexiones ✓ Sí (FastAPI, aiohttp)
Llamar 50 APIs en paralelo ✓ Sí
Comprimir 100 vídeos ❌ No (CPU-bound)
Bot de Telegram ✓ Sí (espera mensajes)
Script que hace 1 cosa ❌ No vale la pena

💡 ¿Por qué Python es lento en CPU pero asyncio ayuda en I/O? Mira ¿Por qué Python es lento? — la diferencia entre I/O-bound y CPU-bound es clave.

Casos reales típicos

Pool de tareas con límite de concurrencia

A veces no quieres lanzar 10000 peticiones a la vez (te bloquean por DDoS). Limitas con un Semaphore:

import asyncio
import aiohttp

async def descargar(session, url, sem):
    async with sem:                   # solo N en paralelo
        async with session.get(url) as r:
            return await r.text()

async def main(urls, max_concurrentes=10):
    sem = asyncio.Semaphore(max_concurrentes)
    async with aiohttp.ClientSession() as session:
        tareas = [descargar(session, u, sem) for u in urls]
        return await asyncio.gather(*tareas)

resultados = asyncio.run(main(urls, max_concurrentes=20))

20 peticiones simultáneas, ni más. Cuando una termina, otra arranca.

Manejar errores sin romper todo

Por defecto, si una corutina lanza, gather cancela las demás. Para que no se cancelen y devolver excepciones como valores:

resultados = await asyncio.gather(
    *tareas,
    return_exceptions=True,
)
# resultados puede contener mezclados: respuestas y excepciones
exitosos = [r for r in resultados if not isinstance(r, Exception)]
fallidos = [r for r in resultados if isinstance(r, Exception)]

Timeout total

import asyncio

async def main():
    try:
        resultado = await asyncio.wait_for(operacion_lenta(), timeout=5.0)
    except asyncio.TimeoutError:
        print("Tardó más de 5 segundos")

asyncio.create_task() — fire and forget

Cuando quieres lanzar una corutina sin esperar inmediatamente:

async def main():
    tarea = asyncio.create_task(descargar("url1"))
    # ... haces otras cosas ...
    resultado = await tarea

create_task programa la corutina en el loop de inmediato. Útil cuando quieres iniciar trabajo en paralelo a lo que estás haciendo.

Asyncio en web scraping

Combinado con web scraping con BeautifulSoup, asyncio acelera dramáticamente cuando rascas muchas páginas:

import asyncio
import aiohttp
from bs4 import BeautifulSoup

async def scrape_titulo(session, url):
    async with session.get(url) as r:
        html = await r.text()
        soup = BeautifulSoup(html, "html.parser")
        return soup.title.string if soup.title else None

async def main(urls):
    async with aiohttp.ClientSession() as session:
        tareas = [scrape_titulo(session, u) for u in urls]
        return await asyncio.gather(*tareas)

titulos = asyncio.run(main(["https://example.com", "https://elpythonista.com"]))

Errores típicos al usar asyncio

# 1. Llamar una corutina sin await
async def saludar():
    print("Hola")

saludar()    # ❌ devuelve un coroutine object, NO ejecuta print

async def main():
    await saludar()    # ✓
asyncio.run(main())

# 2. await fuera de async def
def main():
    await asyncio.sleep(1)   # ❌ SyntaxError
async def main():
    await asyncio.sleep(1)   # ✓

# 3. Mezclar requests (síncrono) con asyncio
async def descargar(url):
    return requests.get(url).text   # ❌ bloquea el event loop entero
async def descargar(url):
    async with aiohttp.ClientSession() as s:
        async with s.get(url) as r:
            return await r.text()    # ✓ asíncrono

# 4. asyncio.run() anidado
async def main():
    asyncio.run(otra())   # ❌ "asyncio.run() cannot be called from a running event loop"

async def main():
    await otra()          # ✓ desde dentro de async, usa await

# 5. Crear muchísimas tareas sin límite
tareas = [descargar(u) for u in 100_000_urls]
await asyncio.gather(*tareas)
# ❌ abre 100k conexiones, te tiran o crashea. Usa Semaphore.

# 6. Confundir asyncio con threading o multiprocessing
# asyncio es UN solo thread con cooperación.
# threading son varios threads (limitados por GIL en CPU-bound).
# multiprocessing son varios procesos.
# Para I/O: asyncio. Para CPU: multiprocessing.

Resumen

Concepto Sintaxis
Definir corutina async def f(): ...
Esperar otra corutina await otra_corutina()
Arrancar event loop asyncio.run(main())
Ejecutar varias en paralelo await asyncio.gather(*tareas)
Limitar concurrencia asyncio.Semaphore(N)
Lanzar y seguir asyncio.create_task(coro)
Timeout asyncio.wait_for(coro, timeout=N)
Cuándo usarlo I/O-bound (red, disco, BBDD) — NO CPU-bound
Librería HTTP async aiohttp (no requests)

¿Te ha valido esto?

Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.

Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.

Tu siguiente paso

Si has llegado hasta aquí, ya tienes la base para escribir scripts que aprovechan los tiempos de espera en lugar de quedarse parados. La próxima vez que tu script haga 50 peticiones HTTP secuenciales, sabes que con 4 líneas de asyncio + gather lo aceleras x50.

Si quieres aprender Python desde la base hasta proyectos reales con APIs, scraping y concurrencia, en El Pythonista lo enseño paso a paso.

Un abrazo,
Oscar

¿Quieres aprender Python en orden, no a saltos?

Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 35+ lecciones, código revisado, ejercicios y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.

Ver el curso completo →

35+ lecciones · Proyecto real · Acceso de por vida · 14 días de garantía

Compartir

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Publicar un comentario