Conceptos básicos de los trabajadores web

El problema: Simultaneidad de JavaScript

Existen varios cuellos de botella que impiden la portabilidad de aplicaciones interesantes (por ejemplo, desde implementaciones pesadas en servidores) al código JavaScript del cliente. Algunos de estos incluyen compatibilidad con navegadores, escritura estática, accesibilidad y rendimiento. Afortunadamente, esta última opción se está convirtiendo rápidamente en cosa del pasado, ya que los proveedores de navegadores mejoran la velocidad de sus motores de JavaScript.

Algo que sigue siendo un obstáculo para JavaScript es, en realidad, el lenguaje en sí. JavaScript es un entorno de un solo subproceso, lo que significa que no se pueden ejecutar varias secuencias de comandos al mismo tiempo. A modo de ejemplo, imagina un sitio que necesita controlar eventos de la IU, consultar y procesar grandes cantidades de datos de API, y manipular el DOM. Es bastante común, ¿verdad? Lamentablemente, todo esto no puede ser simultáneo debido a las limitaciones del entorno de ejecución de JavaScript de los navegadores. La ejecución de secuencias de comandos ocurre dentro de un solo subproceso.

Los desarrolladores imitan la "simultaneidad" con técnicas como setTimeout(), setInterval(), XMLHttpRequest y controladores de eventos. Sí, todas estas funciones se ejecutan de forma asíncrona, pero el hecho de no bloquear no implica necesariamente la simultaneidad. Los eventos asíncronos se procesan después de que se produce la secuencia de comandos en ejecución actual. La buena noticia es que HTML5 nos ofrece algo mejor que estos trucos.

Presentamos Web Workers: cómo incorporar subprocesos a JavaScript

La especificación de Web Workers define una API para generar secuencias de comandos en segundo plano en tu aplicación web. Los trabajadores web te permiten ejecutar secuencias de comandos de larga duración para controlar tareas de procesamiento intensivo, pero sin bloquear la IU ni otras secuencias de comandos para controlar las interacciones del usuario. Nos ayudarán a terminar ese diálogo desagradable de "secuencia de comandos no receptiva" que todos amamos:

Diálogo de secuencia de comandos que no responde
Diálogo de secuencia de comandos común que no responde.

Los trabajadores usan el paso de mensajes tipo subproceso para lograr el paralelismo. Son ideales para mantener la IU actualizada, en buen rendimiento y responsiva para los usuarios.

Tipos de trabajadores web

Vale la pena señalar que, en la especificación, se analizan dos tipos de trabajadores web: los trabajadores dedicados y los trabajadores compartidos. En este artículo, solo se tratarán los trabajadores dedicados. En todo momento nos referiré a ellos como 'trabajadores web' o 'trabajadores'.

Primeros pasos

Los trabajadores web se ejecutan en un subproceso aislado. Como resultado, el código que ejecuten debe estar contenido en un archivo separado. Pero antes de hacerlo, lo primero que debes hacer es crear un nuevo objeto Worker en tu página principal. El constructor toma el nombre de la secuencia de comandos de trabajador:

var worker = new Worker('task.js');

Si el archivo especificado existe, el navegador generará un nuevo subproceso de trabajo, que se descarga de forma asíncrona. El trabajador no comenzará hasta que el archivo se haya descargado y ejecutado por completo. Si la ruta al trabajador muestra un 404, el trabajador fallará de manera silenciosa.

Después de crear el trabajador, inícialo llamando al método postMessage():

worker.postMessage(); // Start the worker.

Cómo comunicarse con un trabajador mediante la transmisión de mensajes

La comunicación entre una tarea y su página superior se realiza con un modelo de eventos y el método postMessage(). Según el navegador o la versión, postMessage() puede aceptar una cadena o un objeto JSON como su único argumento. Las versiones más recientes de los navegadores modernos admiten la transferencia de un objeto JSON.

A continuación, se muestra un ejemplo de cómo usar una cadena para pasar "Hello World" a un trabajador en doWork.js. El trabajador simplemente muestra el mensaje que se le pasa.

Guion principal:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (el trabajador):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

Cuando se llama a postMessage() desde la página principal, nuestro trabajador controla ese mensaje definiendo un controlador onmessage para el evento message. Se puede acceder a la carga útil del mensaje (en este caso, "Hello World") en Event.data. Aunque este ejemplo en particular no es muy emocionante, demuestra que postMessage() también es un medio para pasar datos al subproceso principal. Es práctico.

