Node.js se diseñó tradicionalmente como un entorno de ejecución monohilo con bucle de eventos, altamente optimizado para operaciones de entrada/salida no bloqueantes (I/O-bound). Sin embargo, las aplicaciones backend empresariales modernas afrontan con frecuencia cargas de trabajo con un uso intensivo del procesador (CPU-bound): validación y transformación de grandes estructuras JSON/Protocol Buffers, generación de PDFs, compresión y cifrado de flujos multimedia, cálculo de hashes criptográficos o inferencia local de modelos de machine learning. Ejecutar estas rutinas pesadas directamente en el hilo principal bloquea el bucle de eventos de libuv y dispara la latencia para todas las conexiones concurrentes.
Históricamente, el multiprocesamiento se lograba mediante la duplicación de procesos del sistema operativo con child_process (fork, spawn) o el módulo cluster. A partir de Node.js 10.5+ y su plena estabilización en Node.js 12+, la plataforma incorpora el módulo worker_threads, implementando verdadero multithreading a nivel de sistema operativo dentro de un único proceso:
+-------------------------------------------------------------------------+
| Single OS Process (PID: 14205) |
+-------------------------------------------------------------------------+
| Hilo Principal (Main Thread) | Worker Thread #1 |
| - libuv Event Loop | - libuv Event Loop |
| - V8 Isolate A (Heap A, Stack A) | - V8 Isolate B (Heap B, Stack B) |
| - Call Stack | - Call Stack |
+-----------------------------------+-------------------------------------+
| |
+------------------+------------------+
|
+-------------------------------------------------+
| Memoria Virtual Compartida |
| - SharedArrayBuffer (Zero-copy concurrent R/W) |
| - Atomics Synchronization API (wait / notify) |
+-------------------------------------------------+La distinción arquitectónica fundamental entre los Worker Threads de Node.js y los hilos en Java, C++ o Go radica en el aislamiento estricto del estado:
transferList), o memoria lineal compartida (SharedArrayBuffer) respaldada por operaciones atómicas y seguras (Atomics).La comunicación entre el hilo principal y los hilos de trabajo se coordina mediante canales de mensajes (MessageChannel / MessagePort), que constituyen abstracciones de alto nivel sobre las colas de tareas asíncronas de libuv:
[Hilo Principal: V8 Isolate 1] [Worker Thread: V8 Isolate 2]
| |
| --- postMessage({ task: 1 }) [Structured Clone] -> | (Deserialización en Heap 2)
| |
| --- postMessage(arrayBuffer, [arrayBuffer]) -----> | (Zero-copy: Detach Heap 1)
| (Transferencia de propiedad) |
| |
| <======== SharedArrayBuffer (Atomics) =========> | (Acceso paralelo directo)port.postMessage(data), V8 serializa el grafo de objetos a un formato binario interno, transfiere los bytes a la cola de libuv del hilo de destino y los deserializa en su heap. Para cargas grandes (por ejemplo, 100 MB), la serialización consume un tiempo considerable de CPU.ArrayBuffer en la lista de transferencia:
port.postMessage({ buffer }, [buffer]);buffer.byteLength pasa a valer 0, impidiendo mutaciones desfasadas concurrentes.Atomics sincronizan el acceso concurrente sin estados de carrera.El controlador HTTP delega la carga computacional pesada al gestor del ThreadPool, recibiendo una Promise diferida.
Si hay un worker libre, la tarea se despacha inmediatamente. Si todos están ocupados, se encola en una estructura FIFO.
Las estructuras binarias y los buffers se transfieren mediante transferList con cesión instantánea de propiedad.
El hilo ejecuta el algoritmo pesado al 100% de la capacidad de su núcleo sin bloquear el bucle de eventos principal.
El worker envía el resultado al hilo principal. La fase Poll resuelve la Promise del cliente y extrae la siguiente tarea en cola.
Al compartir memoria de forma directa entre hilos, los bloqueos de exclusión mutua deben implementarse mediante primitivas atómicas del procesador. Usando Atomics.wait() y Atomics.notify(), podemos construir un Mutex de alto rendimiento:
/**
* SharedMutex: Bloqueo de exclusión mutua respaldado por SharedArrayBuffer y Atomics.
* Índice 0: estado del cerrojo (0 = UNLOCKED, 1 = LOCKED).
*/
export class SharedMutex {
private static readonly UNLOCKED = 0;
private static readonly LOCKED = 1;
constructor(private readonly sharedBuffer: Int32Array) {}
public static createBuffer(): Int32Array {
// 4 bytes para una celda Int32
const sab = new SharedArrayBuffer(4);
return new Int32Array(sab);
}
public lock(): void {
// Intento atómico de adquirir el bloqueo
while (
Atomics.compareExchange(
this.sharedBuffer,
0,
SharedMutex.UNLOCKED,
SharedMutex.LOCKED
) !== SharedMutex.UNLOCKED
) {
// Si ya está bloqueado, el hilo entra en suspensión a nivel de kernel
// NOTA: ¡Atomics.wait está estrictamente prohibido en el hilo principal de Node.js!
Atomics.wait(this.sharedBuffer, 0, SharedMutex.LOCKED);
}
}
public unlock(): void {
const oldValue = Atomics.exchange(this.sharedBuffer, 0, SharedMutex.UNLOCKED);
if (oldValue === SharedMutex.LOCKED) {
// Despertamos a un hilo en espera sobre esta dirección de memoria
Atomics.notify(this.sharedBuffer, 0, 1);
}
}
public runSynchronized<T>(action: () => T): T {
this.lock();
try {
return action();
} finally {
this.unlock();
}
}
}Crear una nueva instancia de Worker en cada petición entrante es un antipatrón crítico que colapsa la memoria. En entornos de producción se debe desplegar un pool persistente con un tamaño acotado a los núcleos físicos de la CPU:
import { Worker } from 'node:worker_threads';
import os from 'node:os';
import path from 'node:path';
interface Task<T, R> {
id: string;
data: T;
resolve: (result: R) => void;
reject: (err: Error) => void;
timeoutTimer: NodeJS.Timeout;
}
interface WorkerRecord {
worker: Worker;
busy: boolean;
activeTaskId: string | null;
}
export class ProductionThreadPool<T = unknown, R = unknown> {
private readonly workers: WorkerRecord[] = [];
private readonly taskQueue: Task<T, R>[] = [];
private isTerminating = false;
constructor(
private readonly workerScriptPath: string,
private readonly poolSize: number = os.cpus().length,
private readonly taskTimeoutMs: number = 30_000
) {
this.initPool();
}
private initPool(): void {
for (let i = 0; i < this.poolSize; i++) {
this.spawnWorker();
}
}
private spawnWorker(): void {
const worker = new Worker(this.workerScriptPath);
const record: WorkerRecord = {
worker,
busy: false,
activeTaskId: null,
};
worker.on('message', (result: { taskId: string; payload: R; error?: string }) => {
this.handleWorkerResult(record, result);
});
worker.on('error', (err) => {
console.error(`[Worker Error] El hilo terminó con error inesperado:`, err);
this.replaceCrashedWorker(record);
});
this.workers.push(record);
}
public execute(data: T): Promise<R> {
if (this.isTerminating) {
return Promise.reject(new Error('El pool de hilos se encuentra en proceso de parada'));
}
return new Promise<R>((resolve, reject) => {
const taskId = `task_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const timeoutTimer = setTimeout(() => {
this.handleTaskTimeout(taskId);
}, this.taskTimeoutMs);
const task: Task<T, R> = {
id: taskId,
data,
resolve,
reject,
timeoutTimer,
};
this.taskQueue.push(task);
this.dispatchNext();
});
}
private dispatchNext(): void {
if (this.taskQueue.length === 0) return;
const availableWorker = this.workers.find((w) => !w.busy);
if (!availableWorker) return;
const task = this.taskQueue.shift()!;
availableWorker.busy = true;
availableWorker.activeTaskId = task.id;
(availableWorker.worker as any).__currentTask = task;
availableWorker.worker.postMessage({
taskId: task.id,
data: task.data,
});
}
private handleWorkerResult(
record: WorkerRecord,
result: { taskId: string; payload: R; error?: string }
): void {
const currentTask = (record.worker as any).__currentTask as Task<T, R> | undefined;
if (currentTask && currentTask.id === result.taskId) {
clearTimeout(currentTask.timeoutTimer);
if (result.error) {
currentTask.reject(new Error(result.error));
} else {
currentTask.resolve(result.payload);
}
(record.worker as any).__currentTask = undefined;
}
record.busy = false;
record.activeTaskId = null;
this.dispatchNext();
}
private handleTaskTimeout(taskId: string): void {
const workerIndex = this.workers.findIndex((w) => w.activeTaskId === taskId);
if (workerIndex === -1) return;
const record = this.workers[workerIndex];
const currentTask = (record.worker as any).__currentTask as Task<T, R>;
if (currentTask) {
currentTask.reject(new Error(`Tiempo límite de ejecución superado (${this.taskTimeoutMs} ms)`));
}
record.worker.terminate().finally(() => {
this.workers.splice(workerIndex, 1);
this.spawnWorker();
this.dispatchNext();
});
}
private replaceCrashedWorker(deadRecord: WorkerRecord): void {
const idx = this.workers.indexOf(deadRecord);
if (idx !== -1) {
this.workers.splice(idx, 1);
}
const currentTask = (deadRecord.worker as any).__currentTask as Task<T, R> | undefined;
if (currentTask) {
clearTimeout(currentTask.timeoutTimer);
currentTask.reject(new Error('El hilo de trabajo falló durante la ejecución de la tarea'));
}
if (!this.isTerminating) {
this.spawnWorker();
this.dispatchNext();
}
}
public async shutdown(): Promise<void> {
this.isTerminating = true;
while (this.taskQueue.length > 0) {
const task = this.taskQueue.shift()!;
clearTimeout(task.timeoutTimer);
task.reject(new Error('El pool de hilos se detuvo antes de procesar la tarea'));
}
const terminations = this.workers.map((w) => w.worker.terminate());
await Promise.all(terminations);
this.workers.length = 0;
}
}La especificación de ECMAScript prohíbe de forma tajante el uso de Atomics.wait() en el hilo principal de Node.js. Intentar llamarlo provocará una excepción sincrónica TypeError: Atomics.wait cannot be called in this context. Poner a dormir al hilo principal detendría el bucle de eventos de libuv, congelando sockets, temporizadores y peticiones de toda la aplicación.
Usa Atomics.wait únicamente en hilos Worker en segundo plano. En el hilo principal, la especificación ofrece el método asíncrono Atomics.waitAsync(), que devuelve una Promise.
Instanciar new Worker('./worker.js') en cada petición web ocasiona una degradación catastrófica del servicio. Cada hilo reserva un V8 Isolate nuevo, recompila el script desde cero y arranca su propio bucle libuv. Bajo una carga de 100 RPS, la memoria del servidor se saturará en segundos hasta ser terminado por el OOM Killer.
Implementa siempre un pool de hilos de larga duración (como la librería piscina), limitando el número de workers a los núcleos físicos de la CPU (os.cpus().length).
Enviar estructuras voluminosas (por ejemplo, buffers de imágenes o dumps de más de 50 MB) con worker.postMessage(data) sin una lista de transferencia obliga a V8 a duplicar toda la memoria mediante el Algoritmo de Clonación Estructurada. Esto duplica el consumo de memoria e introduce cientos de milisegundos de latencia por serialización.
Transfiere siempre buffers binarios en formato ArrayBuffer mediante el segundo argumento transferList: port.postMessage(buffer, [buffer.buffer]).
Escribir directamente en posiciones de un SharedArrayBuffer (shared[0]++ o shared[0] = value) desde múltiples hilos no es una operación atómica. A nivel de microarquitectura de la CPU, un incremento implica leer la memoria en un registro, incrementarlo y reescribirlo. En accesos simultáneos, los valores se sobreescribirán de forma corrupta.
Utiliza siempre Atomics.add(), Atomics.store() o Atomics.compareExchange() para garantizar la seguridad entre hilos.
El hilo principal crea workers persistentes. El sistema operativo asigna hilos del kernel y Node.js arranca instancias aisladas de V8 y libuv.
El planificador serializa los metadatos y transfiere los buffers binarios cediendo la propiedad sin copia mediante transferList.
El worker recibe el evento message y procesa algoritmos intensivos en un núcleo de CPU asignado sin demoras en el Event Loop principal.
El resultado se envía de vuelta. Un callback en la fase Poll del bucle principal resuelve la Promise del cliente sin bloqueos.
El worker se mantiene activo en el pool, preparado de inmediato para atender la siguiente tarea en espera dentro de la cola.