JavaScript Multi threading com Web Workers
Dando continuidade à nossa coleção de artigos sobre JavaScript Assíncrono, vamos falar sobre Web Workers.
O que são e para que servem?
Os Web Workers funcionam como threads, um processamento em background.
Podemos usá-los para executar tarefas extensas como ordenar uma lista muito grande, processar pontos de um mapa, criptografia, etc.
Cada Web Worker funcionará "fora" do escopo do browser, ou seja, em background, de forma totalmente independente da sua página web.
Limitações
Como citado no trecho acima, temos algumas limitações evidentes. O Web Worker não pode, por exemplo, acessar o DOM da sua página, ou fazer qualquer alteração no HTML Document.
Vantagens
A principal vantagem é que o Web Worker é assíncrono e não bloqueante. Ou seja, podemos processar algo que normalmente levaria muito tempo e que impediria a interação do usuário em nossa página por um tempo, sem que o usuário fique limitado de forma alguma.
Veja na prática
Como sempre, gosto de disponibilizar uma forma para que você possa visualizar na prática o tema. Por isso bolei essa ferramenta/demo para deixar bem claro sobre o que estou falando aqui! O código está no github e você pode vê-la em funcionamento aqui:
Criando um problema
Vamos criar um problema para resolvermos com Web Workers.
Que tal esta função que nada mais é que um loop bem longo com uma fórmula irrelevante (apenas para fins educacionais).
function expensiveFunction (len) { var i= 0, l= 1000000000 + len, r= 0; console.log('starting'); for (; i<=l; i++) { r = r + l - i; // some random calculation here :p } console.log('ending', r); return r; }
Crie um arquivo index.html em algum lugar, coloque esta função em um script nele e levante um servidor http (digamos com o pacote node http-server). Se executarmos expensiveFunction
no console, veremos que é uma função que leva alguns segundos para finalizar. Mas o curioso aqui - como pode ser visualizado na ferramenta que fiz e citei no item acima - é que, ao clicarmos no botão "Run", toda a interface "congela" para o usuário. Entendendo o problema O que acontece é que o browser tem uma única thread para lidar com tudo o que acontece na sua página, a main thread. No Google Chrome, temos uma para cada aba, enquanto outros navegadores tem apenas uma para todo o browser. Isto quer dizer que é esta mesma thread que precisará processar tudo na sua página, incluindo interações com teclado, mouse ou gestos e toques, escrita, animações em CSS ou até mesmo Gifs animados! Assim que clicamos o botão "Run" da ferramenta, note que não podemos digitar no textarea, que a animação em CSS fica parada e o gif animado (do querido BB8 de Star Wars) também congela! O pior é que são muitos os sites em que vemos isso acontecendo. Criando uma solução Agora crie um arquivo chamado "ww-calc.js". Este será nosso Web Worker. Já no seu arquivo index.html, acrescente: var worker = new Worker('ww-calc.js'); worker.onmessage = function(event) { console.log("Recebemos um retorno do worker", event.data); }; worker.onerror = function(e) { console.error(`Error: Line ${e.lineno} in ${e.filename}: ${e.message}`); }; //start the worker worker.postMessage({ command: 'calculate' });
Vamos entender o que estamos fazendo aqui. Primeiro, instanciamos um novo worker. Então, definimos o que deve acontecer quando chegar uma mensagem dele (onmessage
), e definimos também o que acontece em caso de erro (onerror
). E por fim, enviamos uma mensagem para nosso worker com o objeto { command: 'calculate' }
. Note que poderíamos enviar esta mensagem com qualquer coisa que quiséssemos, estamos simplesmente adotando como padrão, enviar este objeto com a propriedade "command". Vamos...promisificar isto, que tal? function WWExpensiveFunction () { return new Promise((resolve, reject)=>{ var worker = new Worker('ww-calc.js'); worker.onmessage = function(event) { console.log(event.data.result); resolve(event.data); }; worker.onerror = function(e) { console.error(`Error: Line ${e.lineno} in ${e.filename}: ${e.message}`); reject(e); }; //start the worker worker.postMessage({ command: 'calculate' }); }) }
Pronto, criamos uma função WWExpensiveFunction
, que nos retorna uma Promise. Esta promise será resolvida assim que obtivermos um retorno do worker ou rejeitada em caso de algum erro. Até aqui, tudo bem. Só que nada acontecerá, estamos simplesmente enviando um objeto (por meio de postMessage
) para um novo worker, e esperando por uma resposta que jamais chegará. Hora de escrevermos o worker, em si. Abra o ww-calc.js
. O objeto global self
, no escopo de um worker, é o próprio escopo (não existe um objeto window, por exemplo). Vamos criar um arquivo chamado ww-calc-function.js
e colar a nossa função lá dentro: function expensiveFunction (len=90) { var i= 0, l= 1000000000 + len, r= 0; console.log('starting'); for (; i<=l; i++) { r = r + l - i; // some random calculation here :p } console.log('ending', r); return r; }
Já no nosso ww-calc.js
, nós importaremos este arquivo com a função utilizando o importScripts
. O importScripts
pre-carregará (sincronamente) os scripts passados a ele e aceita uma lista de argumentos, como importScripts('a.js', 'b.js', 'c.js')
. O código em ww-calc.js
ficará assim: importScripts('ww-calc-function.js'); self.onmessage = event => { switch (event.data.command) { case 'calculate': { let result = expensiveFunction(); self.postMessage({ result }); close(); break; } } }
Após importarmos o script com a função propriamente dita, este worker ficará esperando por uma mensagem. Lembram do nosso objeto com a propriedade command
? Estamos usando um switch/case
aqui para ilustrar como você faria para ter diferentes comandos. Assim que ele receber "a ordem" da página por meio de uma mensagem com o comando "calculate", ele executará nossa função e enviará o resultado de volta para o cliente (novamente usando postMessage) assim que ficar pronto. Notou o close()
ali? Em nosso cliente (a página web, no browser), temos uma instância de um worker, ainda esperando por novos comandos. É decisão nossa "matá-lo", ou deixar ali, esperando por novas mensagens com comandos (ou ser eliminado, caso a página simplesmente não o referencie mais de forma alguma). Neste nosso caso, criamos um "worker descartável", pois após receber um comando e executá-lo, ele se fechará não escutando por novas ordens. Gerenciar isto vai te permitir, por exemplo, usar um worker para processar algo muito extenso, em partes. Você pode fazer isso recebendo mensagens a cada parte concluída da tarefa, pelo worker, fechando-o apenas quando toda a tarefa for concluída. Neste caso, use myWorkerInstance.terminate()
para finalizá-lo "a força" se precisar. Feature detection É importante usarmos uma feature detection para evitarmos erros em navegadores que não ofereçam suporte: if (window.Worker) { // ... }
Mensagens muito grandes Digamos que você esteja usando um worker para enviar os dados de uma imagem em um canvas, para que seus bytes sejam processados em um worker. Esta lista de bytes pode ser muito grande. Podemos então "transferir a responsabilidade" sobre esta lista de bytes, ou transfer ownership. Ao enviarmos este objeto por meio do postMessage, enviamos também a referência desse objeto para o worker. Isso terá dois efeitos: O worker receberá o objeto por referência com muito melhor performance. O cliente (a página que enviou o objeto) perde o poder sobre esse objeto. Sim, perde a referência do mesmo. Vamos simular isto criando um "arquivo" de 5MB na memória: // criando o arquivo de 5MB. var uInt8Array = new Uint8Array(1024*1024*5); // 5MB for (var i = 0; i < uInt8Array.length; ++i) { // preenchendo cada byte deste arquivo uInt8Array[i] = i; } // e aqui, enviamos este buffer para o worker, dando a ele o poder total sobre este objeto, por referência worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);
Concluindo Agora, quando alguém te disser que "JavaScript não é multi-thread", manda este link pra ele ;) Se este artigo foi útil, não deixe de curtir e deixar um comentário :) Seu feedback é fundamental pra nós. No próximo artigo, abordarei um uso um pouco mais complexo, mais aprofundado do tema. Mas por ora, aproveite para experimentar e usar a criatividade com o que vimos até aqui e, claro, não esqueça de deixar um comentário sobre sua opinião!