Los mensajes que se pasan entre la página principal y los trabajadores se copian, no se comparten. En el siguiente ejemplo, la propiedad “msg” del mensaje JSON es accesible en ambas ubicaciones. Parece que el objeto se pasa directamente al trabajador, aunque se esté ejecutando en un espacio dedicado independiente. En realidad, lo que sucede es que el objeto se serializa a medida que se entrega al trabajador y, luego, se deserializa en el otro extremo. La página y el trabajador no comparten la misma instancia, de modo que el resultado final es que se crea un duplicado en cada pase. La mayoría de los navegadores implementan esta función codificando o decodificando automáticamente el valor JSON en cualquiera de los extremos.

El siguiente es un ejemplo más complejo que pasa mensajes con objetos JSON.

Guion principal:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

Objetos transferibles

La mayoría de los navegadores implementan el algoritmo de clonación estructurada, que te permite pasar tipos más complejos dentro y fuera de los trabajadores, como File, Blob, ArrayBuffer y objetos JSON. Sin embargo, cuando pasas estos tipos de datos con postMessage(), se crea una copia. Por lo tanto, si pasas un archivo grande de 50 MB (por ejemplo), existe una sobrecarga notable al pasar ese archivo entre el trabajador y el subproceso principal.

La clonación estructurada es genial, pero una copia puede tardar cientos de milisegundos. Para combatir el hit de rendimiento, puedes usar Transferable Objects.

Con los objetos transferibles, los datos se transfieren de un contexto a otro. Es una copia cero, lo que mejora enormemente el rendimiento de enviar datos a un trabajador. Considéralo como una referencia de transferencia si perteneces al mundo de C/C++. Sin embargo, a diferencia del paso por referencia, la “versión” del contexto de llamada ya no está disponible una vez que se transfiere al contexto nuevo. Por ejemplo, cuando se transfiere un ArrayBuffer de tu app principal a Worker, se borra el ArrayBuffer original y ya no se puede usar. Su contenido se transfiere (literalmente) al contexto de los trabajadores.

Para usar objetos transferibles, usa una firma ligeramente diferente de postMessage():

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

En el caso del trabajador, el primer argumento son los datos y el segundo es la lista de elementos que se deben transferir. Por cierto, no es necesario que el primer argumento sea ArrayBuffer. Por ejemplo, puede ser un objeto JSON:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

El punto importante es que el segundo argumento debe ser un array de ArrayBuffer. Esta es tu lista de elementos transferibles.

Para obtener más información sobre los elementos transferibles, consulta nuestra publicación en developer.chrome.com.

El entorno del trabajador

Alcance del trabajador

En el contexto de un trabajador, self y this hacen referencia al alcance global del trabajador. Por lo tanto, el ejemplo anterior también podría escribirse de la siguiente manera:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

Como alternativa, puedes configurar el controlador de eventos onmessage directamente (aunque los usuarios de JavaScript siempre recomiendan addEventListener).

onmessage = function(e) {
var data = e.data;
...
};

Funciones disponibles para los trabajadores

Debido a su comportamiento multiproceso, los Web Workers solo tienen acceso a un subconjunto de funciones de JavaScript:

  • El objeto navigator
  • El objeto location (solo lectura)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() y setInterval()/clearInterval()
  • La Caché de aplicación
  • Importa secuencias de comandos externas con el método importScripts()
  • Genera otros trabajadores web

Los trabajadores NO tienen acceso a lo siguiente:

  • El DOM (no es seguro para subprocesos)
  • El objeto window
  • El objeto document
  • El objeto parent

Cargando secuencias de comandos externas

Puedes cargar bibliotecas o archivos de secuencias de comandos externos en un trabajador con la función importScripts(). Este método usa cero o más cadenas que representen los nombres de archivo de los recursos que se importarán.

En este ejemplo, se cargan script1.js y script2.js en el trabajador:

worker.js:

importScripts('script1.js');
importScripts('script2.js');

que también se puede escribir como una sola sentencia de importación:

importScripts('script1.js', 'script2.js');

Subtrabajadores

Los trabajadores tienen la capacidad de generar trabajadores secundarios. Es ideal para dividir más tareas grandes durante el tiempo de ejecución. Sin embargo, ten en cuenta las siguientes advertencias sobre los subtrabajadores:

  • Los trabajadores secundarios deben estar alojados en el mismo origen que la página superior.
  • Los URI dentro de subtrabajadores se resuelven en relación con la ubicación del trabajador superior (a diferencia de la página principal).

Ten en cuenta que la mayoría de los navegadores generan procesos independientes para cada trabajador. Antes de generar una granja de trabajadores, ten cuidado cuando acabes demasiados recursos del sistema del usuario. Un motivo es que los mensajes que se pasan entre las páginas principales y los trabajadores se copian, no se comparten. Consulta cómo comunicarse con un trabajador mediante el envío de mensajes.

