Wextensible

Actualización Noviembre 2012: He actualizado este tema agregando código resaltado y revisando algunas cosas.

Concurrencia en JavaScript con Web Workers

Página no responde La especificación WHATWG Web Workers define una API para ejecutar JavaScript en segundo plano de forma independiente a la ejecución de la interfaz de usuario. Lenguajes de elevadas prestaciones como Java tienen características creadas para usar hilos de ejecución. Se trata de realizar tareas de forma concurrente. JavaScript no posee la característica de la concurrencia y los Web Workers intentan llenar este hueco. Todos hemos tenido la experiencia de ver como el navegador se queda bloqueado en ciertas ocasiones debido a que está ejecutando algo en JavaScript. En la imagen puede ver el mensaje de un navegador cuando una tarea en JavaScript ocupa un tiempo excesivo, dándole la posibilidad al usuario de cancelar esa ejecución. Los Web Workers nos servirán para realizar ejecuciones en segundo plano mientras el usuario sigue interactuando con nuestra página. Sin embargo hay algunas limitaciones que restringuen los casos de uso y es lo que intento aprender en estos temas.

Hay dos tipos de Workers, dedicados y compartidos. Los dedicados se vinculan con el origen del script que los creó. Por ejemplo, si en este documento se crea un Worker dedicado, sólo desde los scripts incluidos en este documento podré acceder a ese Worker. En cambio si es un Worker compartido podremos acceder desde cualquier documento, aunque en todo caso restringindo a los del mismo sitio. Es decir, si creo un worker compartido en este documento y abro otro documento de este sitio en otra ventana, también podré acceder a ese Worker. En este tema y el siguiente sólo expondré los Workers dedicados, los más sencillos y la vez los que tienen mayor soporte.

Antes de seguir veámos como usar un Worker. A continuación tenemos un primer ejemplo. Como el resto de ejemplos en esta página, el código JavaScript está junto a cada ejemplo para que pueda consultarlo simplemente mirando el código fuente de esta página.

Ejemplo:

Enviar esto al worker1
Esta es la repuesta del worker1:

El código de este ejemplo es:

<div class="ejemplo-linea">
    Enviar esto al worker1 <input type="text"  class="codigo"
    value="" /><input type="button" value="Llamar al worker1"
    onclick="worker1.postMessage(this.previousSibling.value);" />
    <div>Esta es la repuesta del worker1:
        <div id="divwk1" style="color: navy"></div>
    </div>
</div>

<script>
    //.............. ESTO ESTÁ EN EL HEAD DE LA PÁGINA ...............
    window.onload = function(){
        //..............
        //Ejemplo del worker1
        if (typeof(Worker)=="undefined"){
            document.getElementById("divwk1").innerHTML = "<b style='color:red'>" +
            "Workers no soportado</b>";
        } else {
            worker1 = new Worker("ejemplos/worker1.js");
            worker1.onmessage = function(event){
                document.getElementById("divwk1").innerHTML = event.data;
            };
        }
        //..............
    };
</script>

El navegador implementará en su caso Worker como un objeto Function. Por lo tanto comprobamos el tipo con typeof para ver si está definido. Creamos un nuevo Worker con la palabra clave new y la ruta del JavaScript, que debe estar en una archivo separado. Con el módulo Worker nos comunicamos mediante el paso de mensajes (una técnica que también sirve para otros contextos como pasar mensajes entre objetos Window). Esto se usa porque los Web Workers no tienen acceso a las variables globales ni al DOM, aparte de otras restricciones. Esto es una medida de seguridad que evita que la ejecución de un JavaScript pueda estar modificando elementos del documento (o variables globales) en segundo plano mientras al mismo tiempo el usuario está interactuando con ellos. Esto tiene su coste como veremos después.

El botón Llamar al Worker1 ejecuta el código worker1.postMessage(...) enviando el valor contenido en el cuadro de texto al Worker. Para recibir mensajes desde el Worker hay que declarar un manejador del evento onmessage. Con worker1.onmessage = function(event){...} establecemos que haremos cuando recibamos un mensaje del Worker. En este ejemplo tomamos un dato que viene en event.data y lo ponemos en un elemento <div>. El código del Worker ubicado en ejemplos/worker1.js es muy simple:

self.onmessage = function(event){
    var ahora = new Date();
    self.postMessage("Hola Mundo (" + ahora + "). Esto se genera en el " +
    "<b>worker1</b> declarado en el archivo " +
    "<a href='ejemplos/worker1.js'>worker1.js</a>. " +
    "He recibido este dato: <code>" + event.data + "</code>");
}

Simplemente volvemos a declarar un manejador del evento onmessage que manejará los mensajes recibidos en el Worker. En este ejemplo devolverá otro mensaje con self.postMessage(...). La palabra clave self hace referencia al propio Worker y puede ser obviada.

