Web Workers: multihilo real en el navegador, transferencia de memoria y el hilo principal
Guía técnica sobre Web Workers en JavaScript: cómo sacar cálculos pesados del main thread, comunicar hilos con postMessage(), evitar copias costosas con objetos transferibles y entender cuándo existe memoria compartida real con …
Hablemos con sinceridad: tu aplicación no se siente lenta porque JavaScript sea un lenguaje lento. Se siente lenta porque el hilo principal (main thread) está ocupado procesando un bloque masivo de datos exactamente en el mismo milisegundo en el que debería estar pintando una animación o respondiendo al clic del usuario. El hilo principal no debería cargar ladrillos; debería abrir puertas. Aquí es donde entran los Web Workers.
La premisa arquitectónica de un Web Worker es fascinante: te permite instanciar un script que se ejecuta en un hilo de fondo completamente aislado, otorgándote verdadero multihilo (multi-threading) en el navegador. Sin embargo, no te regala acceso al DOM para manipular la interfaz directamente. Te entrega un contexto separado, potente y silencioso, que se comunica con tu aplicación principal exclusivamente a través del paso de mensajes.
Dos contextos, una frontera estricta
Es crucial separar dos conceptos que a menudo se mezclan: un worker no es "otra función async" ni un truco de promesas en el Event Loop de tu página. Cuando escribes new Worker('procesador.js'), le estás ordenando al motor del navegador que levante un entorno de ejecución de JavaScript completamente nuevo, un WorkerGlobalScope, que corre en paralelo al nivel del sistema operativo. Al estar aislado, dentro del worker el objeto global window no existe; la referencia a su propio entorno global se hace a través de la palabra clave self.
Este aislamiento trae consigo la regla de oro de los workers: no tienen acceso directo al DOM. No puedes usar document.querySelector(), ni interactuar con los nodos visuales. Sin embargo, no están desarmados. Desde el interior de un worker dispones de un arsenal de APIs web tremendamente útiles: puedes realizar peticiones de red con fetch(), usar XMLHttpRequest, manejar temporizadores (setTimeout, setInterval), interactuar con IndexedDB y utilizar utilidades de memoria profunda como structuredClone(). Tienen cerebro, red y memoria, pero no tienen manos para tocar la pantalla.
Cómo hablan el hilo principal y el worker
Puesto que viven en universos paralelos, la única forma de que el hilo principal y el worker colaboren es a través de un puente de mensajería asíncrona. Ambos lados pueden enviarse información usando el método postMessage() y escuchar las respuestas suscribiéndose al evento message (o usando onmessage). El navegador toma el mensaje, lo serializa usando un algoritmo interno nativo llamado Structured Clone, y lo entrega al otro lado. Este algoritmo es increíblemente robusto: soporta objetos anidados, Mapas, Sets, Blobs e incluso referencias cíclicas (algo que JSON.stringify es incapaz de hacer).
Pero aquí reside el matiz arquitectónico más importante de esta guía: por defecto, los datos enviados entre el hilo principal y el worker se copian, no se comparten.
Si envías un arreglo con un millón de registros al worker, el navegador reserva memoria nueva y duplica ese millón de registros en el contexto de destino. Esto se diseñó así por seguridad: al no compartir la referencia, se evita el caos de las race conditions (que ambos hilos modifiquen la misma variable al mismo tiempo). Sin embargo, esa seguridad tiene un costo: duplicar datos masivos consume ciclos de CPU y RAM, lo que puede causar un pico de lentitud que es exactamente lo que queríamos evitar.
La sintaxis base es extremadamente limpia. Fíjate cómo ambos lados actúan como emisores y receptores simétricos:
// ==========================================
// 1. EN EL HILO PRINCIPAL (app.js)
// ==========================================
// Levantamos el contexto aislado
const miWorker = new Worker('procesador.js');
// Enviamos un objeto (El navegador creará una COPIA en memoria)
miWorker.postMessage({ comando: 'iniciar', limite: 50000 });
// Escuchamos la respuesta asíncrona del worker
miWorker.onmessage = (evento) => {
console.log('El worker terminó y respondió:', evento.data);
};
// ==========================================
// 2. DENTRO DEL WORKER (procesador.js)
// ==========================================
// 'self' es nuestro contexto global aquí adentro
self.onmessage = (evento) => {
// Recibimos la copia exacta del objeto enviado
const { comando, limite } = evento.data;
if (comando === 'iniciar') {
const resultado = hacerCalculoPesado(limite);
// Devolvemos el resultado al hilo principal (se COPIA de vuelta)
self.postMessage(resultado);
}
};
function hacerCalculoPesado(limite) {
// Lógica intensa que ahora NO bloquea la interfaz de usuario...
return `Procesados ${limite} ciclos en el hilo de fondo.`;
}
Transferencia de memoria real: Adiós a las copias costosas
Enviar un mensaje de texto corto entre hilos no tiene costo, pero ¿qué pasa si tu worker necesita procesar una imagen satelital de 2GB o un buffer de audio crudo? Como vimos en la sección anterior, usar el mecanismo por defecto (Structured Clone) duplicaría esos 2GB de RAM instantáneamente, provocando un pico de memoria y un parón en el hilo principal mientras se realiza la copia profunda. Para resolver este cuello de botella arquitectónico, JavaScript introdujo el concepto de Objetos Transferibles (Transferable Objects).
Algunos tipos de recursos pesados, siendo el ArrayBuffer el ejemplo estrella, pueden moverse de un contexto a otro en lugar de copiarse. La transferencia de memoria es una operación de "copia cero" (zero-copy). No se duplican los datos útiles; lo que ocurre es un cambio de propiedad (ownership) a nivel del motor del navegador. Es como si el hilo principal le entregara la "llave" de ese bloque de memoria al worker.
La sintaxis: postMessage y la lista de transferencia
Para indicarle al navegador que queremos mover y no copiar un recurso, debemos usar la variante avanzada de postMessage(). Este método acepta un segundo parámetro opcional: un arreglo (Transfer List) que contiene las referencias de los objetos que deseamos transferir. Observa cómo cambia el código para mover un buffer masivo sin costo de rendimiento:
// ==========================================
// EN EL HILO PRINCIPAL (app.js)
// ==========================================
const miWorker = new Worker('procesador.js');
// 1. Creamos un buffer masivo (ej. 1GB de datos crudos)
// Este bloque vive ahora en la RAM del Main Thread.
const bufferMasivo = new ArrayBuffer(1024 * 1024 * 1024); // 1GB
console.log('Antes de enviar:', bufferMasivo.byteLength); // 1073741824 bytes
// 2. TRANSFERENCIA (Zero-copy):
// postMessage(mensaje, [lista_de_transferencia])
// El primer parámetro es el mensaje (se copia).
// El segundo parámetro es el arreglo de buffers a mover (ownership).
miWorker.postMessage({ comando: 'procesar_buffer', datos: bufferMasivo }, [bufferMasivo]);
// 3. ESTADO DETACHED (Invalidación inmediata):
// Justo después de la línea anterior, el buffer original queda vacío.
// Intentar acceder a él lanzará un error o devolverá longitud cero.
console.log('Después de enviar:', bufferMasivo.byteLength); // 0 (detached!)
// bufferMasivo ya no es usable aquí.
Memoria compartida real: El mismo bloque, dos miradas
Ya dominas la copia (Structured Clone) y la mudanza (Transferables). Pero, ¿qué pasa si necesitas que el hilo principal y el worker lean y escriban sobre la misma variable de forma simultánea, sin enviarse mensajes constantemente? Aquí es donde entra en juego SharedArrayBuffer. A diferencia de un ArrayBuffer normal que se transfiere y se bloquea en el origen, un SharedArrayBuffer permite que ambos contextos (window y self) apunten exactamente al mismo bloque de memoria física subyacente.
Al compartir memoria real, puedes usar el objeto global Atomics para sincronizar lecturas y escrituras, evitando race conditions. Sin embargo, este poder tiene un precio altísimo de configuración. Debido a vulnerabilidades de hardware (como Spectre), los navegadores bloquearon el uso de SharedArrayBuffer a menos que tu página web se sirva en un entorno de máxima seguridad llamado Cross-Origin Isolated Context. Para lograr esto, tu servidor HTTP debe enviar obligatoriamente las cabeceras Cross-Origin-Opener-Policy: same-origin (COOP) y Cross-Origin-Embedder-Policy: require-corp (COEP). Si no configuras tu servidor así, el navegador simplemente dirá que SharedArrayBuffer no está definido.
Comentarios y valoraciones
No hay comentarios aún. ¡Sé el primero en opinar!