Si deseas ver una muestra de cómo generar un subtrabajador, consulta el ejemplo en la especificación.

Trabajadores intercalados

¿Qué pasa si deseas crear tu secuencia de comandos de trabajador sobre la marcha o una página independiente sin tener que crear archivos trabajadores separados? Con Blob(), puedes “intercalar” tu trabajador en el mismo archivo HTML que tu lógica principal creando un controlador de URL para el código del trabajador como una cadena:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

URLs de BLOB

La magia viene con la llamada a window.URL.createObjectURL(). Este método crea una cadena de URL simple que se puede usar para hacer referencia a datos almacenados en un objeto File o Blob del DOM. Por ejemplo:

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

Las URL de BLOB son únicas y duran todo el ciclo de vida de tu aplicación (p.ej., hasta que se descarga document). Si vas a crear muchas URLs de BLOB, te recomendamos que liberes referencias que ya no sean necesarias. Para liberar de forma explícita las URLs de BLOB, pásalas a window.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

En Chrome, hay una página para ver todas las URLs de BLOB creadas: chrome://blob-internals/.

Ejemplo completo

Si vamos un paso más allá, podemos aprender de forma más inteligente la manera en que se intercala el código JS del trabajador en nuestra página. Esta técnica usa una etiqueta <script> para definir el trabajador:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

En mi opinión, este nuevo enfoque es un poco más limpio y más legible. Define una etiqueta de secuencia de comandos con id="worker1" y type='javascript/worker' (para que el navegador no analice el JS). Ese código se extrae como una cadena con document.querySelector('#worker1').textContent y se pasa a Blob() para crear el archivo.

Cargando secuencias de comandos externas

Cuando uses estas técnicas para intercalar tu código de trabajador, importScripts() solo funcionará si proporcionas un URI absoluto. Si intentas pasar un URI relativo, el navegador mostrará un error de seguridad. El motivo es que el trabajador (que ahora se crea a partir de una URL de BLOB) se resolverá con un prefijo blob:, mientras que tu app se ejecutará desde un esquema diferente (probablemente http://). Por lo tanto, la falla se debe a restricciones de origen cruzado.

Una forma de usar importScripts() en un trabajador intercalado es “inyectar” la URL actual de tu secuencia de comandos principal. Para ello, pásala al trabajador intercalado y construye la URL absoluta de forma manual. Esto garantizará que la secuencia de comandos externa se importe desde el mismo origen. Suponiendo que tu app principal se ejecuta desde http://example.com/index.html:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

manejo de errores

Al igual que con cualquier lógica de JavaScript, se recomienda controlar los errores que se producen en tus trabajadores web. Si se produce un error mientras se ejecuta un trabajador, se activa ErrorEvent. La interfaz contiene tres propiedades útiles para averiguar qué salió mal: filename (el nombre de la secuencia de comandos del trabajador que causó el error), lineno (el número de línea en el que se produjo el error) y message (una descripción significativa del error). A continuación, se muestra un ejemplo de cómo configurar un controlador de eventos onerror para imprimir las propiedades del error:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

Ejemplo: workerWithError.js intenta ejecutar 1/x, donde x no está definido.

// TODO: DevSite - Se quitó la muestra de código porque usaba controladores de eventos intercalados

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

Información sobre seguridad

Restricciones con acceso local

Debido a las restricciones de seguridad de Google Chrome, los trabajadores no se ejecutarán de manera local (p.ej., desde file://) en las versiones más recientes del navegador. En cambio, fallan silenciosamente. Para ejecutar tu app desde el esquema file://, ejecuta Chrome con la marca --allow-file-access-from-files establecida.

Otros navegadores no imponen la misma restricción.

Consideraciones del mismo origen

Las secuencias de comandos para trabajadores deben ser archivos externos con el mismo esquema que la página que realiza la llamada. Por lo tanto, no puedes cargar una secuencia de comandos desde una URL data: o javascript:, y una página https: no puede iniciar secuencias de comandos de trabajador que comiencen con URLs http:.

Casos de uso

Entonces, ¿qué tipo de aplicación usaría web Workers? Estas son otras ideas para aumentar la actividad de tu cerebro:

  • Carga previa o almacenamiento de datos en caché para su uso posterior
  • Resaltado de sintaxis de código o cualquier otro formato de texto en tiempo real
  • Corrector ortográfico.
  • Analizar datos de audio o video
  • E/S en segundo plano o sondeo de servicios web
  • Procesamiento de grandes arrays o respuestas JSON humungosas
  • Filtrado de imágenes en <canvas>.
  • Actualizar muchas filas de una base de datos web local

Para obtener más información sobre los casos de uso relacionados con la API de Web Workers, visita Descripción general de los trabajadores.

Demostraciones

Referencias