Los manejadores de eventos están especificados en W3C eventTarget. En la documentación de Mozilla también puede ver MDN element.addEventListener. Aconsejan usar addEventListener() para declarar el manejador, pues tiene varias ventajas que no voy a examinar ahora. El siguiente ejemplo es igual que el anterior pero usando este manejador.

Ejemplo:

Enviar esto al worker2
Esta es la repuesta del worker2:

El script del Worker lo puede ver en ejemplos/worker2.js. El código del ejemplo con lo imprescindible es este:

<div class="ejemplo-linea">
    Enviar esto al worker2 <input type="text"  class="codigo"
    value="" /><input type="button" value="Llamar al worker2"
    onclick="worker2.postMessage(this.previousSibling.value);" />
    <div>Esta es la repuesta del worker2:
        <div id="divwk2" style="color: navy"></div>
    </div>
</div>

<script>
    //.............. ESTO ESTÁ EN EL HEAD DE LA PÁGINA ...............
    window.onload = function(){
        //..............
        //Ejemplo del worker2
        if (typeof(Worker)=="undefined"){
            document.getElementById("divwk2").innerHTML = "<b style='color:red'>" +
            "Workers no soportado</b>";
        } else {
            worker2 = new Worker("ejemplos/worker2.js");
            worker2.addEventListener("message",
                function(event){
                    document.getElementById("divwk2").innerHTML = event.data;
                },
            false);
        }
        //..............
    };
</script>

Simulando concurrencia en JavaScript

Antes de continuar exponiendo los Web Workers creo que merece la pena explicar como hasta ahora se ha conseguido resolver el problema de la concurrencia en JavaScript. O al menos simularla. Se trata de usar los métodos de manejo de tiempos como setTimeout() o setInterval. Básicamente es algo como esto:

function ejecutame(){
    //hacer algo aquí
    if (noSeguir) return;
    window.setTimeout(ejecutame, 1);
}

La función ejecutame() se activa cada 1 milisegundo de tal forma que la interfaz del navegador puede consultar las peticiones del usuario entre cada corte. Eso permite ver por ejemplo si la variable noSeguir ha sido modificada y por lo tanto detendríamos el bucle. El ejemplo a continuación hace algo como esto (el JavaScript está en esta misma página):

Ejemplo:

Bucle con simulación de concurrencia mediante setTimeout()
Número:

Código:

<div class="ejemplo-linea">
    Bucle con simulación de concurrencia mediante <code>setTimeout()</code><br />
    <input type="button" value="ejecutar"
    onclick="buclear(this)" />
    <div>Número:<code id="numfc"></code></div>
</div>

<script>
    //.............. ESTO ESTÁ EN EL HEAD DE LA PÁGINA ...............
    window.onload = function(){
        //..............
        //INICIA EJEMPLO APARTADO: Simulando concurrencia en JavaScript
        numfc = document.getElementById("numfc");
        //..............
    };
    //--------------------------------------------------------------------------------
    //FUNCIONES EJEMPLO APARTADO: Simulando concurrencia en JavaScript
    //--------------------------------------------------------------------------------
    function ejecutarBucleFC(){
        if (noSeguir) return;
        iteracion++;
        numfc.innerHTML = iteracion;
        window.setTimeout(ejecutarBucleFC, 1);
    }
    function buclear(este){
        if (este.value=="parar"){
            este.value = "ejecutar";
            noSeguir = true;
        } else {
            este.value = "parar";
            noSeguir = false;
            ejecutarBucleFC();
        }
    }
</script>

Esta técnica es la que he usado en cómo se hace un reloj con Canvas y en el generador de fractales iterativos para volcar los puntos en el canvas. Aunque no se trata de concurrencia, si es algo parecido pues hacemos una desconexión cada cierto tiempo para ejecutar una tarea. Esto funciona pero no es lo más apropiado puesto que realmente la tarea no se está ejecutando en segundo plano.

Pasando y recibiendo datos en el Web Worker

Los Workers usan la técnica del paso de mensajes para enviar y recibir datos. Para comunicar un único dato sólo basta poner postMessage(dato). Pero si queremos tratar con varios datos y tipos podemos usar la técnica JSON. Con postMessage({"var1":"abc","var2":123}) tendríamos en el destino las variables disponibles en event.data.var1 y event.data.var2, cada una con el tipo con el que fue enviada. Veámos este ejemplo:

Ejemplo:

Pasaremos al array unos valores de prueba de cada uno de los tipos JavaScript:
  • string abcdef.
  • number 123456.
  • boolean true.
  • object new Date()
  • array ["abcdef", 123456, true, Date].
