Ir al contenido
wannacode

Por qué hilos

En resumen: En JavaScript el trabajo que espera (red, disco) no necesita hilos, basta con Promise.all; el trabajo que calcula necesita hilos de verdad, Workers, y más núcleos.

Problema

El cliente espera tres veces

El cliente pulsa «Pedir». Antes de ver «Gracias», el servidor tiene que consultar tres servicios por la red: el pago (si el banco acepta la tarjeta), el almacén (si el artículo está disponible) y el correo (si salió la confirmación). Cada uno responde en unos 200 ms.

Aquí la red es Network. call espera 200 ms y devuelve la respuesta del servicio. El programa consulta los tres uno tras otro:

const net = new Network();
const payment = await net.call("pago", "aceptado");
const stock = await net.call("almacén", "disponible");
const email = await net.call("correo", "enviado");
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Rondas de espera: ${net.rounds}`);
Salida
pago: aceptado
almacén: disponible
correo: enviado
Rondas de espera: 3

Cada pregunta empieza cuando termina la anterior. El cliente espera la suma: unos 600 ms. Y todo el tiempo el procesador no hace nada: solo espera una respuesta.

Network también cuenta las rondas de espera: una ronda nueva empieza cuando el programa pregunta y no se espera ninguna otra respuesta. Aquí hay tres, una por pregunta. En esta lección la velocidad se mide con este número y no en milisegundos: el tiempo depende de la máquina, mientras que el número de rondas es el mismo en cada ejecución.

Más precisamente, para curiosos

Así funciona Network. sleep es setTimeout de node:timers/promises, como en la lección «Async, await e hilos». Hay un solo hilo, así que los contadores no necesitan protección.

class Network {
inFlight = 0;
rounds = 0;
// Consulta un servicio por la red: la respuesta llega después de ~200 ms.
async call(service, answer) {
if (this.inFlight === 0) this.rounds++; // nadie espera: una ronda nueva
this.inFlight++;
await sleep(200);
this.inFlight--;
return `${service}: ${answer}`;
}
}

La idea

Trabajo que espera y trabajo que calcula

El trabajo de un programa es de dos tipos.

  • Trabajo que espera (I/O-bound): una petición al banco, una consulta a la base de datos, leer un archivo, enviar un correo. El programa pregunta y se queda quieto hasta que llega la respuesta. Mientras tanto el procesador está libre.
  • Trabajo que calcula (CPU-bound): 1000 facturas en PDF, miniaturas de productos, un informe sobre un millón de pedidos. Aquí el procesador está ocupado todo el tiempo.
En JavaScript el trabajo que espera no necesita hilos en absoluto. Node deja la espera al sistema y sigue con otro código (la lección «Async, await e hilos»). El trabajo que calcula ocupa el único hilo. Necesita hilos de verdad, Workers, en núcleos distintos.

Así que la primera pregunta ante un programa lento es: «¿qué está esperando?». La respuesta decide qué ayuda.

Míralo

Los tres a la vez

Envías las tres preguntas sin esperar y esperas juntas las tres promesas con Promise.all (la lección «Async, await e hilos»). No hace falta ningún hilo:
const net = new Network();
const [payment, stock, email] = await Promise.all([
net.call("pago", "aceptado"),
net.call("almacén", "disponible"),
net.call("correo", "enviado"),
]);
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Rondas de espera: ${net.rounds}`);
Salida
pago: aceptado
almacén: disponible
correo: enviado
Rondas de espera: 1

const net = new Network();
const [payment, stock, email] = await Promise.all([
net.call("pago", "aceptado"),
net.call("almacén", "disponible"),
net.call("correo", "enviado"),
]);
console.log(payment);
console.log(stock);
console.log(email);
console.log(`Rondas de espera: ${net.rounds}`);
Paso a paso, en palabras
  1. Un pedido necesita tres respuestas por la red: el pago, la comprobación del almacén y el correo de confirmación. Cada una espera unos 200 ms.
  2. Una tras otra: el hilo pregunta por el pago y no hace nada hasta que llega la respuesta.
  3. Solo entonces pregunta por el almacén. Otros 200 ms de espera.
  4. Solo entonces pregunta por el correo. Otros 200 ms de espera.
  5. 3 rondas de espera: el cliente espera unos 600 ms, y el procesador está parado todo el tiempo.
  6. A la vez: las tres preguntas salen juntas, cada una en su propio hilo, y las tres esperas se superponen.
  7. El programa espera las respuestas. Cuando llega la primera, las otras dos también están listas.
  8. 1 ronda de espera: unos 200 ms, lo que tarda la respuesta más lenta. Las mismas respuestas, en el mismo orden, un tercio de la espera.

Las respuestas son las mismas y en el mismo orden. Hay una ronda de espera: las tres preguntas salen antes de que haya llegado ninguna respuesta. El cliente espera unos 200 ms, lo que tarda el servicio más lento, en vez de 600.

Los tres temporizadores se disparan en el orden en que se pusieron, y Node llama a las continuaciones una a una. Todo ocurre en un solo hilo.
Más precisamente, para curiosos
Cómo hace Node exactamente los malabares con las esperas y dónde está la trampa entre dos await lo cuenta la lección «async no es un hilo».

Pruébalo

El correo espera el pago

Un compañero cambia el pedido: el correo tiene que decir si está pagado y si hay existencias, así que sale solo después de que hayan respondido el pago y el almacén.

