WebVR Parte 2: Web Workers e computação de borda do navegador

Publicados: 2022-03-11

Nosso simulador de astrofísica é alimentado por uma potente mistura de combustível de foguete de esperança, hype e acesso a um novo poder de computação.

Podemos acessar esse poder de computação com web workers . Se você já estiver familiarizado com web workers, talvez queira grocar o código e avançar para o WebAssembly, que será discutido no próximo artigo.

JavaScript tornou-se a linguagem de programação mais instalada, aprendida e acessível porque trouxe alguns recursos incrivelmente úteis para a web estática:

  • Loop de evento de thread único
  • Código assíncrono
  • Coleta de lixo
  • Dados sem digitação rígida

Single-thread significa que não precisamos nos preocupar muito com a complexidade e as armadilhas da programação multithread.

Assíncrono significa que podemos passar funções como parâmetros a serem executados posteriormente - como eventos no loop de eventos.

Esses recursos e o investimento maciço do Google no desempenho do mecanismo JavaScript V8 do Chrome, juntamente com boas ferramentas de desenvolvedor, tornaram o JavaScript e o Node.js a escolha perfeita para arquiteturas de microsserviços.

A execução de thread único também é ótima para fabricantes de navegadores que precisam isolar e executar com segurança todos os seus tempos de execução de guias do navegador infestados de spyware nos vários núcleos de um computador.

Descrição : Como uma guia do navegador pode acessar todos os núcleos de CPU do seu computador?
Resposta: Trabalhadores da Web!

Trabalhadores da Web e Threading

Trabalhadores da Web usam o loop de eventos para passar mensagens de forma assíncrona entre threads, ignorando muitas das armadilhas potenciais da programação multithread.

Os web workers também podem ser usados ​​para mover a computação para fora do thread principal da interface do usuário. Isso permite que o thread de UI principal manipule cliques, animações e gerencie o DOM.

Vejamos alguns códigos do repositório GitHub do projeto.

Se você se lembra do nosso diagrama de arquitetura, delegamos toda a simulação ao nBodySimulator para que ele gerencie o web worker.

Diagrama de arquitetura

Se você se lembra do post de introdução, nBodySimulator tem uma função step() chamada a cada 33ms da simulação. Ele chama calculateForces() , então atualiza as posições e redesenha.

 // Methods from class nBodySimulator /** * The simulation loop */ start() { // This is the simulation loop. step() calls visualize() const step = this.step.bind(this) setInterval(step, this.simulationSpeed) } /** * A step in the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) } // Remove any "debris" that has traveled out of bounds - this is for the button this.trimDebris() // Now Update forces. Reuse old forces if we skipped calculateForces() above this.applyForces() // Ta-dah! this.visualize() }

A contribuição do web worker é hospedar um thread separado para o WebAssembly. Como uma linguagem de baixo nível, o WebAssembly só entende inteiros e floats. Não podemos passar Strings ou Objetos JavaScript - apenas ponteiros para “memória linear”. Então, por conveniência, empacotamos nossos “corpos” em uma série de floats: arrBodies .

Voltaremos a isso em nosso artigo sobre WebAssembly e AssemblyScript.

Movendo dados para dentro/fora do web worker
Movendo dados para dentro/fora do web worker

Aqui, estamos criando um web worker para executar o calculateForces() em um thread separado. Isso acontece abaixo quando empacotamos os corpos (x, y, z, mass) em uma matriz de floats arrBodies e, em seguida, this.worker.postMessage() para o trabalhador. Retornamos uma promessa que o trabalhador resolverá posteriormente em this.worker.onMessage() .

 // src/nBodySimulator.js /** * Use our web worker to calculate the forces to apply on our bodies. */ calculateForces() { this.workerCalculating = true this.arrBodies = [] // Copy data to array into this.arrBodies ... // return promise that worker.onmessage will fulfill const ret = new Promise((resolve, reject) => { this.forcesResolve = resolve this.forcesReject = reject }) // postMessage() to worker to start calculation // Execution continues in workerWasm.js worker.onmessage() this.worker.postMessage({ purpose: 'nBodyForces', arrBodies: this.arrBodies, }) // Return promise for completion // Promise is resolve()d in this.worker.onmessage() below. // Once resolved, execution continues in step() above - await this.calculateForces() return ret }

Do topo, o index.html do navegador GET que executa main.js que cria um new nBodySimulator() e em seu construtor encontramos setupWebWorker() .

n-body-wasm-canvas

 // nBodySimulator.js /** * Our n-body system simulator */ export class nBodySimulator { constructor() { this.setupWebWorker() ...

Nosso new nBodySimulator() reside no thread principal da interface do usuário, e setupWebWorker() cria o web worker buscando workerWasm.js da rede.

 // nBodySimulator.js // Main UI thread - Class nBodySimulator method setupWebWorker() { // Create a web worker (separate thread) that we'll pass the WebAssembly module to. this.worker = new Worker("workerWasm.js"); // Console errors from workerWasm.js this.worker.onerror = function (evt) { console.log(`Error from web worker: ${evt.message}`); } ...

Em new Worker() , o navegador busca e executa workerWasm.js em um tempo de execução JavaScript separado (e thread) e começa a transmitir mensagens.

Então, workerWasm.js entra no cerne do WebAssembly, mas na verdade é apenas uma única função this.onmessage() contendo uma instrução switch() .

Lembre-se de que os web workers não podem acessar a rede, portanto, o thread de interface do usuário principal deve passar o código WebAssembly compilado para o web worker como uma mensagem resolve("action packed") . Vamos nos aprofundar nisso no próximo post.

 // workerWasm.js - runs in a new, isolated web worker runtime (and thread) this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. ... // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return // Message: Given array of floats describing a system of bodies (x, y, z, mass), // calculate the Grav forces to be applied to each body case 'nBodyForces': ... // Do the calculations in this web worker thread synchronously const resultRef = wasm.nBodyForces(dataRef); ... // See nBodySimulation.js' this.worker.onmessage return this.postMessage({ purpose: 'nBodyForces', arrForces }) } }

Voltando ao método setupWebWorker() de nossa classe nBodySimulation , ouvimos as mensagens do web worker usando o mesmo onmessage() + switch() .

 // Continuing class nBodySimulator's setupWebWorker() in the main UI thread // Listen for messages from workerWasm.js postMessage() const self = this this.worker.onmessage = function (evt) { if (evt && evt.data) { // Messages are dispatched by purpose const msg = evt.data switch (msg.purpose) { // Worker's reply that it has loaded the wasm module we compiled and sent. Let the magic begin! // See postmessage at the bottom of this function. case 'wasmReady': self.workerReady = true break // wasm has computed forces for us // Response to postMessage() in nBodySimulator.calculateForces() above case 'nBodyForces': self.workerCalculating = false // Resolve await this.calculateForces() in step() above if (msg.error) { self.forcesReject(msg.error) } else { self.arrForces = msg.arrForces self.forcesResolve(self.arrForces) } break } } } ...

Neste exemplo, calculateForces() cria e retorna uma promessa salvando resolve() e reject() como self.forcesReject() e self.forcesResolve() .

Dessa forma, worker.onmessage() pode resolver a promessa criada em calculateForces() .