Haremos que el Worker los reciba y los los devuelva.
Esta es la repuesta del worker4:
El último tipo Object es un tipo Array. Al final todo en JavaScript son objetos. En rojo se verán los valores recibidos desde el Worker así como los tipos reales de esos valores, coincidiendo con los que se enviaron.

El script del Worker lo puede ver en ejemplos/worker4.js. El código del ejemplo con lo imprescindible es este:

Los datos son clonados, es decir, se remita una copia de los datos en lugar de una referencia a la variable. Recuerde que en JavaScript los argumentos de una función siempre se pasan por valor, pues no permite el paso por referencia (ver mi artículo Paso de argumentos a funciones en JavaScript). Ahora no se trata exactamente de pasar argumentos al Worker, sino que éste no puede por motivos de integridad en la concurrencia acceder a variables globales. Esto no deja de ser un limitación importante, puesto que si el dato es una estructura muy larga, el tiempo de realizar una copia puede afectar a la ejecución. En el próximo tema veremos como podemos mejorar la comunicación con el Worker.

Para qué sirve un Web Worker

Cuando una ejecución de JavaScript toma mucho tiempo los navegadores avisan al usuario dándole la posibilidad de cancelar o continuar con la tarea. Entretanto la interfaz del usuario se bloquea totalmente. Resulta algo confuso para el usuario no saber si hay algo bloqueado o se está ejecutando lo que solicitó. Con el Worker podemos solucionar esto. El siguiente ejemplo ejecuta un bucle de 100 millones de iteraciones. Cada 10 mil iteraciones actualiza un elemento de la página mostrando ese número. Esto se hace también con JavaScript pero realmente no actualiza nada, pero sí lo hace con Worker.

Ejemplo:

Número de iteraciones para el bucle
Mientras se ejecuta este bucle no podrá interactuar con el documento (por ejemplo con el botón "Ver fecha-hora").
Iteración del bucle: (Se actualizará sólo al final del bucle)
Mensaje del NO worker:
NOTA: Opera 11.62 sí permite cierta interacción con la interfaz del navegador, por lo que irá actualizando el número de la iteración. De hecho parece ser que Opera es un navegador multi-hilo (multi-thread). Sin embargo siempre se dice que JavaScript no permite esta técnica, razón por la cual se implementan los Workers. En fin, este aspecto no lo tengo muy claro.

Actualizar iteración
Mientras se ejecuta este bucle SÍ podrá interactuar (por ejemplo con el botón "Ver fecha-hora").
Iteración del bucle: (Este número se va actualizando cada 0 veces desde el worker si está activada la opción actualizar iteración)
Mensaje del worker:

El script del Worker lo puede ver en ejemplos/worker3.js. El código del ejemplo con lo imprescindible es este:

Al probar el ejemplo vemos que con Worker hay una evidencia de que se está ejecutando algo en segundo plano. Además el resto de botones de la página siguen funcionando. Indudablemente esto es concurrencia, o se le parece mucho, pues alguna desventaja como el coste del paso de mensajes puede limitar los casos de uso. Con Chrome 18.0 el bucle sin Worker tarda unos 4 segundos y con Worker unos 11 segundos con la opción de ir actualizando el número de iteración. Sin esta opción sólo tarda unos 0.6 segundos. El motivo es que pasar mensajes, en este caso desde el Worker a la página, tiene un coste de tiempo. En el siguiente tema veremos algo más sobre esto y las soluciones para evitarlo.

Por eso la especificación nos aclara que los Workers son para

Bucle Dual Core Evidentemente que ejecutar un bucle de 100 millones es siempre preferible hacerlo con un Worker, pues cumple todas las condiciones anteriores. Pero aún así no podemos abusar pues el Worker con mensajes consume más recursos que una ejecución de JavaScript sin Worker. La imagen adjunta es la del visor de rendimiento de un ordenador con Intel Pentium 4, dual cuore, CPU 3.2GHz, RAM 1.5GB y Windows XP. He ejecutado las tres alternativas del ejemplo anterior que se corresponden con los tres picos señalados, el primero es de la ejecución del bucle sin Worker. El segundo con Worker y paso de mensajes. El último es sin el paso de mensajes. Vemos que esta opción es la que menos recursos consume, tanto en tiempo como en uso de las CPU. Por lo tanto lo óptimo serían tareas largas, que no necesiten comunicarse entretanto con el documento y que si las ejecutáramos en primer plano bloquearían la interfaz del usuario.

APIs disponibles para Web Worker

El Worker no puede acceder a las variables globales de JavaScript. Tampoco puede acceder al DOM. Por lo tanto para para comunicarnos con el documento hemos de usar el paso de mensajes. A modo de resumen lo que podemos hacer con un Worker es usar lo siguiente: