Introducción a los Web Workers

HTML5 Rocks

El problema de la simultaneidad en JavaScript

Existen varios obstáculos que evitan que las aplicaciones interesantes se extrapolen (es decir, de implementaciones principalmente de servidor) a JavaScript del cliente. Algunas de estas aplicaciones incluyen compatibilidad de navegadores, escritura estática, accesibilidad y rendimiento. Afortunadamente, el rendimiento se está convirtiendo rápidamente en algo del pasado, pues los desarrolladores de navegadores mejoran con gran rapidez la velocidad de los motores JavaScript de estos.

De hecho, uno de los obstáculos que aún se mantienen en JavaScript es el lenguaje en sí. JavaScript es un entorno de subproceso único, es decir, que no se pueden ejecutar varias secuencias de comandos al mismo tiempo. Por ejemplo, imagina un sitio que necesite gestionar eventos de interfaz de usuario, solicitar y procesar grandes cantidades de datos de API y manipular los DOM. Algo muy común, ¿verdad? Desafortunadamente, todo esto no puede realizarse de forma simultánea debido a las limitaciones en el tiempo de ejecución de los navegadores de JavaScript. La ejecución de secuencias de comandos se realiza en un único subproceso.

Los desarrolladores imitan la "simultaneidad" utilizando técnicas como setTimeout(), setInterval() y XMLHttpRequest, así como gestores de eventos. Sí, todas estas funciones se ejecutan de forma asíncrona; sin embargo, que no se bloqueen unas a otras no significa necesariamente que tengan lugar de forma simultánea. Los eventos asíncronos se procesan después de haber generado la secuencia de comandos que se esté ejecutando en ese momento. La buena noticia es que HTML5 nos ofrece algo mejor que este tipo de trucos de hacker.

Introducción a los Web Workers: implementar subprocesos en JavaScript

La especificación de Web Workers recomienda un API para generar secuencias de comandos en segundo plano en tu aplicación web. Los Web Workers te permiten realizar acciones como activar secuencias de comandos con tiempos de ejecución largos para gestionar tareas intensivas de computación, pero sin bloquear la interfaz de usuario u otras secuencias de comandos para gestionar las interacciones del usuario. Nos ayudarán a acabar con esos molestos cuadros de diálogo, a los que todos hemos cogido cariño, que informan de que la secuencia de comandos no responde.

Cuadro de diálogo que informa de que la secuencia de comandos no responde
Cuadro de diálogo habitual para informar de que la secuencia de comandos no responde

Los Web Workers utilizan una transferencia de mensajes similar a los subprocesos para alcanzar el paralelismo. Son perfectos para mantener tu interfaz actualizada, eficiente y receptiva para los usuarios.

Tipos de Web Workers

Es importante destacar que en la especificación se debaten dos tipos de Web Workers: los Workers dedicados y los Workers compartidos. En este artículo solo se ofrecerá información sobre los Workers dedicados y, a lo largo del mismo, se hará referencia a ellos como "Web Workers" o "Workers".

Introducción

Los Web Workers se ejecutan en un subproceso aislado. Como resultado, es necesario que el código que ejecutan se encuentre en un archivo independiente. Sin embargo, antes de hacer esto, lo primero que tienes que hacer es crear un nuevo objeto Worker en tu página principal. El constructor toma el nombre de la secuencia de comandos del Worker.

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

Si el archivo especificado existe, el navegador generará un nuevo subproceso de Worker que lo descargará de forma asíncrona. El Worker no empezará hasta que el archivo se haya descargado completamente y se haya ejecutado. Si la ruta a tu Worker devuelve un error 404, el Worker fallará automáticamente.

Después de crear el Worker, comienza ejecutando el método postMessage().

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

Cómo establecer comunicación con un Worker mediante transferencia de mensajes

La comunicación entre un Worker y su página principal se realiza mediante un modelo de evento y el método postMessage(). En función del navegador o de la versión, postMessage() puede aceptar una cadena o un objeto JSON como argumento único. Las últimas versiones de los navegadores modernos son compatibles con la transferencia de objetos JSON.

A continuación, se muestra un ejemplo sobre cómo utilizar una cadena para transferir "Hello World" a un Worker en doWork.js. El Worker simplemente devuelve el mensaje que se le transfiere.

Secuencia de comandos 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 Worker):

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

Cuando se ejecuta postMessage() desde la página principal, nuestro Worker gestiona este mensaje definiendo un gestor onmessage para el evento message. Se puede acceder a la carga del mensaje (en este caso "Hello World") en Event.data. Aunque este ejemplo concreto no es demasiado emocionante, demuestra que postMessage() también sirve para transferir datos de vuelta al subproceso principal. Algo que resulta conveniente.