Se você se lembrar da função step() do nosso loop de simulação:

 /** * This is the simulation loop. */ async step() { // Skip calculation if worker not ready. Runs every 33ms (30fps), expect it to skip. if (this.ready()) { await this.calculateForces() } else { console.log(`Skipping calculation: WorkerReady: ${this.workerReady} WorkerCalculating: ${this.workerCalculating}`) }

Isso nos permite pular calculateForces() e reaplicar as forças anteriores se o WebAssembly ainda estiver calculando.

Esta função de etapa é acionada a cada 33ms. Se o web worker não estiver pronto, ele aplica e pinta as forças anteriores. Se o calculateForces() de uma etapa específica funcionar após o início da próxima etapa, a próxima etapa aplicará forças da posição da etapa anterior. Essas forças anteriores são semelhantes o suficiente para parecer “certas” ou acontecem tão rápido que são incompreensíveis para o usuário. Essa compensação aumenta o desempenho percebido - mesmo que não seja recomendado para viagens espaciais humanas reais.

Isso poderia ser melhorado? Sim! Uma alternativa para setInterval para nossa função step é requestAnimationFrame() .

Para o meu propósito, isso é bom o suficiente para explorar Canvas, WebVR e WebAssembly. Se você acredita que algo pode ser adicionado ou trocado, sinta-se à vontade para comentar ou entrar em contato.

Se você estiver procurando por um design de mecanismo de física moderno e completo, confira o Matter.js de código aberto.

E quanto ao WebAssembly?

WebAssembly é um binário portátil que funciona em navegadores e sistemas. O WebAssembly pode ser compilado a partir de muitas linguagens (C/C++/Rust, etc.). Para meu próprio propósito, eu queria experimentar AssemblyScript - uma linguagem baseada em TypeScript, que é uma linguagem baseada em JavaScript, porque é tartarugas até o fim.

O AssemblyScript compila o código TypeScript em um binário de “código de objeto” portátil, para ser compilado “just-in-time” em um novo tempo de execução de alto desempenho chamado Wasm. Ao compilar o TypeScript no binário .wasm , é possível criar um formato de “texto de montagem da web” .wat legível por humanos descrevendo o binário.

A última parte de setupWebWorker() inicia nosso próximo post no WebAssembly e mostra como superar as limitações do web worker no acesso à rede. Buscamos fetch() o arquivo wasm no thread principal da interface do usuário e, em seguida, compilamos "just-in-time" para um módulo wasm nativo. Nós postMessage() esse módulo como uma mensagem para o web worker:

 // completing setupWebWorker() in the main UI thread … // Fetch and compile the wasm module because web workers cannot fetch() WebAssembly.compileStreaming(fetch("assembly/nBodyForces.wasm")) // Send the compiled wasm module to the worker as a message .then(wasmModule => { self.worker.postMessage({ purpose: 'wasmModule', wasmModule }) }); } }

workerWasm.js então instancia esse módulo para que possamos chamar suas funções:

 // wasmWorker.js - web worker onmessage function this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. wasm = loader.instantiate(msg.wasmModule, importObj) // Throws // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return case 'nBodyForces': ... // Do the calculations in this thread synchronously const resultRef = wasm.nBodyForces(dataRef);

É assim que acessamos a funcionalidade do WebAssembly. Se você estiver olhando para o código-fonte não editado, notará que ... é um monte de código de gerenciamento de memória para colocar nossos dados em dataRef e nossos resultados em resultRef . Gerenciamento de memória em JavaScript? Excitante!

Vamos nos aprofundar no WebAssembly e no AssemblyScript com mais detalhes no próximo post.

Limites de execução e memória compartilhada

Há algo mais para falar aqui, que são limites de execução e memória compartilhada.

Quatro cópias dos dados dos nossos Órgãos
Quatro cópias dos dados dos nossos Órgãos

O artigo do WebAssembly é muito tático, então aqui é um bom lugar para falar sobre tempos de execução. JavaScript e WebAssembly são tempos de execução “emulados”. Conforme implementado, toda vez que cruzamos um limite de tempo de execução, estamos fazendo uma cópia de nossos dados de corpo (x, y, z, massa). Embora copiar memória seja barato, esse não é um design maduro de alto desempenho.

Felizmente, muitas pessoas muito inteligentes estão trabalhando na criação de especificações e implementações dessas tecnologias de navegador de ponta.

JavaScript tem SharedArrayBuffer para criar um objeto de memória compartilhada que eliminaria a cópia de postMessage() de (2) -> (3) na chamada e a cópia de onmessage() de arrForces de (3) -> (2) no resultado .

O WebAssembly também possui um design de memória linear que pode hospedar uma memória compartilhada para a chamada nBodyForces() de (3) -> (4). O web worker também pode passar uma memória compartilhada para a matriz de resultados.

Junte-se a nós na próxima vez para uma emocionante jornada no gerenciamento de memória JavaScript.