La lección anterior repartió Escena Viva entre siete trabajadores y multiplicó por casi cinco la capacidad del servidor. Pero dejó un problema intacto y lo dijimos sin rodeos: cluster no desbloquea el bucle de eventos. Cuando un trabajador se pone a componer el PDF con el código QR de las 500 entradas del estreno del Festival de Jazz de Primavera (evt-003, Auditorio Ribera), ese proceso deja de atender a todo el mundo durante el tiempo que dure el cálculo.
En esta lección medimos ese bloqueo con el instrumental del módulo 2, entendemos por qué los hilos son la herramienta correcta para el trabajo de CPU (y cluster no lo es), y construimos en Escena Viva un PoolDeHilos propio, porque crear un hilo por petición es peor que no usar hilos.
Contenido
- El bloqueo, medido con
monitorEventLoopDelay - Cluster frente a worker threads: la regla de decisión
node:worker_threads: el mecanismo básico- Clonado estructurado: qué viaja entre hilos y qué cuesta
- Transferencia frente a copia:
ArrayBuffer,SharedArrayBufferyAtomics - El hilo de Escena Viva:
src/trabajadores/generar-entradas.js PoolDeHilos: por qué un hilo por petición es un error- Errores dentro del hilo y cómo se propagan
- Medición antes y después
- Cuándo NO usar hilos
- El bloqueo, medido con
monitorEventLoopDelay
monitorEventLoopDelayEn el módulo 3 dejamos src/utiles/qr-entrada.js con { codificarQr, decodificarQr }: firma el contenido con HMAC y lo codifica en base64url. Al generar las entradas de una sesión completa hay que hacerlo una vez por entrada, más la composición del PDF. Es trabajo síncrono y de CPU: no hay E/S que delegar, solo cálculo.
Vamos a medirlo antes de opinar. Este script simula la generación de las 500 entradas del estreno mientras un temporizador intenta latir cada 20 ms:
'use strict';
const { monitorEventLoopDelay } = require('node:perf_hooks');
const { generarLoteDeEntradas } = require('../src/servicios/entradas-pdf.js');
const histograma = monitorEventLoopDelay({ resolution: 10 });
histograma.enable();
// Latido regular: si el bucle esta libre, se ejecuta cada ~20 ms.
let latidos = 0;
const latido = setInterval(() => { latidos += 1; }, 20);
(async function medir() {
const inicio = process.hrtime.bigint();
await generarLoteDeEntradas({ sesionId: 'ses-003-1', eventoId: 'evt-003', cantidad: 500 });
const msTotales = Number(process.hrtime.bigint() - inicio) / 1e6;
clearInterval(latido);
histograma.disable();
console.log(`Generacion: ${msTotales.toFixed(0)} ms`);
console.log(`Latidos: ${latidos} (esperados ~${Math.round(msTotales / 20)})`);
console.log(`Retraso maximo: ${(histograma.max / 1e6).toFixed(2)} ms`);
})();Resultado en la máquina de referencia: Generacion: 4180 ms, Latidos: 3 (esperados ~209), Retraso maximo: 4176.44 ms.
Tres latidos donde debería haber 209. Durante 4,18 segundos el proceso no ejecutó absolutamente nada más: ni respondió peticiones, ni atendió la conexión a PostgreSQL, ni procesó el keep-alive. Con SCHED_RR, el primario le siguió mandando conexiones —no sabe que está ocupado— y esas conexiones se quedaron en la cola del sistema. Cuando el trabajador volvió, tenía un atasco esperándole y el percentil 99 de toda la aplicación se disparó. Con 7 trabajadores el daño es 1/7 del tráfico; pero si tres organizadores generan entradas a la vez (algo normal la noche del estreno), tres de siete trabajadores están congelados.
- Cluster frente a worker threads: la regla de decisión
| Aspecto | cluster (procesos) |
worker_threads (hilos) |
|---|---|---|
| Unidad | Proceso del sistema operativo | Hilo dentro del mismo proceso |
| Instancia de V8 | Una por proceso | Una por hilo (aisladas) |
| Bucle de eventos | Uno por proceso | Uno por hilo |
| Montículo | Independiente, sin compartir | Independiente, pero se puede compartir memoria con SharedArrayBuffer |
| Coste de arranque | ~40-80 ms (proceso Node completo) | ~10-25 ms |
| Coste de memoria | ~40-80 MB por proceso | ~5-15 MB por hilo |
| Comunicación | IPC serializado (JSON o avanzado) | postMessage con clonado estructurado, o memoria compartida |
| Comparten puerto | Sí (el primario reparte) | No (pero pueden compartir un socket con net, avanzado) |
| Un fallo mata... | Solo ese proceso | Solo ese hilo, pero un SharedArrayBuffer corrupto afecta a todos |
| Sirve para | Concurrencia de E/S: más peticiones a la vez | Trabajo de CPU: no bloquear el hilo principal |
La regla de decisión, que conviene memorizar: cluster para escalar la E/S, hilos para sacar la CPU del camino. No son alternativas: en Escena Viva usaremos las dos. Siete trabajadores de cluster, y dentro de cada uno un pool pequeño de hilos para el trabajo pesado. Y ojo con la aritmética: 7 trabajadores × 4 hilos = 28 hilos de CPU en una máquina de 8 núcleos es sobreventa. Volveremos a ello en el apartado 7.
node:worker_threads: el mecanismo básico
node:worker_threads: el mecanismo básicoEl módulo expone lo esencial en muy pocas piezas:
| Elemento | Dónde vive | Para qué |
|---|---|---|
new Worker(ruta, opciones) |
Hilo principal | Crea el hilo y ejecuta el fichero indicado |
workerData |
Hilo trabajador | Datos iniciales clonados en el arranque |
parentPort |
Hilo trabajador | Puerto de comunicación con quien lo creó |
isMainThread / threadId |
Ambos | En qué hilo estamos y con qué identificador |
MessageChannel / MessagePort |
Ambos | Canales adicionales entre hilos cualesquiera |
Un ejemplo mínimo, con los eventos que importan:
'use strict';
const path = require('node:path');
const { Worker } = require('node:worker_threads');
function ejecutarEnHilo(ruta, datos) {
return new Promise((resolver, rechazar) => {
const hilo = new Worker(path.join(__dirname, ruta), { workerData: datos });
hilo.on('message', (resultado) => resolver(resultado));
// 'error': excepcion no capturada dentro del hilo. 'online': el hilo
// ya ejecuta JavaScript (util para medir el coste de arranque).
hilo.on('error', (error) => rechazar(error));
hilo.on('exit', (codigo) => {
if (codigo !== 0) rechazar(new Error(`El hilo termino con codigo ${codigo}`));
});
});
}Y el fichero del hilo, donde workerData llega ya clonado (es una copia, no una referencia):
'use strict';
const { parentPort, workerData, threadId } = require('node:worker_threads');
const suma = workerData.numeros.reduce((acumulado, valor) => acumulado + valor, 0);
parentPort.postMessage({ hilo: threadId, suma });Dos matices importantes. Primero, postMessage no termina el hilo; el hilo vive hasta que se queda sin trabajo pendiente o alguien llama a terminate(), y puede seguir recibiendo tareas por parentPort.on('message', ...). Esto es precisamente lo que permite reutilizarlo en un pool. Segundo, cada hilo carga sus propios módulos: si generar-entradas.js hace require de Sequelize, ese require se ejecuta una vez por hilo, con su coste de arranque y de memoria.
- Clonado estructurado: qué viaja entre hilos y qué cuesta
Los mensajes entre hilos no comparten memoria por defecto: se copian con el algoritmo de clonado estructurado (el mismo de postMessage en el navegador y de structuredClone). Es más capaz que JSON.stringify, pero tiene límites estrictos.
| Tipo | ¿Viaja? | Nota |
|---|---|---|
Primitivos (null, undefined, BigInt incluidos) |
Sí | |
| Objetos y arrays planos | Sí | Incluidas referencias circulares |
Date, RegExp |
Sí | Se conservan como tales, no como cadenas |
Map, Set |
Sí | Ventaja clara sobre JSON |
Buffer, TypedArray, ArrayBuffer |
Sí | Copiado, salvo que se transfiera |
Error |
Sí | Se conservan message, name y stack |
| Funciones | No | DataCloneError |
| Clases (la identidad) | No | Llega el objeto plano, sin prototipo ni métodos |
Getters, símbolos, WeakMap |
No | Se pierden silenciosamente o fallan |
| Conexiones, sockets, handles de fs | No | No tienen sentido fuera de su hilo |
Consecuencia directa para Escena Viva: no puedes enviar una instancia de Entrada ni de Pedido y esperar que conserve sus métodos. Nuestro dominio (src/dominio/) usa clases; al cruzar la frontera del hilo hay que serializar a datos planos y reconstruir al otro lado, y por eso lo diseñaremos así: el hilo recibe datos primitivos y devuelve datos primitivos. El coste de copiar tampoco es cero: clonar 500 objetos de entrada es del orden de un milisegundo, pero clonar un PDF de 8 MB como Buffer es del orden de decenas. Si el resultado es grande, transfiérelo en lugar de copiarlo.
- Transferencia frente a copia:
ArrayBuffer, SharedArrayBuffer y Atomics
ArrayBuffer, SharedArrayBuffer y AtomicspostMessage acepta un segundo argumento con la lista de objetos transferibles: parentPort.postMessage({ datos: arrayBuffer }, [arrayBuffer]). Un ArrayBuffer transferido no se copia, cambia de dueño, y el hilo emisor se queda con un buffer desconectado (byteLength === 0). Es la forma correcta de devolver un PDF de varios megabytes, y la veremos aplicada en el apartado 6.
SharedArrayBuffer va un paso más allá: la misma memoria es visible desde varios hilos a la vez, sin copia ni transferencia. Es la herramienta para contadores compartidos, como el número de entradas generadas en el estreno:
'use strict';
// En el hilo principal: 2 enteros de 32 bits compartidos, que se envian
// a cada hilo en workerData SIN copiarse.
// contadores[0] = entradas generadas, contadores[1] = errores.
const contadores = new Int32Array(new SharedArrayBuffer(8));
// Atomics.add lee, suma y escribe sin que otro hilo pueda colarse en
// medio. Con contadores[0] += 1 perderiamos incrementos.
const registrarEntradaGenerada = (c) => Atomics.add(c, 0, 1);
// Atomics.load garantiza ver el valor mas reciente publicado por
// cualquier hilo, sin reordenaciones del compilador o la CPU.
const leerTotal = (c) => Atomics.load(c, 0);
module.exports = { contadores, registrarEntradaGenerada, leerTotal };Advertencia seria. Hasta ahora, en JavaScript de un solo hilo, las condiciones de carrera de memoria no existían: entre dos líneas de tu función nadie podía modificar tus variables. SharedArrayBuffer abre esa puerta. contadores[0] += 1 son tres operaciones (leer, sumar, escribir) y dos hilos pueden entrelazarlas y perder incrementos. Por eso existe Atomics (add, load, store, compareExchange, wait, notify). Úsalo siempre para tocar memoria compartida, y usa memoria compartida solo cuando de verdad haga falta: para datos estructurados, el clonado es más lento pero infinitamente más seguro.
- El hilo de Escena Viva:
src/trabajadores/generar-entradas.js
src/trabajadores/generar-entradas.jsEl hilo recibe los datos planos de un lote de entradas, genera para cada una su QR firmado reutilizando codificarQr del módulo 3, compone el PDF y devuelve el resultado transfiriendo el buffer.
'use strict';
const { parentPort } = require('node:worker_threads');
const { codificarQr } = require('../utiles/qr-entrada.js');
const { componerPdfDeEntradas } = require('../servicios/pdf.js');
// Genera el QR y el PDF de un lote de entradas. Trabajo puramente de CPU.
function generarLote({ pedidoId, eventoTitulo, salaNombre, fechaSesion, entradas }) {
const entradasConQr = entradas.map((entrada) => ({
codigo: entrada.codigo, // formato EV-2026-004871
butaca: entrada.butaca,
precioCentimos: entrada.precioCentimos,
// codificarQr firma con HMAC y devuelve base64url (modulo 3).
qr: codificarQr({ codigo: entrada.codigo, sesionId: entrada.sesionId }),
}));
const bufferPdf = componerPdfDeEntradas({
pedidoId, eventoTitulo, salaNombre, fechaSesion, entradas: entradasConQr,
});
return { bufferPdf, generadas: entradasConQr.length };
}
// El hilo permanece vivo escuchando tareas: asi lo reutiliza el pool.
parentPort.on('message', ({ idTarea, peticion }) => {
try {
const { bufferPdf, generadas } = generarLote(peticion);
const arrayBuffer = bufferPdf.buffer.slice(
bufferPdf.byteOffset, bufferPdf.byteOffset + bufferPdf.byteLength
);
parentPort.postMessage(
{ idTarea, ok: true, resultado: { pdf: arrayBuffer, generadas } },
[arrayBuffer] // transferencia sin copia
);
} catch (error) {
// Nunca dejamos que la excepcion mate el hilo: la devolvemos como dato.
parentPort.postMessage({
idTarea, ok: false,
error: { mensaje: error.message, nombre: error.name, pila: error.stack },
});
}
});Fíjate en la decisión de diseño: el hilo no lanza excepciones hacia arriba, las convierte en respuestas. Un throw sin capturar dispararía el evento error y mataría el hilo, obligando al pool a crear otro. Capturar dentro y responder con ok: false mantiene el hilo reutilizable.
PoolDeHilos: por qué un hilo por petición es un error
PoolDeHilos: por qué un hilo por petición es un errorMedimos el coste de arranque de un hilo que hace require de nuestro código: entre 18 y 40 ms. Si la tarea dura 4 000 ms, ese coste es ruido. Pero para lotes pequeños —una entrada suelta de Lucia para ses-001-1— la tarea dura 12 ms y el arranque cuesta el triple que el trabajo. Peor aún: 50 peticiones simultáneas crearían 50 hilos, es decir 50 instancias de V8 compitiendo por 8 núcleos, con cientos de megabytes de memoria y un colapso por cambio de contexto.
La solución es un pool: un número fijo y pequeño de hilos creados al arrancar, una cola de tareas, y reutilización.
'use strict';
const path = require('node:path');
const os = require('node:os');
const crypto = require('node:crypto');
const { Worker } = require('node:worker_threads');
const RUTA_HILO = path.join(__dirname, 'generar-entradas.js');
class PoolDeHilos {
// tamano pequeno por defecto: el trabajo de CPU no escala mas alla de
// los nucleos, y convivimos con los trabajadores de cluster (10-01).
constructor({ tamano, rutaHilo = RUTA_HILO, tiempoLimiteMs = 30_000 } = {}) {
this.tamano = tamano || Math.max(1, Math.floor(os.availableParallelism() / 2));
this.rutaHilo = rutaHilo;
this.tiempoLimiteMs = tiempoLimiteMs;
this.hilos = []; // todos los hilos vivos
this.libres = []; // hilos sin tarea asignada
this.cola = []; // tareas esperando hilo
this.pendientes = new Map(); // idTarea -> { resolver, rechazar, temporizador }
this.cerrado = false;
for (let i = 0; i < this.tamano; i += 1) this.crearHilo();
}
crearHilo() {
const hilo = new Worker(this.rutaHilo);
hilo.tareaActual = null;
hilo.on('message', (mensaje) => {
const pendiente = this.pendientes.get(mensaje.idTarea);
if (pendiente) {
clearTimeout(pendiente.temporizador);
this.pendientes.delete(mensaje.idTarea);
if (mensaje.ok) pendiente.resolver(mensaje.resultado);
else pendiente.rechazar(Object.assign(new Error(mensaje.error.mensaje), {
name: mensaje.error.nombre, stack: mensaje.error.pila,
}));
}
this.devolverHilo(hilo);
});
// El hilo murio por una excepcion no capturada: rechazamos su tarea
// en curso y lo sustituimos para mantener el tamano del pool.
hilo.on('error', (error) => {
this.rechazarTareaDe(hilo, error);
this.retirarHilo(hilo);
if (!this.cerrado) this.crearHilo();
});
hilo.on('exit', (codigo) => {
if (codigo !== 0 && !this.cerrado) { this.retirarHilo(hilo); this.crearHilo(); }
});
this.hilos.push(hilo);
this.libres.push(hilo);
}
retirarHilo(hilo) {
this.hilos = this.hilos.filter((otro) => otro !== hilo);
this.libres = this.libres.filter((otro) => otro !== hilo);
}
rechazarTareaDe(hilo, error) {
const pendiente = hilo.tareaActual && this.pendientes.get(hilo.tareaActual);
if (pendiente) {
clearTimeout(pendiente.temporizador);
this.pendientes.delete(hilo.tareaActual);
pendiente.rechazar(error);
}
hilo.tareaActual = null;
}
// Al quedar libre, el hilo toma la siguiente tarea de la cola si la hay.
devolverHilo(hilo) {
hilo.tareaActual = null;
const siguiente = this.cola.shift();
if (siguiente) this.asignar(hilo, siguiente);
else this.libres.push(hilo);
}
asignar(hilo, tarea) {
hilo.tareaActual = tarea.idTarea;
hilo.postMessage({ idTarea: tarea.idTarea, peticion: tarea.peticion });
}
// API publica: devuelve una promesa con el resultado del hilo.
ejecutar(peticion) {
if (this.cerrado) return Promise.reject(new Error('El pool esta cerrado'));
const idTarea = crypto.randomUUID();
return new Promise((resolver, rechazar) => {
const temporizador = setTimeout(() => {
this.pendientes.delete(idTarea);
rechazar(new Error(`La tarea ${idTarea} supero ${this.tiempoLimiteMs} ms`));
}, this.tiempoLimiteMs);
this.pendientes.set(idTarea, { resolver, rechazar, temporizador });
const hilo = this.libres.pop();
if (hilo) this.asignar(hilo, { idTarea, peticion });
else this.cola.push({ idTarea, peticion }); // todos ocupados: espera turno
});
}
// estado() expone { tamano, libres, enCola } para el endpoint de salud.
// cerrar() marca el pool como cerrado y hace terminate() de cada hilo,
// y se engancha en el apagado ordenado del M6.
}
module.exports = { PoolDeHilos };Sobre el tamaño del pool: para trabajo puramente de CPU, más hilos que núcleos no aporta nada, solo cambio de contexto. Y como cada trabajador de cluster tiene su propio pool, el cálculo real es trabajadoresCluster × tamañoDelPool ≤ núcleos. Con 7 trabajadores en 8 núcleos, el pool debería ser de 1 o 2 hilos. Esta tensión —cluster y hilos compitiendo por los mismos núcleos— es la razón por la que muchas arquitecturas acaban sacando el trabajo pesado del servidor web por completo, a un proceso aparte con una cola: la lección 10-03. En producción, además, la biblioteca piscina hace todo esto y más (cancelación con AbortSignal, reciclado de hilos tras N tareas, límites de cola, métricas); hemos escrito el nuestro para entender qué hace por dentro.
El servicio de aplicación queda así de simple:
'use strict';
// Un unico pool por proceso, creado al arrancar. Nunca uno por peticion.
function crearServicioDeEntradas({ poolDeHilos }) {
return {
async generarPdfDePedido(datosPedido) {
const { pdf, generadas } = await poolDeHilos.ejecutar(datosPedido);
// 'pdf' llega como ArrayBuffer transferido; lo envolvemos sin copiar.
return { buffer: Buffer.from(pdf), generadas };
},
};
}
module.exports = { crearServicioDeEntradas };Encaja sin fricción con el refactor del módulo 9: los controladores son fábricas con dependencias inyectadas, así que el pool se inyecta como una dependencia más y en las pruebas se sustituye por un doble de Sinon.
- Errores dentro del hilo y cómo se propagan
| Situación en el hilo | Qué ocurre | Cómo lo maneja el pool |
|---|---|---|
throw capturado por nuestro try/catch |
Se responde { ok: false, error } |
Rechaza la promesa; el hilo sigue vivo |
throw no capturado |
Evento error en el padre; el hilo muere |
Rechaza la tarea en curso, retira y recrea el hilo |
| Rechazo de promesa no gestionado | Evento error (política por defecto) |
Igual que el anterior |
process.exit() dentro del hilo |
Evento exit con código |
Recrea el hilo; la tarea queda huérfana si no la rechazamos |
| Bucle infinito | Nada: el hilo no responde jamás | Salta el tiempoLimiteMs y se rechaza; conviene terminate() |
| Memoria agotada | Muere el hilo (o el proceso entero) | Limita el tamaño de los lotes |
Un matiz sobre el tiempo límite: nuestro ejecutar rechaza la promesa, pero el hilo sigue calculando. Para un bucle infinito real hay que llamar a terminate() y recrear el hilo (ejercicio 2).
- Medición antes y después
Escenario: estreno del Festival de Jazz, con una carga base de 500 peticiones por segundo al catálogo y, cada 2 segundos, un organizador que solicita el PDF de un lote de 500 entradas.
| Métrica | Sin hilos (síncrono) | Con PoolDeHilos (2 hilos) |
|---|---|---|
| Retraso máximo del bucle de eventos | 4 176 ms | 11 ms |
| Latencia p50 del catálogo | 1 240 ms | 18 ms |
| Latencia p99 del catálogo | 6 810 ms | 74 ms |
| Peticiones con 503 / tiempo agotado | 217 | 0 |
| Duración de la generación del lote | 4 180 ms | 4 390 ms |
Lee la última fila con atención: la generación tarda un poco más (4 390 frente a 4 180 ms), porque hay que clonar la petición de entrada, transferir el resultado y coordinar el pool. Los hilos no aceleran la tarea pesada; lo que hacen es sacarla del camino de todos los demás. El p99 del catálogo cae de 6,8 segundos a 74 milisegundos, y esa es la única cifra que le importa a quien está intentando comprar una entrada mientras se genera el lote.
- Cuándo NO usar hilos
Los hilos tienen un coste real (memoria, arranque, complejidad, serialización). No los uses si:
- La tarea es de E/S. Leer un fichero, consultar PostgreSQL, llamar a la API de divisas: eso ya es asíncrono y no bloquea nada. Meter una consulta SQL en un hilo no la acelera; solo añade una copia de datos y un salto entre hilos. Este es el malentendido más frecuente.
- La tarea es trivial. Si el trabajo dura 2 ms, el clonado y el traspaso cuestan más que hacerlo en el sitio. Umbral orientativo: por debajo de ~10-20 ms de CPU, no compensa.
- La tarea se puede trocear. En el módulo 2 vimos cómo partir un bucle largo con
setImmediatepara devolver el control al bucle entre trozos. Si el trabajo se deja cortar limpiamente y no es enorme, trocear es más simple que un pool.
'use strict';
// Alternativa del modulo 2: trocear en lugar de usar hilos. Procesa el
// lote en bloques de 25 y cede el control entre bloques, de modo que el
// servidor responde peticiones entre bloque y bloque.
function procesarPorBloques(elementos, procesar, tamanoBloque = 25) {
return new Promise((resolver) => {
const resultados = [];
let indice = 0;
(function siguienteBloque() {
const fin = Math.min(indice + tamanoBloque, elementos.length);
for (; indice < fin; indice += 1) resultados.push(procesar(elementos[indice]));
if (indice < elementos.length) setImmediate(siguienteBloque);
else resolver(resultados);
})();
});
}Trocear reduce el retraso máximo del bucle a la duración de un bloque (unos 200 ms en nuestro caso), lo cual ya es aceptable para muchas aplicaciones, y no cuesta ni un hilo ni un megabyte. La contrapartida es que la tarea total tarda más y sigue consumiendo el único hilo principal.
- La tarea es realmente larga. Si generar y enviar por correo las 500 entradas del estreno tarda minutos, ni el hilo ni el troceado son la respuesta: la petición HTTP no debería esperar en absoluto. Hay que sacar el trabajo del proceso y encolarlo, responder
202 Acceptedcon un identificador, y que el cliente consulte el estado. Ese es el tema de la lección siguiente.
Errores Comunes y Consejos
- Crear un
Workerpor petición. El error más caro. Pool siempre, creado al arrancar el proceso. - Enviar instancias de clases del dominio. Llegan como objetos planos sin métodos: serializa a datos y reconstruye al otro lado.
- Meter E/S en un hilo. No mejora nada: la E/S ya era asíncrona.
- Usar
SharedArrayBuffersinAtomics, u olvidar que unArrayBuffertransferido queda vacío en el emisor. - No poner tiempo límite a las tareas. Un hilo colgado retiene su plaza del pool para siempre y acaba parando toda la generación.
- Consejo: expón
pool.estado()en tu endpoint de salud. Una cola que crece sin parar te dice que el pool está infradimensionado o que hay que encolar fuera del proceso. - Consejo: mide siempre con
monitorEventLoopDelayantes y después. Si el retraso no baja, los hilos no eran el problema.
Ejercicios
Ejercicio 1: demostrar el bloqueo y medirlo
Escribe un script que arranque un servidor HTTP mínimo con una ruta /bloquear que calcule 500 hashes SHA-256 con 100 000 iteraciones cada uno de forma síncrona, y una ruta /ping que responda { ok: true }. Con autocannon contra /ping, mide el p99 en reposo y mientras se ejecuta /bloquear. Registra el retraso del bucle con monitorEventLoopDelay.
Ejercicio 2: añadir terminate al tiempo límite
Modifica PoolDeHilos para que, al agotarse tiempoLimiteMs, además de rechazar la promesa, llame a terminate() sobre el hilo bloqueado y lo sustituya. Comprueba el comportamiento con un hilo que ejecute while (true) {}.
Ejercicio 3: contador compartido con Atomics
Con 4 hilos incrementando el mismo Int32Array sobre SharedArrayBuffer 100 000 veces cada uno, compara el resultado usando contadores[0] += 1 frente a Atomics.add(contadores, 0, 1). Explica la diferencia.
Soluciones
Ejercicio 1. En reposo, /ping da un p99 de 2-4 ms. Durante /bloquear, todas las peticiones a /ping se acumulan y el p99 sube a varios segundos: exactamente la duración del cálculo síncrono. El histograma muestra un max casi idéntico a esa duración, porque el bucle no dio ni una vuelta. La lectura correcta es que /ping no tiene ningún problema: el problema es un vecino ruidoso en el mismo hilo. Es el argumento completo de esta lección en 30 líneas de código.
Ejercicio 2. Dentro del setTimeout de ejecutar, se localiza el hilo con this.hilos.find((h) => h.tareaActual === idTarea), se retira del pool, se llama a await hiloColgado.terminate() —que mata el hilo aunque esté en bucle— y se crea uno nuevo antes de rechazar la promesa. Con while (true) {} en el hilo, sin terminate() la plaza del pool se pierde para siempre y tras N tareas colgadas el pool queda inutilizable. Con terminate(), el pool se recupera solo. Nota: terminate() mata el hilo de forma inmediata y no hay apagado ordenado posible dentro de él.
Ejercicio 3. Con contadores[0] += 1 el total esperado es 400 000 pero el observado ronda las 120 000-250 000, y varía en cada ejecución. El motivo es que += son tres pasos (leer, sumar, escribir) y dos hilos pueden leer el mismo valor y escribir el mismo resultado, perdiendo un incremento. Con Atomics.add el total es exactamente 400 000, siempre: la operación es indivisible a nivel de CPU. La moraleja es doble: la memoria compartida introduce una clase de errores que en JavaScript no existía, y esos errores son no deterministas, así que tus pruebas del módulo 9 pueden pasar mil veces y fallar en producción la noche del estreno.
Conclusión
Hemos convertido un bloqueo de 4,18 segundos en un retraso de 11 milisegundos. Medimos el problema con monitorEventLoopDelay, distinguimos con claridad cuándo toca cluster (concurrencia de E/S) y cuándo hilos (trabajo de CPU), entendimos qué viaja entre hilos con el clonado estructurado y qué no, aprendimos a transferir en lugar de copiar y a compartir memoria con Atomics sin abrir la puerta a condiciones de carrera, y construimos src/trabajadores/generar-entradas.js y src/trabajadores/pool.js con cola, reutilización, tiempo límite y recuperación ante errores. También aprendimos los límites: los hilos no aceleran la tarea, solo la apartan; no sirven para E/S; no compensan para trabajo trivial; y compiten por los mismos núcleos que los trabajadores de cluster, lo que obliga a dimensionar con cabeza.
Y queda una pieza fuera de sitio. Generar el PDF ya no bloquea, pero el organizador del Auditorio Ribera sigue esperando cuatro segundos y medio con la petición HTTP abierta a que termine. Si además hay que enviar por correo esas 500 entradas, la espera se vuelve absurda. La respuesta no es un hilo más rápido: es no esperar. En la lección siguiente, Caché y Colas de Trabajo con Redis, sacaremos el trabajo pesado fuera del proceso, responderemos 202 Accepted con un identificador de trabajo, cachearemos el catálogo que hoy se recalcula en cada petición, y de paso saldaremos la deuda que dejó la lección 10-01: sesiones y límites de peticiones compartidos entre todos los trabajadores.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
