Use web workers para executar JavaScript fora da thread principal do navegador

Uma arquitetura de linha de execução fora do principal pode melhorar significativamente a confiabilidade e a experiência do usuário do seu app.

Surma
Surma

Nos últimos 20 anos, a Web evoluiu drasticamente de documentos estáticos com alguns estilos e imagens para aplicativos complexos e dinâmicos. No entanto, uma coisa não mudou em grande parte: temos apenas um encadeamento por guia do navegador (com algumas exceções) para fazer o trabalho de renderizar nossos sites e executar nosso JavaScript.

Como resultado, a linha de execução principal ficou incrivelmente sobrecarregada. E, à medida que os apps da Web ficam mais complexos, a linha de execução principal se torna um gargalo significativo para o desempenho. Para piorar, o tempo necessário para executar o código na linha de execução principal de um determinado usuário é quase totalmente imprevisível, porque os recursos do dispositivo têm um efeito enorme sobre o desempenho. Essa imprevisibilidade só aumentará à medida que os usuários acessarem a Web usando um conjunto cada vez mais diverso de dispositivos, de celulares com recursos superrestritos a máquinas principais de alta potência com alta taxa de atualização.

Para apps da Web sofisticados que atendam a diretrizes de desempenho, como as Core Web Vitals, que são baseadas em dados empíricos sobre a percepção humana e a psicologia, precisamos de maneiras de executar nosso código fora da linha de execução principal (OMT).

Por que Web workers?

Por padrão, o JavaScript é uma linguagem de linha de execução única que executa tarefas na linha de execução principal. No entanto, os web workers oferecem um tipo de saída da linha de execução principal, permitindo que os desenvolvedores ativem linhas de execução separadas para gerenciar o trabalho fora da linha de execução principal. Embora o escopo dos web workers seja limitado e não ofereça acesso direto ao DOM, eles podem ser muito benéficos se houver trabalho considerável a ser feito que sobrecarregaria a linha de execução principal.

Em relação às Principais métricas da Web, realizar o trabalho fora da linha de execução principal pode ser benéfico. Em particular, o descarregamento de trabalho da linha de execução principal para os workers da Web pode reduzir a contenção da linha de execução principal, o que pode melhorar métricas de resposta importantes, como Interação com a próxima exibição (INP) e Latência na primeira entrada (FID, na sigla em inglês). Quando a linha de execução principal tem menos trabalho para processar, ela pode responder mais rapidamente às interações do usuário.

Menos trabalho da linha de execução principal, especialmente durante a inicialização, também traz um possível benefício para a Maior exibição de conteúdo (LCP), reduzindo tarefas longas. A renderização de um elemento LCP requer tempo da linha de execução principal, seja para renderizar texto ou imagens, que são elementos LCP frequentes e comuns. Além disso, reduzindo o trabalho da linha de execução principal em geral, você pode garantir que o elemento LCP da sua página tenha menos probabilidade de ser bloqueado por trabalhos caros que um web worker pode processar.

Linhas de execução com web workers

Outras plataformas normalmente são compatíveis com trabalhos paralelos, permitindo atribuir uma função a uma linha de execução, que é executada em paralelo com o restante do programa. Você pode acessar as mesmas variáveis nas duas linhas de execução, e o acesso a esses recursos compartilhados pode ser sincronizado com mutexes e semáforos para evitar disputas.

Em JavaScript, podemos obter funcionalidades semelhantes aos web workers, que existem desde 2007 e compatíveis com os principais navegadores desde 2012. Os Web workers são executados em paralelo com a linha de execução principal, mas, ao contrário do encadeamento do SO, eles não podem compartilhar variáveis.

Para criar um web worker, transmita um arquivo para o construtor do worker, que começa a executar esse arquivo em uma linha de execução separada:

const worker = new Worker("./worker.js");

Comunicação com o worker da Web enviando mensagens pela API postMessage. Transmita o valor da mensagem como um parâmetro na chamada de postMessage e adicione um listener de eventos de mensagem ao worker:

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

Para enviar uma mensagem de volta à linha de execução principal, use a mesma API postMessage no Web worker e configure um listener de eventos na linha de execução principal:

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

É verdade que essa abordagem é um pouco limitada. Historicamente, os web workers foram usados principalmente para mover uma única peça de trabalho pesado da linha de execução principal. Tentar lidar com várias operações com um único web worker fica desordenado rapidamente: é preciso codificar não apenas os parâmetros, mas também a operação na mensagem, além de fazer registros para corresponder as respostas às solicitações. Essa complexidade é provavelmente o motivo pelo qual os web workers não foram adotados de forma mais ampla.

Mas se pudéssemos eliminar um pouco da dificuldade de comunicação entre a linha de execução principal e os web workers, esse modelo poderia ser adequado para muitos casos de uso. E, para nossa sorte, há uma biblioteca que faz exatamente isso!

O Comlink é uma biblioteca que permite usar workers da Web sem se preocupar com os detalhes do postMessage. O Comlink permite compartilhar variáveis entre web workers e a linha de execução principal quase como outras linguagens de programação compatíveis com linhas de execução.

