StackPractices
intermediate Por Mathias Paulenko

Patrones Async con Promises, Futures y Coroutines

Escribe código concurrente eficiente con async/await, promises, futures y coroutines en JavaScript, Python y Java para I/O no bloqueante.

Visión general

El código síncrono bloquea el hilo de ejecución hasta que una operación termina. Cuando esa operación es I/O — consultar una base de datos, llamar a una API, leer un archivo — el hilo queda inactivo, desperdiciando ciclos de CPU que podrían atender otras peticiones. La programación async lo resuelve suspendiendo la tarea actual al encontrar I/O, dejando que el runtime ejecute otro trabajo, y reanudándola cuando el I/O completa. Así, un solo hilo puede mantener miles de conexiones concurrentes.

La parte difícil no es escribir async y await — es entender el event loop debajo, evitar el callback hell, propagar errores a través de los puntos de suspensión y acotar la concurrencia para no agotar recursos compartidos. Cada runtime lo hace distinto: JavaScript usa un event loop con promises, Python tiene coroutines con asyncio, Java tiene CompletableFuture sobre pools de hilos. Abajo están los patrones, anti-patrones e implementaciones que funcionan en los tres.

Cuándo usarlo

Usa esta receta cuando:

  • Construyes APIs que atienden cientos de peticiones concurrentes por proceso
  • Obtienes datos de varios servicios independientes que pueden llamarse en paralelo
  • Procesas cargas I/O-bound como web scraping, subida de archivos o colas de mensajes
  • Implementas capacidades en tiempo real como WebSockets, chat o dashboards en vivo
  • Reemplazas un modelo thread-per-request por una arquitectura event-driven para reducir servidores

Solución

Async/Await con peticiones concurrentes (JavaScript / Node.js)

async function fetchUserDashboard(userId) {
  const [profile, orders, recommendations] = await Promise.all([
    getProfile(userId),
    getOrders(userId),
    getRecommendations(userId),
  ]);
  return { profile, orders, recommendations };
}

async function fetchDashboardResilient(userId) {
  const [profile, orders, recommendations] = await Promise.allSettled([
    getProfile(userId),
    getOrders(userId),
    getRecommendations(userId),
  ]);

  return {
    profile: profile.status === 'fulfilled' ? profile.value : null,
    orders: orders.status === 'fulfilled' ? orders.value : [],
    recommendations: recommendations.status === 'fulfilled' ? recommendations.value : [],
  };
}

Python asyncio con Task Groups

import asyncio
import aiohttp

async def fetch_url(session: aiohttp.ClientSession, url: str) -> dict:
    async with session.get(url) as response:
        return await response.json()

async def fetch_all_urls(urls: list[str]) -> list[dict]:
    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:
            tasks = [tg.create_task(fetch_url(session, url)) for url in urls]
        return [task.result() for task in tasks]

async def fetch_with_limit(urls: list[str], max_concurrent: int = 10):
    semaphore = asyncio.Semaphore(max_concurrent)

    async def bounded_fetch(session, url):
        async with semaphore:
            return await fetch_url(session, url)

    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(
            *[bounded_fetch(session, url) for url in urls]
        )

urls = ["https://api.example.com/users/1", "https://api.example.com/users/2"]
results = asyncio.run(fetch_all_urls(urls))

Pipeline con CompletableFuture en Java

import java.util.concurrent.CompletableFuture;

public class AsyncOrderService {
    public CompletableFuture<Order> processOrder(String orderId) {
        return validateOrder(orderId)
            .thenCompose(this::checkInventory)
            .thenCompose(this::processPayment)
            .thenCompose(this::createShipment)
            .exceptionally(ex -> {
                log.error("Order processing failed", ex);
                return Order.failed(orderId, ex.getMessage());
            });
    }

    private CompletableFuture<ValidatedOrder> validateOrder(String orderId) {
        return CompletableFuture.supplyAsync(() -> new ValidatedOrder(orderId));
    }

    public CompletableFuture<Dashboard> loadDashboard(String userId) {
        CompletableFuture<Profile> profileFuture = fetchProfile(userId);
        CompletableFuture<List<Order>> ordersFuture = fetchOrders(userId);
        return profileFuture.thenCombine(ordersFuture, Dashboard::new);
    }
}