const net = new Network();
const [payment, stock] = await Promise.all([
net.call("pago", "aceptado"),
net.call("almacén", "disponible"),
]);
// el correo dice si está pagado y si hay existencias
console.log(payment);
console.log(stock);
console.log(await net.call("correo", "enviado"));
console.log(`Rondas de espera: ${net.rounds}`);
Respuesta

Pruébalo

Esperar la respuesta

¿Qué espera la promesa del pago?

const payment = net.call("pago", "aceptado");
console.log( payment);

Problema

Mil facturas a fin de mes

A fin de mes la tienda genera una factura en PDF para cada uno de 1000 pedidos. Aquí nada espera la red: el procesador calcula todo el tiempo. render representa el dibujo del PDF con un cálculo que solo carga el procesador y devuelve una suma de control:

// Genera la factura (aquí: un cálculo que solo carga el procesador).
function render(invoice) {
let h = invoice;
for (let i = 0; i < 2000; i++) {
h = (h * 31 + i) % 1_000_003;
}
return h;
}
// Las facturas desde from hasta to, inclusive.
function renderRange(from, to) {
let sum = 0;
for (let n = from; n <= to; n++) {
sum += render(n);
}
return sum;
}
const total = renderRange(1, 1000);
console.log("Facturas: 1000");
console.log(`Suma de control: ${total}`);
Salida
Facturas: 1000
Suma de control: 499409728

Un núcleo calcula todo mientras los demás están parados. Aquí no hay rondas de espera: nada espera.

Míralo

Cuatro Workers, cuatro núcleos

Las facturas se dividen en 4 partes de 250, y cada parte va a su propio Worker: un hilo de verdad en su propio núcleo. El Worker devuelve la suma de su parte con postMessage, y el hilo principal la recibe con once(worker, "message"):
if (isMainThread) {
const workers = [0, 1, 2, 3].map((p) => {
const from = p * 250 + 1;
const range = { from, to: from + 249 };
return new Worker(new URL(import.meta.url), { workerData: range });
});
const parts = await Promise.all(
workers.map(async (worker) => (await once(worker, "message"))[0]),
);
const total = parts.reduce((sum, part) => sum + part, 0);
console.log("Facturas: 1000");
console.log(`Suma de control: ${total}`);
} else {
parentPort.postMessage(renderRange(workerData.from, workerData.to));
}
Salida
Facturas: 1000
Suma de control: 499409728

La suma de control es la misma: dividir no cambia el resultado, solo quién lo calcula.

Cuánto más rápido es depende de la máquina. En mi portátil, con un cálculo 250 veces más pesado por factura (500 000 pasos en vez de 2000), un núcleo tardó unos 1,9 s y cuatro unos 0,5 s.

Son tiempos de ejemplo, no una promesa. Más partes que núcleos no ayudan: solo se turnan en los mismos núcleos.

Más precisamente, para curiosos
  • Cuántos hilos. Para el trabajo que calcula, tantos como núcleos: os.availableParallelism() te dice cuántos. Para el trabajo que espera, muchos más está bien: solo duermen.
  • No todo se divide. Si una décima parte del trabajo no se puede dividir (por ejemplo sumar las partes o escribir un archivo), ningún número de núcleos lo hace más de 10 veces más rápido. Es la ley de Amdahl.
  • Los Workers no comparten objetos normales: workerData y postMessage los copian. La memoria compartida existe solo en SharedArrayBuffer, y ahí empiezan los problemas de la lección siguiente.

La idea

Qué ayuda

Trabajo que espera Trabajo que calcula
Qué lo frena la respuesta de la red, del disco, de la base de datos el propio cálculo
Qué ayuda esperar varias cosas a la vez más núcleos
En JavaScript Promise.all, sin hilos hilos Worker
Con un núcleo ayuda no ayuda

Los hilos tienen un precio. Mientras cada uno trabaje con sus propios datos (su propio servicio, sus propias 250 facturas), todo va bien. En cuanto dos hilos tocan lo mismo (un contador compartido, las existencias del almacén), empiezan errores que no aparecen en cada ejecución. Esa es la lección siguiente: «Condición de carrera».

Ejercicio

La página del producto

Resuelve la tarea en tu editor.

La página del producto. Antes de mostrar la página, el servidor consulta tres servicios: el precio, las reseñas y la entrega. Cada uno responde en unos 200 ms y no depende de los demás. Usa Network de la primera tarjeta y consúltalos todos a la vez, de modo que haya una ronda de espera:

Salida
precio: 89 €
reseñas: 4,6 de 5
entrega: mañana
Rondas de espera: 1

Más tareas

  • Cinco servicios. Añade el almacén y un descuento. ¿Cuántas rondas hay si el descuento depende del precio?
  • Cuántos núcleos. Ejecuta las facturas con 1, 2, 4 y 8 partes y mide el tiempo en tu máquina. ¿Cuándo deja de ir más rápido? Compáralo con el número de núcleos de tu máquina. - La herramienta equivocada. Ejecuta las 1000 facturas con Promise.all y una función async en vez de Workers. ¿Va más rápido? ¿Por qué?

Test

Ponte a prueba

1. Un pedido espera tres servicios de 200 ms cada uno que no dependen entre sí. ¿Cuánto espera el cliente si los consultas todos a la vez?
2. ¿Cómo consulta JavaScript tres servicios a la vez?
3. Tienes que calcular 1000 facturas en Node con 4 núcleos. ¿Qué lo acelera?
4. ¿Por qué la lección cuenta rondas de espera y no milisegundos?

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