---
name: async-python
description: Patrones avanzados de async/await en Python. asyncio, aiohttp, SQLAlchemy async, concurrencia, semáforos, task groups, error handling en async, testing. Anti-patrones comunes de código asíncrono.
version: "1.0.0"
herramientasPermitidas: [Read]
exclusiones:
  - "No cargar para paralelismo CPU-bound con múltiples núcleos — `asyncio` es monohilo y no aprovecha múltiples CPUs; para paralelismo CPU-bound usar `multiprocessing` o `concurrent.futures.ProcessPoolExecutor`."
  - "No cargar para scripts Python de una sola ejecución sin I/O concurrente — si el script hace una sola operación I/O secuencial, `requests` síncrono es más simple y correcto que introducir asyncio."
  - "No cargar cuando el framework ya maneja la concurrencia (FastAPI, Django Channels) y la pregunta es sobre la lógica de endpoints — en ese contexto cargar `fastapi-experto` o `django-experto` que cubren el uso de async en su framework específico."
  - "No cargar para orquestación de agentes SWL en paralelo — ese patrón tiene semántica diferente (fire-and-forget, corrección mid-task, supervisión); cargar `orquestacion-async`."
evolvable: true  # default para skill estandar
---
# Async Python — Patrones Avanzados

## Cuándo NO cargar

- La tarea es CPU-bound (cálculos numéricos, procesamiento de imágenes) — asyncio no paralela CPU; usar `multiprocessing`.
- El script hace una sola operación I/O sin concurrencia — `requests` síncrono es más simple y adecuado.
- La pregunta es sobre endpoints en FastAPI o Django — cargar el skill del framework que ya incorpora el contexto async correcto.

asyncio es el núcleo de I/O asíncrono en Python. Este skill cubre desde patrones básicos
hasta orquestación avanzada de tareas concurrentes para sistemas de producción.

---

## Reglas Obligatorias

1. **NUNCA bloquear el event loop** con operaciones síncronas (`requests`, `time.sleep`, I/O de disco síncrono). Usar clientes async (`aiohttp`, `httpx`, `asyncio.sleep`).
2. **SIEMPRE usar `async with` para sesiones HTTP** — nunca dejar sesiones aiohttp sin cerrar (file descriptor leak).
3. **SIEMPRE usar `expire_on_commit=False`** en `async_sessionmaker` de SQLAlchemy — el default causa `MissingGreenlet` en async.
4. **SIEMPRE usar `selectinload`** para relaciones ORM accedidas fuera de la sesión activa.
5. **NUNCA llamar `asyncio.run()` dentro de una coroutine** — causa `RuntimeError: loop anidado`.
6. **SIEMPRE poner timeout en llamadas HTTP** — sin timeout puede colgar indefinidamente.
7. **Una sesión SQLAlchemy por request/task** — compartir sesiones entre tasks causa race conditions.

---

## Fundamentos: Event Loop y Coroutines

```python
# MAL: bloquear el event loop con operaciones síncronas
async def descargar_archivo_mal(url: str) -> bytes:
    import requests                      # ¡síncrono! bloquea el loop
    return requests.get(url).content

# BIEN: usar clientes async
import aiohttp

async def descargar_archivo(url: str) -> bytes:
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            response.raise_for_status()
            return await response.read()
```

---

## Semáforos — Control de concurrencia máxima

```python
# MAL: lanzar 1000 requests simultáneos sin límite
async def fetch_todo_mal(urls: list[str]) -> list[str]:
    tasks = [fetch_url(url) for url in urls]  # ¡sobrecarga el servidor!
    return await asyncio.gather(*tasks)

# BIEN: limitar concurrencia con Semaphore
async def fetch_url_limitado(
    session: aiohttp.ClientSession, url: str, sem: asyncio.Semaphore,
) -> str:
    async with sem:
        async with session.get(url) as resp:
            return await resp.text()

async def fetch_todo_con_limite(urls: list[str], max_concurrente: int = 10) -> list[str]:
    sem = asyncio.Semaphore(max_concurrente)
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url_limitado(session, url, sem) for url in urls]
        return await asyncio.gather(*tasks, return_exceptions=True)
```

---

## SQLAlchemy Async — Reglas críticas

```python
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import selectinload
from sqlalchemy import select

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@localhost/db",
    pool_size=20, max_overflow=0, pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(
    engine, expire_on_commit=False, class_=AsyncSession,  # CRÍTICO
)

# REGLA: selectinload SIEMPRE para relaciones accedidas fuera de la sesión
async def obtener_usuario_con_roles(db: AsyncSession, usuario_id: str) -> Usuario:
    stmt = (
        select(Usuario).where(Usuario.id == usuario_id)
        .options(selectinload(Usuario.roles), selectinload(Usuario.departamento))
    )
    result = await db.execute(stmt)
    return result.scalar_one_or_none()
```