Explicación

  • Event loop: el mecanismo detrás del async de JavaScript y de asyncio en Python. Mantiene una cola de tareas listas y las ejecuta de una en una. Cuando una tarea llega a un await, cede el control; el loop toma la siguiente tarea lista; cuando el I/O esperado termina, la tarea suspendida vuelve a la cola. Un solo hilo, sin coste de cambio de contexto por tarea.
  • Concurrencia estructurada: en Python 3.11+, asyncio.TaskGroup cancela todas las tareas hermanas si una hija falla, así nunca quedan tareas huérfanas en segundo plano tras un error del padre. JavaScript no tiene un equivalente incorporado — Promise.all falla rápido pero no cancela el resto, así que AbortController es la vía manual.
  • Composición de promises: Promise.all() espera a todas y rechaza ante el primer fallo; Promise.allSettled() espera a todas y reporta cada resultado; Promise.race() devuelve la primera en resolverse. Los equivalentes en asyncio son gather, TaskGroup y wait con FIRST_COMPLETED.
  • Backpressure con semáforos: la concurrencia sin límites agota memoria, file descriptors y cuotas del servicio externo. Un semáforo acota las operaciones simultáneas — con límite de 10, la undécima petición espera un slot libre en lugar de competir por sockets.
Mermaid flowchart TD diagram

Variantes

PatrónLenguajeModelo de concurrenciaManejo de erroresMejor para
async/awaitJS/PythonEvent looptry/catchAPIs I/O-bound
CompletableFutureJavaPool de hilosexceptionally()CPU + I/O mixto
GoroutinesGoHilos M:NChannelsServicios de alto throughput
RxJS/RxPYJS/PythonObservablesonErrorStreams de eventos
HilosTodosHilos del SOtry/catchTareas CPU-bound

La diferencia real es entre concurrencia de event loop (un hilo, scheduling cooperativo) y concurrencia de pool de hilos (apropiativa, planificada por el SO). Las goroutines quedan en el medio: el runtime de Go mapea goroutines sobre unos pocos hilos del SO, por eso Go escala distinto que Node.js con el mismo número de conexiones.

Lo que funciona

  • Siempre espera tus promises: una promise sin await es fire-and-forget — si rechaza y nada la espera, Node.js emite unhandledRejection, que crashea el proceso desde Node 15. En funciones async, haz await o .catch() a cada promise que crees.
  • Promise.all para trabajo independiente, awaits secuenciales para dependencias: si la tarea B necesita el resultado de A, deben correr en orden. Las llamadas independientes desperdician tiempo si las serializas — Promise.all, asyncio.gather o CompletableFuture.allOf las corren en concurrente.
  • Timeout a cada llamada externa: una API que nunca responde cuelga la tarea para siempre. Envuelve las llamadas externas en un timeout con reintentos para que los fallos queden acotados y las latencias sean predecibles.
  • Concurrencia estructurada antes que fire-and-forget: una tarea lanzada que sobrevive a su padre fuga memoria y compite en el apagado. Usa TaskGroup, ámbitos con Promise.allSettled o tokens de cancelación explícitos para que cada tarea tenga un padre que la posea.
  • Perfila el event loop en vez de adivinar: en Node.js usa perf_hooks.monitorEventLoopDelay() o clinic.js; en Python corre asyncio con PYTHONASYNCIODEBUG=1. Si el trabajo CPU bloquea el loop, muévelo a un worker thread o a un pool de procesos.

Errores comunes

  • Bloquear el event loop: un fs.readFileSync o un loop CPU-pesado dentro de una función async detiene todas las demás peticiones del proceso. Usa fs.promises.readFile y descarga el trabajo CPU a worker threads.
  • Callback hell sin async/await: cadenas .then() muy anidadas son difíciles de leer y depurar. El JavaScript moderno debería usar async/await para todo menos el one-liner — código plano que se lee síncrono pero corre async.
  • Condiciones de carrera sobre estado mutable compartido: dos tareas incrementando un contador a través de boundaries de await compiten igual — await es un punto de suspensión, no un lock. Usa operaciones atómicas, locks o paso de mensajes en vez de mutación compartida.
  • Ignorar el backpressure: aceptar trabajo más rápido de lo que puedes drenarlo hace crecer la memoria hasta que el OOM killer interviene. Un 503 rápido es mejor que un servidor caído.

