Ir al contenido
wannacode

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

El programa descarga tres informes mensuales del servidor. Aquí la descarga es una pausa: 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.
reports.js
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");
Salida
Descargué marzo.pdf
Descargué abril.pdf
Descargué mayo.pdf
Listo
Las pausas son de 0,6, 0,2 y 0,4 segundos, 1,2 segundos en total. Mientras espera, Node no está bloqueado: verás que puede hacer otras cosas. Pero cada await 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ó");
Salida
el bucle terminó
el temporizador

Para 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");
Salida
El programa no espera
Descargué abril.pdf
El informe está descargado
  • La llamada sin await inicia la descarga y vuelve enseguida, por eso «El programa no espera» se imprime primero.
  • await worker espera a que la promesa se cumpla. Es como join() 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");
Salida
1. una función que Node llama después (callback)
2. then en una promesa
3. await
Un callback es una función que pasas para que Node la llame más tarde. then 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

Ahora iniciamos las tres descargas sin esperar, y esperamos las tres promesas juntas con Promise.all:
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}`);
Salida
Descargué abril.pdf
Descargué mayo.pdf
Descargué marzo.pdf
Listo en menos de un segundo: true

Las 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}`);
Respuesta

La idea

La vida de una promesa

Una promesa está en uno de tres estados. No se puede preguntar en cuál desde el código, pero 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}`);
}
Salida
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 una signal a sleep o a fetch, y abort() rechaza la promesa con AbortError.
  • Promise.allSettled espera todas las promesas, aunque alguna sea rechazada, y Promise.race te da la primera que esté lista.

Problema

Las visitas perdidas

El sitio cuenta sus visitas. El contador está en un 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:
visits.js
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:

Salida (distinta cada vez)
Visitas: 1091916
Visitas: 1063068
Visitas: 1072106
Visitas: 1119773
Visitas: 1080911

Cada 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. leer
value = value + 1; // 2. sumar 1
workerData[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
  1. counts[0] vale 5. El Worker A y el Worker B llaman a increment() al mismo tiempo.
  2. A lee count: 5.
  3. B lee count antes de que A haya escrito: también 5.
  4. A suma 1 en su propia copia y escribe 6.
  5. B también escribe 6. Dos llamadas, y count vale 6, no 7: una de las sumas se ha perdido.
  6. Otra vez desde el principio, con Atomics.add: A lee 5 y escribe 6 en un solo paso indivisible.
  7. B llega a Atomics.add y espera hasta que termine el paso de A.
  8. 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

La solución es que los tres pasos se conviertan en uno que nadie pueda interrumpir. Eso hace 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);
}
}
Salida
Visitas: 2000000

Atomics.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, y put cumple 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;
Salida
La cocina recibió pizza
La cocina recibió ensalada
La 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

Dos personas sacan 80 € cada una de una misma cuenta con 100 € en el mismo instante. El banco confirma cada retiro en 0,1 s. El programa va en un solo hilo, y aun así las dos reciben el dinero y el saldo queda en -60 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Grupo de tareas y Promise

Mil descargas a la vez sobrecargan el servidor. Un grupo (pool) limita cuántas tareas van al mismo tiempo: en cuanto una termina, arranca la siguiente. Node no tiene un grupo ya hecho para promesas, pero son unas pocas líneas: 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`));
Salida
marzo.pdf: 900 KB
abril.pdf: 900 KB
mayo.pdf: 800 KB
junio.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 await lo espera.
  • runLimited devuelve 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
Si la tarea no devuelve nada, solo esperas a 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.");
Salida
Descargué informe-1.pdf
Descargué informe-2.pdf
Descargué informe-3.pdf
Descargué informe-4.pdf
Descargué informe-5.pdf
Todos terminaron.
Los dos primeros informes van al mismo tiempo, pero en un solo hilo: sus temporizadores vencen en el orden en que se iniciaron, así que el orden es el mismo en cada ejecución.

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}`);
Respuesta

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:

Salida
Partes: [12375000, 12375000, 12375000, 12375000]
Total: 49500000

2. 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):

Salida
marzo.pdf: 900 KB
abril.pdf: 900 KB
mayo.pdf: 800 KB
junio.pdf: 900 KB
julio.pdf: 900 KB
agosto.pdf: 1000 KB
Total: 5400 KB
Menos de 1,5 segundos: true

3. 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.

Salida
Visitas: 200000

Má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 await entre ellos, y comprueba que el saldo se queda en 20 € también con cinco cajeros.

Test

Ponte a prueba

1. ¿Qué hace const report = download("abril.pdf", 200); sin await?
2. Tres descargas están en un bucle for, cada una con await. ¿Cuánto tardan?
3. Dos Workers hacen cada uno workerData[0]++ 1 000 000 de veces sobre un SharedArrayBuffer compartido. ¿Cuánto valdrá al final?
4. ¿Por qué el código JavaScript en un solo hilo no necesita cerrojos para sus variables normales?
5. Inicias dos tareas y esperas primero con await big y luego con await small. La pequeña termina primero. ¿Qué se imprime primero?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·