Para configurar o Comlink, importe-o em um web worker e defina um conjunto de funções a serem expostas à linha de execução principal. Em seguida, importe Comlink na linha de execução principal, envolva o worker e tenha acesso às funções expostas:

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

A variável api na linha de execução principal se comporta da mesma forma que a do worker da Web, exceto pelo fato de que cada função retorna uma promessa para um valor, e não o próprio valor.

Qual código você deve mover para um web worker?

Os Web workers não têm acesso ao DOM e a muitas APIs, como WebUSB, WebRTC ou Web Audio. Por isso, não é possível colocar partes do seu app que dependem desse acesso em um worker. Ainda assim, cada pequeno trecho de código movido para um worker aumenta a margem na linha de execução principal para o que precisa estar presente, como atualizar a interface do usuário.

Um problema para os desenvolvedores da Web é que a maioria dos apps da Web depende de um framework de IU, como Vue ou React, para orquestrar tudo no app. Tudo é um componente da estrutura e, portanto, está inerentemente vinculado ao DOM. Isso parece dificultar a migração para uma arquitetura OMT.

No entanto, se mudarmos para um modelo em que as preocupações da IU são separadas de outras questões, como gerenciamento de estado, os web workers podem ser bastante úteis mesmo com aplicativos baseados em framework. Essa é exatamente a abordagem adotada com o PROXX.

PROXX: um estudo de caso da OMT

A equipe do Google Chrome desenvolveu o PROXX como um clone de campo minado que atende aos requisitos do Progressive Web App, o que inclui trabalhar off-line e oferecer uma experiência de usuário envolvente. Infelizmente, as primeiras versões do jogo tiveram baixo desempenho em dispositivos com restrições, como celulares, o que levou a equipe a perceber que a linha de execução principal era um gargalo.

A equipe decidiu usar Web workers para separar o estado visual do jogo da lógica:

  • A linha de execução principal processa a renderização de animações e transições.
  • Um web worker lida com a lógica do jogo, que é puramente computacional.

A OMT teve efeitos interessantes no desempenho dos telefones básicos do PROXX. Na versão não OMT, a IU é congelada por seis segundos após a interação do usuário. Não há feedback, e o usuário precisa esperar os seis segundos completos antes de poder fazer outra coisa.

Tempo de resposta da IU na versão não OMT do PROXX.

No entanto, na versão OMT, o jogo leva doze segundos para concluir uma atualização da IU. Embora isso pareça uma perda de desempenho, na verdade leva a um maior feedback para o usuário. A lentidão ocorre porque o app está enviando mais frames do que a versão não OMT, que não está enviando nenhum frame. O usuário, portanto, sabe que algo está acontecendo e pode continuar jogando enquanto a IU é atualizada, fazendo com que o jogo pareça consideravelmente melhor.

Tempo de resposta da IU na versão OMT do PROXX.

Essa é uma desvantagem consciente: oferecemos aos usuários de dispositivos restritos uma experiência que se sente melhor, sem penalizar os usuários de dispositivos de última geração.

Implicações de uma arquitetura OMT

Como mostra o exemplo da PROXX, a OMT faz seu app ser executado de maneira confiável em uma variedade maior de dispositivos, mas não o deixa mais rápido:

  • Você está apenas movendo o trabalho da linha de execução principal, sem reduzir o trabalho.
  • A sobrecarga extra de comunicação entre o worker da Web e a linha de execução principal pode deixar as coisas um pouco mais lentas.

Considerar os prós e contras

Como a linha de execução principal é livre para processar interações do usuário, como rolagem enquanto o JavaScript está em execução, há menos frames perdidos, mesmo que o tempo de espera total possa ser ligeiramente maior. É preferível fazer o usuário esperar um pouco do que perder um frame, porque a margem de erro é menor para frames perdidos: a queda de um frame ocorre em milissegundos, enquanto você tem centenas de milissegundos antes que o usuário perceba o tempo de espera.

Devido à imprevisibilidade do desempenho nos dispositivos, o objetivo da arquitetura de OMT é reduzir os riscos, ou seja, tornar o app mais robusto diante de condições de execução altamente variáveis, e não os benefícios de desempenho do carregamento em paralelo. O aumento da resiliência e as melhorias na UX valem mais do que qualquer pequena compensação na velocidade.

Observação sobre ferramentas

Os Web workers ainda não são conhecidos, portanto, a maioria das ferramentas de módulo, como webpack e Rollup, não oferece suporte imediato a eles. Mas o Parcel faz isso. Felizmente, existem plug-ins para que os web workers também funcionem com webpack e Rollup:

Resumo

Para garantir que nossos apps sejam o mais confiáveis e acessíveis possível, especialmente em um mercado cada vez mais global, precisamos oferecer suporte a dispositivos restritos, que são a forma como a maioria dos usuários acessa a Web no mundo todo. A OMT oferece uma maneira promissora de aumentar o desempenho nesses dispositivos sem afetar negativamente os usuários de dispositivos de última geração.

Além disso, a OMT tem benefícios secundários:

Os Web workers não precisam ser assustadores. Ferramentas como o Comlink estão tirando o trabalho dos workers e os tornando uma escolha viável para uma ampla gama de aplicativos da Web.

Imagem principal do Unsplash, de James Peacock.