Cuándo no usar este enfoque

  • Tareas CPU-bound: async no ayuda cuando el cuello de botella es la CPU; usa worker threads o pools de procesos.
  • Scripts secuenciales simples: una llamada HTTP, esperar, salir — un requests.get() directo se lee más claro que el equivalente asyncio.
  • Deadlines de tiempo real duros: los schedulers async son cooperativos y no deterministas; mantenlos fuera del camino crítico de sistemas sensibles a deadlines.
  • Embebidos con memoria muy limitada: cada tarea pendiente retiene un closure y una máquina de estado; miles de tareas suspendidas se acumulan.
  • Codebases legacy síncronos: convertir a async implica tocar cada llamada de I/O de la cadena; el beneficio rara vez justifica la refactorización.
  • Jobs batch de una sola petición: un job nocturno que llama a un endpoint y escribe una fila no gana nada con async.

Benchmarks de rendimiento

Tómalos como órdenes de magnitud, no como garantías — mide en tu propio stack y con tus tamaños de payload:

  • Capacidad del event loop: un proceso Node.js en una VM chica (2 cores / 4 GB) puede mantener del orden de 10k conexiones keep-alive; el límite lo ponen los file descriptors y la memoria por conexión, no la CPU.
  • asyncio vs hilos: para fan-out HTTP I/O-bound, asyncio con aiohttp suele sostener varias veces el throughput de un stack WSGI con hilos en un solo core, porque no hay stack por hilo ni contención del GIL mientras se espera.
  • Coste de goroutines: las goroutines arrancan con stacks de ~2 KB que crecen bajo demanda, así que cientos de miles caben en unos pocos cientos de MB.
  • Coste de spawn: crear una tarea tokio cuesta decenas de nanosegundos; un hilo del SO cuesta microsegundos más una reserva de stack de escala MB.
  • Virtual threads de Java: los virtual threads de JDK 21 viven en el heap, no en stacks del SO — un millón de ellos es realista donde un millón de platform threads agotaría el espacio de direcciones.
  • Coste de cambio de contexto: un cambio de hilo del SO toma ~1–10 µs; un cambio de tarea async es una llamada a función, del orden de ~100–500 ns.
  • Latencia bajo carga: los sistemas de event loop mantienen percentiles de cola más ajustados bajo carga de I/O porque no hay espera en cola del pool de hilos; la saturación de CPU es donde el modelo se cae.
  • Presión de GC: cada tarea pendiente es una máquina de estado alojada en el heap. Una rotación alta de tareas se traduce en carga de GC — conviene vigilarlo en JVM y V8.

Estrategia de testing

  • Unit test de funciones async en aislamiento: mockea el boundary de I/O y verifica valores de retorno; usa pytest.mark.asyncio (o anyio) en Python y el soporte async de tu runner en JavaScript.
  • Integration test contra I/O real: levanta un servidor HTTP local y una base de datos; los bugs end-to-end se esconden justo donde terminan los mocks.
  • Stress test con concurrencia realista: lanza 1.000+ tareas concurrentes y verifica que no haya deadlocks, fugas ni resultados incorrectos — las condiciones de carrera solo aparecen bajo presión de intercalación.
  • Test de comportamiento de timeouts: verifica que las operaciones lentas disparen de verdad los timeouts de asyncio.wait_for o los wrappers de deadline basados en Promise.race, y que el I/O subyacente se cancela en lugar de quedar abandonado.
  • Test de propagación de cancelación: cancela una tarea padre y verifica que todas las hijas se cancelen; en asyncio, la cancelación llega como CancelledError en el siguiente await.
  • Test de propagación de errores: los fallos de las hijas deben llegar al padre — TaskGroup los expone como ExceptionGroup, Promise.all rechaza ante el primer fallo.
  • Detección de carreras: corre los tests de Python con el modo debug de asyncio (PYTHONASYNCIODEBUG=1) para detectar recursos sin cerrar y callbacks lentos; usa ThreadSanitizer donde haya hilos reales.
  • Load test con payloads de tamaño productivo: los payloads de juguete esconden problemas de backpressure y GC.
  • Test de backpressure: envía peticiones más rápido de lo que el servicio las drena y verifica que descarte carga (503, colas acotadas) en lugar de comerse la memoria.
  • Chaos testing del camino async: mata tareas a mitad de un await, inyecta delay de red, corta conexiones — los handlers de cleanup y los bloques finally son donde viven los bugs async.

Estimación de costos

  • Dimensionado de servidores: las cargas async necesitan menos máquinas y más chicas — un servidor Node.js async manteniendo ~10k conexiones corre bien en una instancia de 2 cores / 4 GB, que cuesta en torno a $20–40/mes en los clouds habituales.
  • Pools de conexiones: los drivers async habituales (aiohttp, asyncpg, undici) son open source; el coste es operativo, no de licencias.
  • Coste de desarrollo: el código async suele llevar un 20–30 % más de tiempo en escribirse y depurarse que el equivalente síncrono — las rutas de error a través de puntos de suspensión son lo caro.
  • Overhead de monitoreo: necesitas métricas de lag del event loop, profundidad de la cola de tareas y tasa de rejections que el monitoreo genérico de host no cubre.
  • Ahorro de infraestructura: pasar de thread-per-request a async suele recortar la capacidad necesaria 3–5x en servicios I/O-bound.
  • Memoria: una tarea async cuesta KBs; un hilo del SO cuesta un stack de escala MB. Con 10k unidades de trabajo concurrentes es la diferencia entre decenas de MB y decenas de GB.
  • Coste operativo: menos partes móviles — sin tuning de pools de hilos ni debugging de contención de locks — a cambio de una clase distinta de bugs (tareas colgadas, promises sin await).

Monitoreo y observabilidad

  • Lag del event loop: el delay entre un callback planificado y su ejecución; un lag sostenido por encima de ~50 ms indica que algo bloquea. Mídelo con perf_hooks.monitorEventLoopDelay() en Node.js o clinic.js para perfiles.
  • Profundidad de la cola de tareas: tareas pendientes creciendo de forma monótona significa que los productores superan a los consumidores — es un problema de backpressure, no de escalado.
  • Conexiones activas: registra el conteo de conexiones concurrentes contra ulimit -n.
  • Rejections y excepciones sin manejar: cuenta los eventos unhandledRejection en Node.js y las líneas “Task exception was never retrieved” en asyncio.
  • Pausas de GC: una rotación alta de tareas aloca muchos objetos pequeños; observa las pausas de GC junto con el crecimiento del heap.
  • Memoria: RSS y heap — el crecimiento lento bajo carga constante es la firma de una tarea o un closure fugado.
  • Percentiles de latencia: p50/p95/p99 por endpoint; un p99 que se abre con p50 plano suele significar que unas pocas rutas de código bloquean el loop.

Checklist de despliegue

  • Límites de file descriptors configurados: ulimit -n 65536 o LimitNOFILE=65536 en la unit de systemd
  • Pools de conexiones dimensionados para la concurrencia esperada — en async bastan pools chicos (aprox. 2× cores), no 50+
  • Timeouts en todo el I/O: clientes HTTP, queries de base de datos, lecturas de caché; una banda sensata es 5–30 s
  • Logging estructurado con request IDs para que las cadenas de llamadas async sigan siendo trazables
  • Health checks que prueben que el event loop responde, no solo que el proceso vive
  • Límites de memoria y comportamiento del OOM killer configurados — las tareas async son baratas, las colas sin límite no
  • Apagado graceful: dejar de aceptar trabajo, drenar las tareas en vuelo durante 5–10 s y salir

Consideraciones de seguridad

  • Inundación de tareas: un atacante que pueda disparar creación de tareas genera trabajo ilimitado; acota la concurrencia y aplica rate limiting en los puntos de entrada.
  • Inyección de callbacks: si el input del usuario elige qué callback corre, elige qué código corre. Nunca dispaches sobre strings crudos del usuario.
  • DoS por rejections sin manejar: en Node.js 15+ un rejection de promise sin manejar crashea el proceso. Adjunta .catch() en cada promise o un handler unhandledRejection a nivel de proceso.
  • Bloqueo del event loop: una llamada síncrona (fs.readFileSync, time.sleep, crypto.pbkdf2Sync) detiene todas las peticiones del proceso. Audita las dependencias en busca de I/O síncrono.
  • Estado compartido a través de awaits: los boundaries de await permiten intercalación; un read-modify-write repartido a través de un await compite.
  • Timeouts que no cancelan: si el I/O subyacente ignora la cancelación, la tarea hace timeout pero la operación sigue consumiendo recursos — usa transports que respeten la cancelación.
  • Fugas de memoria por closures: cada tarea captura su scope; tareas long-lived que retienen objetos grandes impiden el GC.
  • Riesgo de supply chain: los drivers async (aiohttp, tokio, Netty) han tenido CVEs; fija versiones y vigila los advisories.
  • DoS por clientes lentos: clientes estilo slowloris mantienen conexiones abiertas — configura timeouts de lectura y límites de conexiones por IP.
  • Deserialización insegura: await response.json() sobre un payload sin límite carga todo el body en memoria antes de validar; impone límites de tamaño primero.
  • Suplantación de coroutines: en Python, cualquier objeto que implemente __await__ puede recibir await — no hagas await de objetos construidos desde input no confiable.
  • Agotamiento de file descriptors: cada conexión cuesta un FD; sin límites, una inundación de conexiones mata el proceso.
  • Fuga de información en mensajes de error: los stack traces async son profundos y pueden exponer paths internos, query strings o credenciales — sanitiza lo que cruza el cable.
  • Verificación TLS desactivada: algunos clientes HTTP traen defaults de TLS permisivos — verifica que la comprobación de certificados esté realmente activa.
  • ReDoS en el event loop: una regex patológica corre en el hilo del loop y lo detiene todo; saca el matching del loop o usa re2 (node-re2) para matching en tiempo lineal.
  • Cancelación vs trabajo no idempotente: cancelar una tarea a mitad de operación (p. ej., un cobro) puede cobrar dos veces si la operación completó antes de que la cancelación llegara; haz los handlers idempotentes.
  • Disciplina de async with: saltarte async with en sesiones y conexiones fuga recursos de forma invisible.
  • Bypass de backpressure: productor rápido + consumidor lento + cola sin límite = agotamiento de memoria.
  • Manipulación de prioridad de tareas: un atacante que controle el timing de las tareas puede dejar sin recursos a las críticas.
  • Timing attacks en autenticación: compara secretos con secrets.compare_digest (Python) o crypto.timingSafeEqual (Node.js), nunca con ==, para que el scheduling no filtre prefijos de bytes.
  • Prototype pollution de Promise: si Promise.prototype puede modificarse, todo lo de abajo queda comprometido; haz Object.freeze en entornos con plugins hostiles.
  • Bypass de middleware async: un eslabón de middleware sin await puede saltarse — siempre await next().
  • Check-then-act en rate limiting: leer y actualizar un contador en awaits separados deja pasar peticiones concurrentes; haz la comprobación atómica.
  • El apagado forzado salta el cleanup: SIGKILL nunca ejecuta los handlers de cleanup async; confía en SIGTERM más una ventana de drenaje.
  • Inanición de pools compartidos: consumidores que comparten un pool de conexiones sin cuotas permiten que un pico deje a los demás sin recursos.
  • Logging síncrono en el loop: un logger bloqueante dentro de un handler async detiene el loop; usa logging buffered o async.
  • Locks retenidos durante la cancelación: una coroutine cancelada mientras retiene un lock puede no liberarlo nunca; adquiere locks con async with y timeout.
  • Inundación de microtasks: cadenas recursivas de Promise.resolve().then() monopolizan la cola de microtasks y dejan sin recursos a los macrotasks.

Solución de problemas

  • Carreras que solo aparecen bajo carga: protege el estado compartido con locks, atómicas o paso de mensajes; reproduce con stress tests dirigidos, no con unit tests.
  • Deadlock entre workers: establece un orden global de adquisición de locks y mantén las secciones críticas libres de await.
  • Saturación del pool de hilos (código CompletableFuture/executor): observa la longitud de la cola y la política de rechazo; agranda el pool solo si CPU y memoria lo permiten.
  • Colas o mailboxes de actores creciendo sin límite: aplica backpressure, colas acotadas y load shedding aguas arriba.
  • Tarea async que nunca termina: busca un await olvidado, un rejection no manejado tragado aguas arriba o un loop que nunca cede al scheduler.