Los mensajes que se transfieren entre la página principal y los Workers se copian, no se comparten. Por ejemplo, en el siguiente ejemplo, a la propiedad "msg" del mensaje JSON se accede en las dos ubicaciones. Parece que el objeto se transfiere directamente al Worker aunque se esté ejecutando en un espacio específico e independiente. En realidad, lo que ocurre es que el objeto se serializa mientras se transfiere al Worker y, posteriormente, se anula la serialización en la otra fase del proceso. La página y el Worker no comparten la misma instancia, por lo que el resultado final es la creación de un duplicado en cada transferencia. La mayoría de los navegadores implementan esta función mediante la codificación/descodificación JSON automática del valor en la otra fase del proceso.

En el siguiente ejemplo, que es más complejo, se transfieren mensajes utilizando objetos JSON.

Secuencia de comandos 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() {
    // Calling 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);

Nota: existen dos formas de detener un Worker: ejecutando worker.terminate() desde la página principal o ejecutando self.close() dentro del propio Worker.

Ejemplo: ¡ejecuta este Worker!

El entorno del Worker

Alcance del Worker

En el contexto de un Worker, tanto self como this hacen referencia al alcance global del Worker. Por tanto, el ejemplo anterior también se podría haber escrito de la siguiente forma:

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

De forma alternativa, también podrías configurar el gestor de eventos onmessage directamente (aunque los expertos en JavaScript siempre recomiendan addEventListener).

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

Funciones disponibles para Workers

Debido al comportamiento con múltiples subprocesos característico de los Web Workers, estos solo pueden acceder al siguiente conjunto de funciones de JavaScript:

Los Workers NO pueden acceder a las siguientes funciones:

  • DOM (no es seguro para el subproceso)
  • Objeto window
  • Objeto document
  • Objeto parent

Cómo cargar secuencias de comandos externas

Puedes cargar bibliotecas o archivos de secuencias de comandos externas en un Worker con la función importScripts(). El método utiliza cero o más cadenas que representan los nombres de archivo de los recursos que se van a importar.

En este ejemplo se carga script1.js y script2.js en el Worker:

worker.js:

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

Que también se puede escribir como una única instrucción de importación:

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

Subworkers

Los Workers tienen la capacidad de generar Workers secundarios. Esto es algo fantástico para dividir aún más las tareas intensivas en el tiempo de ejecución. Sin embargo, a la hora de utilizar los Subworkers es necesario tener en cuenta los siguientes aspectos:

  • Los Subworkers deben estar alojados en el mismo origen que la página principal.
  • La resolución de las URI de los Subworkers está relacionada con la ubicación de su Worker principal (en oposición a la página principal).

Ten en cuenta que la mayoría de los navegadores generan procesos independientes para cada Worker. Antes de que generes un conjunto de Workers, debes tener cuidado para no utilizar demasiados recursos del sistema del usuario. Una de las razones de esta advertencia se debe a que los mensajes transferidos entre las páginas principales y los Workers se copian, no se comparten. Consulta la sección Cómo establecer comunicación con un Worker mediante transferencia de mensajes.

Para obtener más información sobre cómo generar un Subworker, consulta el ejemplo que aparece en la especificación.

Workers integrados

¿Y qué ocurre si quieres crear sobre la marcha una secuencia de comandos para tu Worker o si quieres crear una página autosuficiente sin tener que crear archivos de Worker independientes? Con la nueva interfaz BlobBuilder, puedes "integrar" tu Worker en el mismo archivo HTML como lógica principal creando un BlobBuilder y añadiendo el código del Worker como una cadena.

// Prefixed in Webkit, Chrome 12, and FF6: window.WebKitBlobBuilder, window.MozBlobBuilder
var bb = new BlobBuilder();
bb.append("onmessage = function(e) { postMessage('msg from worker'); }");

// Obtain a blob URL reference to our worker 'file'.
// Note: window.webkitURL.createObjectURL() in Chrome 10+.
var blobURL = window.URL.createObjectURL(bb.getBlob());

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

URL Blob

Lo increíble comienza con la ejecución de window.URL.createObjectURL(). Este método crea una cadena de URL sencilla que se puede utilizar para hacer referencia a datos almacenados en un archivo File DOM o en un objeto Blob. A continuación se indica un ejemplo:

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

Las URL Blob son únicas y su duración es la misma que la de tu aplicación (por ejemplo, hasta que document se descargue). Si estás creando muchas URL Blob, sería buena idea liberar referencias que ya no sean necesarias. Para liberar una URL Blob de forma explícita, transfiérela a window.URL.revokeObjectURL(), tal y como se muestra a continuación.

window.URL.revokeObjectURL(blobURL); // window.webkitURL.createObjectURL() in Chrome 10+.

En Chrome, existe una página estupenda para visualizar todas las URL Blob que has creado: chrome://blob-internals/.

Ejemplo completo

Para ampliar este paso, podemos profundizar más sobre cómo se integra el código JS del Worker en tu página. Esta técnica utiliza una etiqueta <script> para definir el Worker:

<!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 bb = new BlobBuilder();
    bb.append(document.querySelector('#worker1').textContent);

    // Note: window.webkitURL.createObjectURL() in Chrome 10+.
    var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
    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 claro y más legible. Define una etiqueta de secuencia de comandos con id="worker1" y type='javascript/worker' (por lo que el navegador no analiza el JS). Este código se extrae en forma de cadena mediante document.querySelector('#worker1').textContent y se transfiere a BlobBuilder.append().

Cómo cargar secuencias de comandos externas

Al utilizar estas técnicas para integrar tu código de Worker, importScripts() solo funcionará si proporcionas una URI absoluta. Si intentas transferir una URI relativa, el navegador devolverá un error de seguridad. El motivo de esto es que el Worker (ahora creado desde una URL Blob) se resolverá con un prefijo blob:, mientras que tu aplicación se estará ejecutando desde un esquema diferente (probablemente http://). Por lo tanto, el error se deberá a restricciones de orígenes cruzados.

Una forma de utilizar importScripts() en un Worker integrado es "inyectar" la URL actual desde la que se está ejecutando tu secuencia de comandos principal. Para ello, transfiérela al Worker integrado y crea la URL absoluta de forma manual. De esta forma, te asegurarás de que la secuencia de comandos externa se ha importado desde el mismo origen. Suponiendo que tu aplicación principal se ejecute 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({url: document.location});
</script>

Cómo gestionar errores

Como con cualquier lógica de JavaScript, es posible que quieras gestionar todos los errores que se producen en tus Web Workers. Si se produce un error mientras se ejecuta un Worker, se activa un evento ErrorEvent. La interfaz incluye tres propiedades útiles para descubrir la causa del error: filename (el nombre de la secuencia de comandos del Worker que causó el error), lineno (el número de línea donde se produjo el error) y message (una descripción significativa del error). A continuación, se muestra un ejemplo sobre cómo configurar un gestor de eventos onerror para reproducir 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 el valor de "x" no se ha definido.

workerWithError.js:

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

Seguridad

Restricciones con acceso local

Debido a las restricciones de seguridad de Google Chrome, los Workers no se ejecutarán de forma local (por ejemplo, desde file://) en las últimas versiones del navegador. En su lugar, fallan de forma automática. Para ejecutar tu aplicación desde el esquema file://, ejecuta Chrome con el conjunto de marcadores --allow-file-access-from-files. NOTA: no es recomendable ejecutar tu navegador principal con este conjunto de marcadores, pues solo se debe utilizar para realizar pruebas y no para navegar con normalidad.

Otros navegadores no aplican esta restricción.

Consideraciones sobre un mismo origen

Las secuencias de comandos del Worker deben ser archivos externos con el mismo esquema que su página de llamada. Por ello, no puedes cargar una secuencia de comandos desde una URL data: o una URL javascript:. Asimismo, una página https: no puede iniciar secuencias de comandos de Worker que comiencen con una URL http:.

Casos prácticos

Entonces, ¿qué tipo de aplicación deben utilizar los Web Workers? Desafortunadamente, los Web Workers aún son tecnologías relativamente nuevas y la mayoría de los ejemplos y tutoriales que existen están relacionados con la computación de números primos. Aunque no resulta demasiado interesante, es útil para entender los conceptos básicos de los Web Workers. A continuación se indican algunas ideas para que mantener tu cerebro despierto.

  • Obtención previa y/o almacenamiento en caché de datos para un uso futuro
  • Métodos para destacar la sintaxis de código u otros formatos de texto en tiempo real
  • Corrector ortográfico
  • Análisis de datos de vídeo o audio
  • Entrada y salida en segundo plano o solicitud de servicios web
  • Procesamiento de conjuntos o respuestas JSON de gran tamaño
  • Filtrado de imágenes en <canvas>
  • Actualización de varias filas de una base de datos web local

Demos

Referencias

Comments

0