Noções básicas sobre Web Workers

O problema: simultaneidade do JavaScript

Há vários gargalos que impedem a portabilidade de aplicativos interessantes (por exemplo, de implementações pesadas de servidor) para o JavaScript do lado do cliente. Alguns deles incluem compatibilidade do navegador, digitação estática, acessibilidade e desempenho. Felizmente, esse último cenário está se tornando rapidamente coisa do passado, já que os fornecedores de navegador melhoram rapidamente a velocidade dos mecanismos JavaScript.

Na verdade, uma coisa que permaneceu como um obstáculo para o JavaScript é a própria linguagem. O JavaScript é um ambiente com uma única linha de execução, ou seja, não é possível executar vários scripts ao mesmo tempo. Por exemplo, imagine um site que precisa lidar com eventos de interface, consultar e processar grandes quantidades de dados da API e manipular o DOM. Bem comum, certo? Infelizmente, nem tudo isso pode ser simultâneo, devido a limitações no tempo de execução do JavaScript dos navegadores. A execução do script ocorre dentro de uma única linha de execução.

Os desenvolvedores imitam a "simultaneidade" usando técnicas como setTimeout(), setInterval(), XMLHttpRequest e manipuladores de eventos. Sim, todos esses recursos são executados de forma assíncrona, mas não bloquear isso não significa necessariamente simultaneidade. Eventos assíncronos são processados depois que o script em execução atual gerar o resultado. A boa notícia é que o HTML5 oferece algo melhor do que essas dicas!

Introdução ao Web Workers: trazer as linhas de execução para o JavaScript

A especificação Web Workers define uma API para gerar scripts em segundo plano no seu aplicativo da Web. O Web Workers permite que você realize ações como iniciar scripts de longa duração para lidar com tarefas que consomem muitos recursos computacionais, mas sem bloquear a interface ou outros scripts para processar as interações do usuário. Eles vão ajudar a acabar com aquele diálogo de "script não responsivo" que todos nós adoramos:

Caixa de diálogo com um script que não responde
Caixa de diálogo de script sem resposta comum.

Os workers utilizam a transmissão de mensagens do tipo thread para conseguir paralelismo. Eles são perfeitos para manter a interface atualizada, com desempenho e responsiva para os usuários.

Tipos de Web Workers

É importante notar que a especificação discute dois tipos de Web Workers: Workers dedicados e Compartilhados. Este artigo abordará apenas workers dedicados. Chamarei eles de "web workers" ou "workers" o tempo todo.

Como começar

Os Web Workers são executados em uma linha de execução isolada. Como resultado, o código que eles executam precisa estar contido em um arquivo separado. Mas, antes de fazer isso, a primeira coisa a fazer é criar um novo objeto Worker na página principal. O construtor usa o nome do script do worker:

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

Se o arquivo especificado existir, o navegador gerará uma nova linha de execução de worker, que será transferida por download de forma assíncrona. O worker não será iniciado até que o arquivo tenha sido totalmente transferido por download e executado. Se o caminho para o worker retornar um 404, ele falhará silenciosamente.

Depois de criar o worker, inicie-o chamando o método postMessage():

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

Comunicação com um worker por transmissão de mensagens

A comunicação entre um trabalho e a página principal é feita por meio de um modelo de evento e o método postMessage(). Dependendo do navegador/versão, o postMessage() pode aceitar uma string ou um objeto JSON como argumento único. As versões mais recentes dos navegadores mais recentes suportam a transmissão de um objeto JSON.

Confira abaixo um exemplo de como usar uma string para transmitir "Hello World" a um worker em doWork.js. O worker simplesmente retorna a mensagem que foi transmitida a ele.

Script 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 (o worker):

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

Quando postMessage() é chamado na página principal, nosso worker processa essa mensagem definindo um gerenciador onmessage para o evento message. O payload da mensagem (neste caso, "Hello World") pode ser acessado em Event.data. Embora esse exemplo específico não seja muito emocionante, ele demonstra que postMessage() também é um meio para transmitir dados de volta à linha de execução principal. Praticidade!

