Async, await e hilos
En resumen: JavaScript ejecuta el código en un solo hilo con un bucle de eventos: async y await esperan sin detener el programa, y los hilos Worker calculan de verdad en paralelo.
Problema
Tres informes, uno tras otro
await sleep(ms) espera esos milisegundos. sleep es setTimeout de node:timers/promises, y async delante de la función permite usar await dentro de ella.import { setTimeout as sleep } from "node:timers/promises";
async function download(file, ms) { await sleep(ms); console.log(`Descargué ${file}`);}
await download("marzo.pdf", 600);await download("abril.pdf", 200);await download("mayo.pdf", 400);console.log("Listo");Descargué marzo.pdfDescargué abril.pdfDescargué mayo.pdfListoawait espera a que termine la descarga anterior antes de empezar la siguiente. Y los tres informes no dependen unos de otros, así que se podrían descargar perfectamente al mismo tiempo.La idea
Proceso e hilo
Cuando ejecutas el programa, el sistema operativo le da un proceso: su propia memoria, sus propios archivos abiertos. Dentro del proceso trabajan uno o más hilos (threads). Un hilo es una secuencia de instrucciones que se ejecutan una tras otra. Hasta ahora, cada uno de tus programas tenía un solo hilo: el que ejecuta el archivo de arriba abajo.
Los hilos de un mismo proceso comparten su memoria: ven los mismos objetos. Eso los hace prácticos (se pasan datos con facilidad) y peligrosos (pueden estorbarse entre sí, como verás en un momento).
- En paralelo significa de verdad al mismo tiempo, en distintos núcleos del procesador.
- Concurrente significa en un solo núcleo, turnándose tan rápido que parece al mismo tiempo.
Para quien programa, la diferencia rara vez importa: en los dos casos no sabes qué hilo continuará y cuándo.
JavaScript es distinto. Node ejecuta tu código en un solo hilo. Cuando el código espera (un temporizador, la red, un archivo), Node no se queda quieto: deja la espera al sistema y sigue con otro código. Cuando el resultado está listo, la función que lo espera entra en una cola. Esto es el bucle de eventos (event loop): concurrente, en un solo hilo.
El precio: mientras tu código calcula sin esperar, nada más puede arrancar. Aquí el temporizador es de 0 ms, pero sale solo después del bucle:
setTimeout(() => console.log("el temporizador"), 0);const end = Date.now() + 200;while (Date.now() < end) { // 0,2 s de cálculo, sin un solo await}console.log("el bucle terminó");el bucle terminóel temporizadorPara cálculos de verdad en paralelo, Node tiene hilos Worker (node:worker_threads): cada uno con su propia memoria. Los verás a mitad de la lección.
Más precisamente, para curiosos
- El navegador Chrome lanza un proceso aparte para cada pestaña. Por eso un sitio que se cuelga no detiene a los demás, y también por eso el navegador usa mucha memoria.
- Todo programa que hace varias cosas a la vez usa hilos: un juego reproduce el sonido en un hilo y ejecuta la lógica en otro; un servidor web atiende a cada cliente en su propio hilo.
- El precio: cada hilo tiene su propia pila, y la memoria para ella se reserva por separado. Miles de hilos cuestan mucho. Por eso los programas reales usan un grupo de hilos (la última idea de la lección).
Míralo
La primera promesa
download es una función async. Cuando la llamas sin await, arranca y enseguida devuelve una promesa (Promise): un objeto que dice «el resultado llegará». El programa sigue:
const worker = download("abril.pdf", 200);console.log("El programa no espera");await worker;console.log("El informe está descargado");El programa no esperaDescargué abril.pdfEl informe está descargado- La llamada sin
awaitinicia la descarga y vuelve enseguida, por eso «El programa no espera» se imprime primero. await workerespera a que la promesa se cumpla. Es comojoin()con los hilos en otros lenguajes.
Aquí no hay ningún hilo: la descarga es un temporizador, y Node llama al resto de download cuando el temporizador vence.
Más precisamente, para curiosos
await es la forma más nueva de decir «haz esto cuando esté listo». En código antiguo te encontrarás las otras dos:setTimeout(() => { console.log("1. una función que Node llama después (callback)");}, 0);await sleep(50);
sleep(50).then(() => console.log("2. then en una promesa"));await sleep(100);
await sleep(50);console.log("3. await");1. una función que Node llama después (callback)2. then en una promesa3. awaitthen engancha una función así a una promesa. await hace lo mismo, pero el código se lee de arriba abajo, por eso hoy se escribe así.Míralo
Tres a la vez
const start = Date.now();await Promise.all([ download("marzo.pdf", 600), download("abril.pdf", 200), download("mayo.pdf", 400),]);const ms = Date.now() - start;console.log(`Listo en menos de un segundo: ${ms < 1000}`);Descargué abril.pdfDescargué mayo.pdfDescargué marzo.pdfListo en menos de un segundo: trueLas descargas arrancan en el orden marzo, abril, mayo, pero terminan en el orden de las pausas: abril (0,2 s), mayo (0,4 s), marzo (0,6 s). La espera completa dura lo que la más larga, 0,6 segundos en lugar de 1,2.
Promise.all espera todas las promesas y te da un array con sus resultados. Hay un solo hilo: los temporizadores vencen en su orden, y Node llama a las continuaciones una por una.Pruébalo
await en un bucle
¿Qué imprimirá el programa?
const start = Date.now();for (const [file, ms] of [["marzo.pdf", 600], ["abril.pdf", 200]]) { await download(file, ms);}console.log(`Tardó más de 0,7 s: ${Date.now() - start > 700}`);await dentro del bucle espera a que termine cada descarga antes de iniciar la siguiente. Por eso marzo (0,6 s) va primero, y todo tarda 0,8 s. Es un error frecuente: el programa funciona, solo que nada va al mismo tiempo. Para que vayan al mismo tiempo, las inicias todas y esperas con Promise.all.La idea
La vida de una promesa
inspect de node:util lo muestra, y también console.log:const report = sleep(500, "mayo.pdf");console.log(inspect(report));await report;console.log(inspect(report));
const broken = Promise.reject(new Error("sin conexión"));try { await broken;} catch (error) { console.log(`Error: ${error.message}`);}Promise { <pending> }Promise { 'mayo.pdf' }Error: sin conexión| Estado | Cuándo |
|---|---|
pending (pendiente) |
el trabajo todavía no ha terminado |
fulfilled (cumplida) |
hay un resultado: await lo devuelve |
rejected (rechazada) |
hay un error: await lo lanza, y try/catch lo captura |
Una promesa cambia de estado una sola vez. Después, await sobre ella devuelve el mismo resultado enseguida.
Más precisamente, para curiosos
- Un temporizador o una petición se cancelan con
AbortController: pasas unasignalasleepo afetch, yabort()rechaza la promesa conAbortError. Promise.allSettledespera todas las promesas, aunque alguna sea rechazada, yPromise.racete da la primera que esté lista.
Problema
Las visitas perdidas
SharedArrayBuffer: memoria que pueden ver varios Workers. Un Int32Array lee y escribe números enteros en ella. Dos Workers atienden a los visitantes, y cada uno aumenta el contador 1 000 000 de veces. new Worker(new URL(import.meta.url)) ejecuta el mismo archivo en un hilo nuevo, y allí isMainThread es false:import { Worker, isMainThread, workerData } from "node:worker_threads";import { once } from "node:events";
if (isMainThread) { const visits = new Int32Array(new SharedArrayBuffer(4)); const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits })); await Promise.all(pages.map((page) => once(page, "exit"))); console.log(`Visitas: ${visits[0]}`);} else { for (let i = 0; i < 1_000_000; i++) { workerData[0]++; }}Esperas 2 000 000. Cinco ejecuciones:
Visitas: 1091916Visitas: 1063068Visitas: 1072106Visitas: 1119773Visitas: 1080911Cada vez menos, y cada vez distinto. La razón es que workerData[0]++ no es un solo paso, sino tres:
let value = workerData[0]; // 1. leervalue = value + 1; // 2. sumar 1workerData[0] = value; // 3. escribir// counts es un Int32Array en un SharedArrayBuffer: memoria compartida por los dos Workers.function increment(counts) { counts[0]++; // leer, sumar 1, escribir}
function safeIncrement(counts) { Atomics.add(counts, 0, 1);}Paso a paso, en palabras
- counts[0] vale 5. El Worker A y el Worker B llaman a increment() al mismo tiempo.
- A lee count: 5.
- B lee count antes de que A haya escrito: también 5.
- A suma 1 en su propia copia y escribe 6.
- B también escribe 6. Dos llamadas, y count vale 6, no 7: una de las sumas se ha perdido.
- Otra vez desde el principio, con Atomics.add: A lee 5 y escribe 6 en un solo paso indivisible.
- B llega a Atomics.add y espera hasta que termine el paso de A.
- B lee 6 y escribe 7: no se pierde nada.
Cuando los dos hilos leen el mismo valor antes de que alguno haya escrito, una de las sumas se pierde. Esto se llama condición de carrera (race condition). Que ocurra o no depende de cuándo el sistema operativo cambia de hilo, por eso el resultado es distinto en cada ejecución.
La idea
Atomics: los tres pasos de una vez
Atomics.add (una suma atómica, indivisible):import { Worker, isMainThread, workerData } from "node:worker_threads";import { once } from "node:events";
if (isMainThread) { const visits = new Int32Array(new SharedArrayBuffer(4)); const pages = [1, 2].map(() => new Worker(new URL(import.meta.url), { workerData: visits })); await Promise.all(pages.map((page) => once(page, "exit"))); console.log(`Visitas: ${visits[0]}`);} else { for (let i = 0; i < 1_000_000; i++) { Atomics.add(workerData, 0, 1); }}Visitas: 2000000Atomics.add(array, índice, cantidad) lee, suma y escribe como un solo paso. Los dos Workers no pueden partirlo, así que el resultado es 2 000 000 en cada ejecución. Atomics.load lee un valor de la misma forma segura. No hay cerrojos como en Java: los objetos y arrays normales no se comparten entre Workers, se copian.
Más precisamente, para curiosos
- Productor y consumidor en un solo hilo no necesita cerrojo: la cocina espera un pedido con
await, yputcumple su promesa:
class OrderBox { #orders = []; #waiting = [];
put(order) { const kitchen = this.#waiting.shift(); if (kitchen) { kitchen(order); } else { this.#orders.push(order); } }
take() { if (this.#orders.length > 0) { return Promise.resolve(this.#orders.shift()); } return new Promise((resolve) => this.#waiting.push(resolve)); }}
const box = new OrderBox();async function kitchen() { for (let i = 0; i < 3; i++) { console.log(`La cocina recibió ${await box.take()}`); }}const cook = kitchen();box.put("pizza");box.put("ensalada");box.put("sopa");await cook;La cocina recibió pizzaLa cocina recibió ensaladaLa cocina recibió sopa- No hay interbloqueo (deadlock) entre cerrojos en un solo hilo, porque no hay cerrojos. Los Workers pueden esperarse entre sí con
Atomics.wait, y ahí sí es posible, pero rara vez hace falta. - Los Workers no tienen prioridades.
Pruébalo
Dos cajeros automáticos
Elige la línea con el error.
Hay un await entre la comprobación y el retiro. Mientras el primer cajero espera al banco, el segundo pasa la comprobación: el saldo todavía es 100. No hay hilos, pero el error es el mismo que con ellos: la comprobación y el cambio no son un todo. La corrección es retirar primero y esperar después:
class Account { balance = 100;
async withdraw(amount) { if (this.balance >= amount) { this.balance -= amount; await sleep(100); // el banco confirma } }}Entonces el segundo cajero ve 20 € y rechaza el retiro. El saldo se queda en 20 €. En un solo hilo, otro código puede intervenir solo en un await.
La idea
Grupo de tareas y Promise
runLimited lanza limit «carriles», y cada uno toma la siguiente tarea en cuanto termina la suya:async function sizeOf(file, ms) { await sleep(ms); return file.length * 100;}async function runLimited(limit, jobs) { const results = []; let next = 0; async function lane() { while (next < jobs.length) { const i = next++; results[i] = await jobs[i](); } } await Promise.all(Array.from({ length: limit }, lane)); return results;}const files = ["marzo.pdf", "abril.pdf", "mayo.pdf", "junio.pdf"];const sizes = await runLimited(2, files.map((file) => () => sizeOf(file, 300)));files.forEach((file, i) => console.log(`${file}: ${sizes[i]} KB`));marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KB- La tarea es una función que devuelve una promesa:
() => sizeOf(file, 300). Así arranca solo cuando un carril la toma. - Una promesa es el Future de JavaScript: el resultado llegará, y
awaitlo espera. runLimiteddevuelve un array con los resultados en el orden de las tareas, no en el orden en que terminan.
Para cálculos pesados, el grupo se hace con Workers. Node tampoco trae uno ya hecho, pero sí lo traen librerías como Piscina.
Más precisamente, para curiosos
runLimited:const reports = [1, 2, 3, 4, 5].map((i) => `informe-${i}.pdf`);await runLimited(2, reports.map((file) => () => download(file, 100)));console.log("Todos terminaron.");Descargué informe-1.pdfDescargué informe-2.pdfDescargué informe-3.pdfDescargué informe-4.pdfDescargué informe-5.pdfTodos terminaron.Pruébalo
¿Qué resultado sale primero?
«archivo.zip» tarda 0,6 s en descargarse, y «nota.txt», 0,1 s. ¿Qué imprimirá el programa?
const big = sizeOf("archivo.zip", 600);const small = sizeOf("nota.txt", 100);console.log(`Grande: ${await big}`);console.log(`Pequeño: ${await small}`);El archivo pequeño de verdad está listo primero. Pero await big es la primera línea, y espera al grande. Cuando este llega, await small devuelve enseguida, porque el resultado ya está ahí. El orden de la salida es el orden de los await, así que es el mismo en cada ejecución.
Ejercicio
Programas con async y Worker
Resuélvelos en tu editor.
1. Suma en paralelo. El array sales tiene 1 000 000 de ventas, sales[i] = i % 100, en un Int32Array sobre un SharedArrayBuffer. Divídelo en 4 partes iguales y suma cada una en su propio Worker. En workerData, pásale a cada uno el array, el número de su parte y el Float64Array compartido partial. Cada Worker escribe en su propio lugar de partial, así que no necesitas Atomics. Espéralos con once(worker, "exit") y suma las partes:
Partes: [12375000, 12375000, 12375000, 12375000]Total: 495000002. Descarga con un grupo. Descarga seis informes (marzo.pdf, abril.pdf, mayo.pdf, junio.pdf, julio.pdf, agosto.pdf) con runLimited, 3 a la vez. Usa sizeOf(file, 300) de la tarjeta del grupo. Imprime los tamaños en el orden de los archivos, el total, y si todo tardó menos de 1,5 segundos (uno tras otro serían 1,8 s):
marzo.pdf: 900 KBabril.pdf: 900 KBmayo.pdf: 800 KBjunio.pdf: 900 KBjulio.pdf: 900 KBagosto.pdf: 1000 KBTotal: 5400 KBMenos de 1,5 segundos: true3. Un contador sin pérdidas. Cuatro Workers, cada uno con 50 000 visitas, aumentan un contador compartido en un SharedArrayBuffer. Escríbelo de forma que el resultado sea siempre 200 000. Pruébalo primero con ++ y mira cuánto se pierde (se ve mejor con 1 000 000 por Worker), luego corrígelo con Atomics.add.
Visitas: 200000Más tareas
- Una prueba para el contador. Escribe una prueba de node:test que lance 8 Workers con 100 000 aumentos cada uno y compruebe 800 000 con
assert.equal. Ejecútala varias veces contra el contador con++. Un test que falla solo a veces es la señal típica de una condición de carrera. - Los cajeros automáticos. En el programa de los dos cajeros, pon la comprobación y el retiro en una función aparte sin
awaitentre ellos, y comprueba que el saldo se queda en 20 € también con cinco cajeros.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Arrays: push, splice y sort · Clases anidadas y datos · Funciones flecha · Tests con node:test
- Continúa en: Programación en red · Quicksort y el mal pivote · La pila de llamadas por dentro · De la recursión al bucle
- Ejercicios sobre el tema: Programas en el mundo real