Lectura adicional

Notas de producción

  • Despliega gradualmente con canary o blue-green — los bugs del event loop solo aparecen bajo concurrencia real.
  • Configura alertas de lag del event loop, tasa de rejections no manejadas y latencia p99 antes del rollout, no después.
  • Mantén un rollback documentado en el runbook; volver de async a síncrono es la dirección dolorosa.
  • Propaga correlation IDs de punta a punta — los saltos async de una petición son invisibles sin ellos.

Puntos clave

  • Async/await compra concurrencia en un solo hilo; es una herramienta de I/O, no de CPU.
  • Acota la concurrencia (semáforos, pools) y pon timeout a cada llamada externa — ambos son innegociables en producción.
  • Prefiere concurrencia estructurada (TaskGroup, combinadores de promises con scope) para que las tareas tengan un dueño en vez de quedar huérfanas.
  • Mide el event loop en sí — lag, profundidad de cola, rejections — porque sus modos de fallo no aparecen en los logs de peticiones.

Modos de fallo en producción

  • Desplegar un fan-out de Promise.all/gather sin límite que se derrite con tráfico real.
  • Hacer timeout a la tarea pero no al I/O subyacente, de modo que el recurso sigue fugando en segundo plano.
  • Hacer await a trabajo disparado por el usuario sin rate limiting — un vector de denegación de servicio gratis.
  • Retener un lock o una transacción a través de un await, que bloquea el lock durante toda la suspensión.
  • Tareas fire-and-forget que mueren en silencio en el apagado y dejan estado a medio escribir.
  • Asumir que async elimina las carreras: el estado compartido a través de un boundary de await sigue compitiendo.
  • Desplegar sin métricas del event loop — vuelas a ciegas sobre el único recurso que importa.
  • Vigilar el p50 mientras el p99 arde.

Preguntas frecuentes

¿Async es siempre más rápido que síncrono?

Solo para cargas I/O-bound. En tareas CPU-bound (procesamiento de imágenes, inferencia de ML), async no aporta nada porque la CPU ya está saturada — usa hilos, procesos o workers dedicados para el paralelismo de CPU.

¿Cuántas peticiones concurrentes puede manejar un solo proceso de Node.js?

Miles, acotadas por memoria y file descriptors, no por CPU. El event loop ejecuta una operación a la vez, pero la mayor parte del tiempo cada operación está esperando I/O. Un servidor Node.js típico mantiene 5.000–10.000 conexiones concurrentes.

¿Cuál es la diferencia entre concurrencia y paralelismo?

La concurrencia es intercalar tareas en un solo core (async/await). El paralelismo es ejecutar tareas simultáneamente en varios cores (hilos/procesos). Async te da concurrencia; multiprocessing te da paralelismo. Los sistemas de alto throughput suelen usar ambos.

¿Debería usar hilos o async en Python?

asyncio para trabajo I/O-bound con muchas conexiones, threading para I/O a través de librerías que bloquean y no soportan async, multiprocessing para trabajo CPU-bound que necesita evitar el GIL. Para servidores web y clientes de API, asyncio suele ser el default correcto.

¿Qué pasa si olvido hacer await a una promise?

La promise corre igual — await no la inicia, solo espera su resultado. Una promise sin await es fire-and-forget: si rechaza, el error cae en unhandledRejection, que crashea el proceso en Node.js 15+.

¿Cómo limito la concurrencia con asyncio?

Envuelve el trabajo en un asyncio.Semaphore(N) y adquiérelo dentro de cada tarea — combínalo con TaskGroup si además quieres propagación de fallos. No existe un gather(limit=N) incorporado.

¿Por qué mi código async sigue siendo lento?

Normalmente una de estas tres causas: una llamada síncrona corriendo en el loop (requests, time.sleep, una regex pesada), un fan-out sin límite que satura el servicio externo, o trabajo CPU-bound que necesita un pool de procesos en lugar de coroutines. Mide el lag del event loop primero.

Código companion: El repo companion de async-patterns tiene los tres ejemplos como archivos ejecutables, incluido un fetcher con semáforo que puedes apuntar a cualquier lista de URLs.