As mensagens transmitidas entre a página principal e os workers são copiadas e não compartilhadas. Por exemplo, no próximo exemplo, a propriedade "msg" da mensagem JSON pode ser acessada nos dois locais. Parece que o objeto está sendo transmitido diretamente ao worker, mesmo que esteja sendo executado em um espaço separado e dedicado. Na verdade, o que acontece é que o objeto está sendo serializado à medida que é entregue ao worker e, em seguida, desserializado na outra extremidade. A página e o worker não compartilham a mesma instância, então o resultado final é a criação de uma cópia em cada cartão. A maioria dos navegadores implementa esse recurso pela codificação/decodificação JSON automática do valor em uma das extremidades.

Confira a seguir um exemplo mais complexo que transmite mensagens usando objetos JSON.

Script 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 transferíveis

A maioria dos navegadores implementa o algoritmo de clonagem estruturada, que permite que você transmita tipos mais complexos de entrada/saída de workers, como File, Blob, ArrayBuffer e objetos JSON. No entanto, ao transmitir esses tipos de dados usando postMessage(), uma cópia ainda é feita. Portanto, se você estiver transmitindo um arquivo grande de 50 MB (por exemplo), vai haver uma sobrecarga perceptível para transferir esse arquivo entre o worker e a linha de execução principal.

A clonagem estruturada é ótima, mas uma cópia pode levar centenas de milissegundos. Para combater o hit do desempenho, use Objetos transferíveis.

Com os objetos transferíveis, os dados são transferidos de um contexto para outro. É zero cópia, o que melhora muito o desempenho do envio de dados para um worker. Pense nisso como uma passagem de referência se você estiver no mundo C/C++. No entanto, ao contrário da passagem por referência, a "versão" do contexto de chamada não fica mais disponível após a transferência para o novo contexto. Por exemplo, ao transferir um ArrayBuffer do seu app principal para o Worker, o ArrayBuffer original é apagado e não pode mais ser usado. O conteúdo é transferido (literalmente) para o contexto do worker.

Para usar objetos transferíveis, use uma assinatura um pouco diferente de postMessage():

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

No caso do worker, o primeiro argumento são os dados e o segundo é a lista de itens que precisam ser transferidos. O primeiro argumento não precisa ser um ArrayBuffer. Por exemplo, pode ser um objeto JSON:

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

O ponto importante é que o segundo argumento precisa ser uma matriz de ArrayBuffers. Esta é sua lista de itens transferíveis.

Para ver mais informações sobre transferíveis, consulte nossa postagem em developer.chrome.com.

O ambiente do worker

Escopo do worker

No contexto de um worker, self e this fazem referência ao escopo global do worker. Assim, o exemplo anterior também poderia ser escrito da seguinte forma:

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

Como alternativa, você pode definir o manipulador de eventos onmessage diretamente, embora addEventListener seja sempre recomendado pelos ninjas do JavaScript.

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

Recursos disponíveis para workers

Devido ao comportamento multithread, os Web Workers só têm acesso a um subconjunto de recursos do JavaScript:

  • O objeto navigator
  • O objeto location (somente leitura)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() e setInterval()/clearInterval()
  • O cache do aplicativo
  • Importação de scripts externos usando o método importScripts()
  • Despertar outros web workers

Os workers NÃO têm acesso a:

  • O DOM (não é seguro para linhas de execução)
  • O objeto window
  • O objeto document
  • O objeto parent

Carregar scripts externos

É possível carregar arquivos de script ou bibliotecas em um worker com a função importScripts(). O método usa zero ou mais strings que representam os nomes de arquivos dos recursos a serem importados.

Neste exemplo, script1.js e script2.js são carregados no worker:

worker.js:

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

Que também pode ser escrita como uma única instrução de importação:

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

Subtrabalhadores

Os workers têm a capacidade de gerar workers filhos. Isso é ótimo para dividir ainda mais tarefas grandes no tempo de execução. No entanto, os subworkers têm algumas ressalvas:

  • Os subworkers precisam estar hospedados na mesma origem que a página pai.
  • Os URIs dos subworkers são resolvidos em relação ao local do worker pai (em oposição à página principal).

A maioria dos navegadores gera processos separados para cada worker. Antes de gerar uma fazenda de trabalhadores, tenha cuidado para não consumir muitos recursos do sistema do usuário. Um motivo para isso é que as mensagens transmitidas entre as páginas principais e os workers são copiadas e não compartilhadas. Consulte Como se comunicar com um worker por transmissão de mensagem.