---

## Timeouts y Cancelación

```python
# BIEN: timeout en múltiples niveles
async def llamar_api(url: str, timeout_seg: float = 5.0) -> dict:
    try:
        async with asyncio.timeout(timeout_seg):
            async with aiohttp.ClientSession() as s:
                async with s.get(url, timeout=aiohttp.ClientTimeout(total=timeout_seg)) as r:
                    r.raise_for_status()
                    return await r.json()
    except asyncio.TimeoutError:
        raise TimeoutError(f"La API no respondió en {timeout_seg}s: {url}")
```

---

## Anti-patrones Comunes

| Anti-patrón | Consecuencia | Corrección |
|-------------|-------------|------------|
| Llamar función síncrona bloqueante en coroutine | Bloquea event loop completo | `loop.run_in_executor(None, func)` |
| Crear engine SQLAlchemy dentro de coroutine | Conexiones huérfanas | Engine singleton a nivel de módulo |
| `await` dentro de list comprehension sin gather | Ejecución secuencial (lenta) | `asyncio.gather(*[coro(x) for x in xs])` |
| No cerrar sesiones aiohttp | File descriptor leak | Siempre `async with ClientSession()` |
| `asyncio.run()` dentro de coroutine | RuntimeError: loop anidado | Solo llamar en código síncrono top-level |
| Compartir objetos de sesión SQLAlchemy entre tasks | Race conditions | Una sesión por request/task |
| `expire_on_commit=True` (default) con async | Lazy-load fuera de sesión falla | `expire_on_commit=False` en async |

---

Para ejemplos completos de gather, TaskGroup, queues async, context managers, error handling con reintento, testing async y service HTTP con pool de conexiones, ver [recursos/patrones-y-ejemplos-completos.md](recursos/patrones-y-ejemplos-completos.md).

## Gotchas / Errores comunes no obvios

- **`asyncio.gather()` con `return_exceptions=False` (default) cancela silenciosamente tareas exitosas cuando una falla**: si una de las coroutines lanza excepción, `gather` cancela el resto y la excepción se propaga, pero las tareas ya en progreso pueden dejar estado parcial. Causa: `return_exceptions=False` propaga la primera excepción inmediatamente y cancela el `gather`, pero no espera a que las tasks canceladas hagan cleanup. Solución: usar `return_exceptions=True` para obtener todos los resultados/excepciones, o `asyncio.TaskGroup` (Python 3.11+) que garantiza que todas las tasks se completan antes de propagar errores.
- **`loop.run_in_executor(None, func_sincrona)` bloquea el event loop cuando se llama con alta frecuencia**: el executor por defecto es un `ThreadPoolExecutor` con un número limitado de workers (CPU count × 5). Causa: si se hacen 1000 llamadas simultáneas a `run_in_executor`, las threads se agotan y las nuevas tasks quedan en cola, bloqueando el event loop. Solución: crear un executor explícito con el número de workers apropiado: `executor = ThreadPoolExecutor(max_workers=50)` y pasarlo como primer argumento a `run_in_executor`.
- **`async with aiohttp.ClientSession()` creado dentro de la coroutine crea y destruye la sesión en cada llamada, anulando los beneficios del pooling**: la sesión aiohttp mantiene un pool de conexiones TCP — si se crea una sesión nueva por cada request, no hay pooling. Causa: la sesión es liviana de crear pero destruye las conexiones keepalive al hacer `await session.close()`. Solución: crear la sesión como singleton a nivel de módulo o en el lifespan de la app (`async with aiohttp.ClientSession() as session:` en el contexto de arranque, no en cada handler).
- **`await asyncio.sleep(0)` en un bucle no es suficiente para que otras coroutines corran si el bucle hace I/O bloqueante entre los sleeps**: el `sleep(0)` cede el control al event loop pero si la siguiente operación es síncrona/bloqueante, el loop queda bloqueado de nuevo. Causa: `asyncio.sleep(0)` es un yield point que permite al event loop ejecutar callbacks pendientes, pero si la operación siguiente es síncrona, el bloqueo reaparece inmediatamente. Solución: identificar la operación I/O bloqueante con `asyncio.run()` + `--profile` o usar `asyncio.iscoroutinefunction()` para verificar que las funciones llamadas son realmente async.
