gantt title Secuencial vs concurrente (3 llamadas de ~300ms) dateFormat X axisFormat %L ms section Secuencial llamada A :a1, 0, 300 llamada B :a2, after a1, 300 llamada C :a3, after a2, 300 section Concurrente llamada A :b1, 0, 300 llamada B :b2, 0, 300 llamada C :b3, 0, 300
29 20. Necesitamos concurrencia de verdad
Tres runtimes, tres modelos distintos — el capítulo estrella
Tu endpoint necesita datos de dos APIs externas: en secuencia son 2×300ms = 600ms; en paralelo son ~300ms. Los tres lenguajes resuelven esto con modelos distintos — y entender las diferencias es de lo que más pagan en una entrevista y en producción.
29.1 El problema
GET /dashboard debe traer: tus tareas + las notificaciones del servicio externo + los contadores del reporte. Tres llamadas que no dependen entre sí — hacerlas una tras otra es pagar el tiempo tres veces cuando podrías pagarlo una. La concurrencia no es un lujo: es devolver el tiempo que el usuario no tenía por qué prestarte.
29.2 Cómo lo resuelve un equipo
Un equipo serio primero clasifica la espera: ¿el tiempo se va en I/O (esperar red, disco, BD) o en CPU (calcular)? Concurrencia resuelve I/O — mientras una llamada espera, la otra viaja. Para CPU necesitas paralelismo real (varios núcleos trabajando a la vez), que no todos los runtimes ofrecen igual. La regla de bolsillo: esperas juntas, cuentas separadas.
El CPU es el cerebro que ejecuta instrucciones; cada núcleo puede ejecutar una cosa a la vez (por eso importan la concurrencia y los procesos paralelos). “CPU-bound” = el límite es el cálculo, no la espera.
Luego decide el límite: “en paralelo” no es “infinitas a la vez” — mil llamadas concurrentes a la API de alguien son un ataque, no una optimización.
29.3 Conceptos nuevos
- TS Event loop +
Promise.all— concurrencia cooperativa - Py
asyncio+gather— el event loop de Python y sus reglas distintas - Go Goroutines +
WaitGroup/errgroup— concurrencia barata y paralelismo real - U El GIL y los threads: qué puede y qué no cada runtime en paralelo — la explicación honesta
- U Cuándo la concurrencia importa (I/O) y cuándo es ruido (CPU)
29.4 La explicación visual
900ms vs ~300ms — mismo trabajo, distinto orden. Las tres llamadas inician juntas; el total es el de la más lenta, no la suma.
29.5 Implementación
El mismo endpoint — traer tres cosas en paralelo — en los tres modelos:
app.get("/dashboard", auth, async (req, res) => {
// Promise.all: starts all three, resolves when ALL resolve
const [tasks, notifications, stats] = await Promise.all([
listTasks(req.user.id), // queries the DB
fetchNotifications(req.user.id), // external API (cap. 19 client)
getStats(req.user.id), // report query
]);
res.json({ tasks, notifications, stats });
// if one rejects → all rejects → 500 (or Promise.allSettled to degrade)
});@app.get("/dashboard")
async def dashboard(user=Depends(get_current_user)):
# asyncio.gather: the event loop interleaves the awaits
tasks, notifications, stats = await asyncio.gather(
list_tasks(user.id), # must be async to interleave!
fetch_notifications(user.id), # httpx.AsyncClient
get_stats(user.id),
)
return {"tasks": tasks, "notifications": notifications, "stats": stats}func dashboard(w http.ResponseWriter, r *http.Request) {
user := userFromCtx(r.Context())
g, ctx := errgroup.WithContext(r.Context())
var tasks []Task
var notes []Notification
var stats Stats
g.Go(func() error { var err error; tasks, err = listTasks(ctx, user.ID); return err })
g.Go(func() error { var err error; notes, err = fetchNotes(ctx, user.ID); return err })
g.Go(func() error { var err error; stats, err = getStats(ctx, user.ID); return err })
if err := g.Wait(); err != nil { // real parallelism — one goroutine per call
writeError(w, 502, `{"error":{"code":"UPSTREAM"}}`); return
}
writeJSON(w, 200, map[string]any{"tasks": tasks, "notifications": notes, "stats": stats})
}Optimiza mi endpoint GET /dashboard en [Express/FastAPI/net-http]: hoy
hace 3 llamadas independientes en secuencia (2 queries a Postgres + 1
API externa con el cliente con timeout del cap. 19). Requisitos:
ejecutarlas en paralelo con [Promise.all/asyncio.gather/errgroup], que
un fallo de UNA dependencia no tumbe las otras dos (degradación por
sección: notifications puede venir como null), timeout total de 5s, y
tests que midan que el tiempo total ≈ el de la llamada más lenta.
29.6 ¿Por qué cada stack lo hace así?
Lo único que necesitas llevarte: los tres lanzan las esperas juntas y recogen los resultados al final — Promise.all/gather/errgroup son la misma idea con distinta maquinaria debajo.
Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.
| Runtime | Modelo | ¿Paralelismo real en CPU? |
|---|---|---|
| Node | Un hilo + event loop: cuando algo espera I/O, el hilo atiende otra cosa | No — para CPU hay worker_threads |
| Python | asyncio: mismo event loop, pero solo funciones async cooperan |
No en threads (el GIL: un solo hilo ejecuta Python a la vez) — para CPU hay multiprocessing |
| Go | Goroutines: miles de hilos lógicos baratos repartidos en núcleos reales | Sí — es la diferencia de diseño de Go |
Dos matices honestos: (1) en Python, await sobre código no async (time.sleep, requests) bloquea el loop entero — por eso httpx y drivers async; (2) en Node/Python la concurrencia es cooperativa: nadie te quita el hilo hasta que sueltas en un await; en Go el scheduler lo reparte — por eso Go escala a CPU y los otros no.
| Alternativa | Qué es | Cuándo |
|---|---|---|
Promise.allSettled |
Espera a todas aunque algunas fallen | Degradación por sección (un widget caído no tumba el dashboard) |
Semáforo/errgroup.SetLimit |
Paralelo con tope (ej. 10 a la vez) | Lotes grandes — 500 items en paralelo = DoS a tu propia dependencia |
| Worker threads / multiprocessing | Núcleos reales para CPU | Resize de imágenes, hashing masivo — eso sí es paralelismo |
| Cola de trabajos | Ni siquiera concurrente en el request | El trabajo no cabe en la paciencia HTTP (cap. 21) |
29.7 Errores comunes
| Error | Por qué pasa | Fix |
|---|---|---|
await dentro de un for |
“Lo hago uno a uno y funciona” | Acumula promesas → Promise.all/gather |
| Bloquear el event loop | requests.get dentro de async def |
Librerías async (httpx, asyncpg) o el executor |
| Concurrencia sin límite | “Lanzo las 5.000 en paralelo” | Semáforo/SetLimit — tu dependencia también tiene rate limit |
| Compartir memoria sin cuidado | Dos goroutines escriben el mismo map | En Go: canales o mutex — go run -race lo detecta gratis |
| Pensar que “concurrencia = CPU más rápido” | Confundir espera con cómputo | I/O → concurrencia; CPU → paralelismo real u otro proceso |
29.8 Buenas prácticas
- Independencia primero: solo se paraleliza lo que no se necesita entre sí — si B usa el resultado de A, no es candidata.
- Define el comportamiento ante fallo parcial antes de elegir:
all(una falla, falla todo) vsallSettled/degradación (sección vacía, página viva). - Contexto propagado: cancelación y deadline del request viajan a las llamadas (el
ctxde Go, AbortSignal, el event loop) — si el cliente se fue, para qué seguir trabajando.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
- Mide el antes/después: “optimizamos” sin medir es superstición — log de duración por llamada (cap. 23).
Un log es el diario del programa: qué pasó, cuándo, con qué request. Estructurado = en JSON con campos (request_id, user_id), para filtrar por máquina y no con los ojos.
29.9 Ejercicio
- En el dashboard, ¿qué debería pasar si
fetchNotificationsfalla pero las tareas y stats salen bien? EligeallvsallSettledy defiéndelo. - Tienes que llamar a la API externa 200 veces (una por tarea). ¿Por qué
Promise.allde las 200 es una mala idea y qué pondrías en su lugar? - En Python, el handler usa
time.sleep(0.3)dentro de un endpointasync def. ¿Qué le pasa a los demás requests mientras duerme?
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
29.10 Mini reto
Medir la diferencia entre secuencial y concurrente con dos llamadas lentas — en tu lenguaje.
Ejercicio.
allSettled(o catch por llamada): el dashboard muestranotifications: nully la página vive — un widget caído no debe tumbar los otros dos.allsería correcto solo si la página no tiene sentido incompleta.- 200 concurrentes = 200 conexiones abiertas a la vez: la API externa te banea por rate limit y tu event loop se ahoga. La forma: un semáforo/
errgroup.SetLimit(10)o una cola con N workers — en paralelo con tope. - Todos esperan:
time.sleepno esawait— bloquea el único hilo del event loop y ningún otro request avanza esos 300ms. La versión async esawait asyncio.sleep(0.3)— el loop atiende a los demás mientras duerme. Es la trampa de asyncio.
Mini reto. Con asyncio.sleep/setTimeout/time.Sleep de ~300ms en dos llamadas: secuencial ≈ 600ms, concurrente ≈ 300ms. Si tu “concurrente” mide 600ms, el await está dentro de un loop o la llamada es bloqueante — los dos errores de arriba.
29.11 Vocabulario técnico del capítulo
Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.
Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.
Un string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
Una goroutine es el hilo ultraligero de Go: go f() lanza una función en paralelo gastando casi nada — se pueden tener millones. Es la superpotencia de Go: concurrencia como palabra del lenguaje.
Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.
El runtime es el motor que ejecuta tu código: Node.js es el runtime de JavaScript fuera del navegador; CPython es el de Python; Go compila a binario y el runtime va empaquetado dentro. Es “quien corre” lo que escribiste.
Una dependencia es código de terceros que tu proyecto usa: npm install, pip install, go get las traen. Cada una es deuda — ahora funciona, pero hay que mantenerla, actualizarla y confiar en ella.
Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.
La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.
Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.
Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.
La autenticación responde “¿quién eres?” — login, contraseña, token. Se confunde con autorización (“¿qué puedes hacer?”), que es la pregunta siguiente. Primero te identificas, luego te dejan o no pasar.
Un hash es una función de un solo sentido: contraseña →$2b\(10\)…`. No se puede revertir — por eso las contraseñas se hashean, no se cifran. bcrypt es lento a propósito: fuerza bruta cara para el atacante.