Para uma amostra de como gerar um subworker, consulte o exemplo na especificação.

Workers inline

E se você quiser criar um script de worker rapidamente ou uma página independente sem ter que criar arquivos de worker separados? Com Blob(), é possível "in-line" o worker no mesmo arquivo HTML que sua lógica principal, criando um identificador de URL para o código do worker como uma string:

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 do Blob

A mágica acontece com a chamada para window.URL.createObjectURL(). Esse método cria uma string de URL simples que pode ser usada para referenciar dados armazenados em um objeto DOM File ou Blob. Exemplo:

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

Os URLs do blob são exclusivos e duram por todo o ciclo de vida do aplicativo (por exemplo, até que o document seja descarregado). Se você estiver criando muitos URLs do Blob, é recomendável liberar as referências que não são mais necessárias. É possível liberar explicitamente URLs do Blob passando-o para window.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

No Chrome, há uma página boa para visualizar todos os URLs de blob criados: chrome://blob-internals/.

Exemplo completo

Indo mais além, podemos aprimorar a forma como o código JS do worker é embutido na nossa página. Essa técnica usa uma tag <script> para definir o 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 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>

Em minha opinião, essa nova abordagem é um pouco mais limpa e mais legível. Ele define uma tag de script com id="worker1" e type='javascript/worker' para que o navegador não analise o JS. Esse código é extraído como uma string usando document.querySelector('#worker1').textContent e transmitido para Blob() para criar o arquivo.

Carregar scripts externos

Ao usar essas técnicas para incorporar o código do worker, o importScripts() só funcionará se você fornecer um URI absoluto. Se você tentar transmitir um URI relativo, o navegador vai informar um erro de segurança. O motivo disso é que o worker (agora criado de um URL de blob) é resolvido com um prefixo blob:, enquanto o app é executado em um esquema diferente (provavelmente http://). Portanto, a falha será devida a restrições de origem cruzada.

Uma maneira de utilizar o importScripts() em um worker inline é "injetar" o URL atual do script principal em execução transmitindo-o para o worker inline e criando o URL absoluto manualmente. Isso garante que o script externo seja importado da mesma origem. Supondo que o app principal seja executado em 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>

tratamento de erros

Como em qualquer lógica JavaScript, é necessário tratar todos os erros gerados nos web workers. Se ocorrer um erro enquanto um worker estiver em execução, uma ErrorEvent será disparada. A interface contém três propriedades úteis para descobrir o que deu errado: filename, o nome do script do worker que causou o erro, lineno, o número da linha em que o erro ocorreu e message, uma descrição significativa do erro. Este é um exemplo de como configurar um manipulador de eventos onerror para mostrar as propriedades do erro:

<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>

Exemplo: workerWithError.js tenta executar 1/x, em que x está indefinido.

// TODO: DevSite - O exemplo de código foi removido porque usava manipuladores de eventos inline

workerWithError.js:

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

Informações sobre segurança

Restrições de acesso local

Devido às restrições de segurança do Google Chrome, os workers não serão executados localmente (por exemplo, em file://) nas versões mais recentes do navegador. Em vez disso, elas falham silenciosamente. Para executar o app no esquema file://, execute o Chrome com a flag --allow-file-access-from-files definida.

Outros navegadores não impõem a mesma restrição.

Considerações sobre a mesma origem

Os scripts de worker precisam ser arquivos externos com o mesmo esquema da página de chamada. Portanto, não é possível carregar um script de um URL data: ou javascript:, e uma página https: não pode iniciar scripts de worker que começam com URLs http:.

Casos de uso

Então, que tipo de aplicativo utilizaria os web workers? Confira mais algumas ideias para você quebrar a mente:

  • Pré-busca e/ou armazenamento em cache de dados para uso posterior.
  • Destaque de sintaxe de código ou outra formatação de texto em tempo real.
  • corretor ortográfico.
  • Analisando dados de vídeo ou áudio.
  • E/S em segundo plano ou sondagem de serviços da Web.
  • Processamento de matrizes grandes ou respostas JSON enormes.
  • Filtragem de imagens em <canvas>.
  • Atualizar muitas linhas de um banco de dados da Web local.

Para mais informações sobre casos de uso que envolvem a API Web Workers, visite Visão geral dos workers.

Demonstrações

Referências