Utiliser des workers Web pour exécuter JavaScript en dehors du thread principal du navigateur

Une architecture hors thread principal peut considérablement améliorer la fiabilité et l'expérience utilisateur de votre application.

Au cours des 20 dernières années, le Web a évolué de manière spectaculaire, passant de documents statiques avec quelques styles et images à des applications dynamiques complexes. Cependant, une chose est restée en grande partie inchangée: nous n'avons qu'un thread par onglet de navigateur (à quelques exceptions près) pour afficher nos sites et exécuter notre code JavaScript.

Par conséquent, le thread principal est devenu incroyablement surchargé. À mesure que les applications Web gagnent en complexité, le thread principal devient un goulot d'étranglement important pour les performances. Pour aggraver la situation, le temps nécessaire pour exécuter du code sur le thread principal pour un utilisateur donné est presque complètement imprévisible, car les fonctionnalités de l'appareil ont un impact énorme sur les performances. Cette imprévisibilité ne fera que croître à mesure que les utilisateurs accéderont au Web à partir d'un ensemble d'appareils de plus en plus varié, des téléphones multifonctions très limités aux modèles phares haute puissance et à la fréquence d'actualisation élevée.

Si nous voulons que les applications Web sophistiquées respectent de manière fiable les consignes de performances telles que les métriques Core Web Vitals, qui sont basées sur des données empiriques sur la perception et la psychologie humaines, nous devons trouver des moyens d'exécuter notre code en dehors du thread principal (OMT).

Pourquoi utiliser des Web Workers ?

Par défaut, JavaScript est un langage à thread unique qui exécute des tâches sur le thread principal. Cependant, les nœuds de calcul Web fournissent une sorte de mécanisme de secours par rapport au thread principal en permettant aux développeurs de créer des threads distincts pour gérer les tâches en dehors du thread principal. Bien que le champ d'application des web workers soit limité et qu'ils n'offrent pas d'accès direct au DOM, ils peuvent être extrêmement utiles si une tâche importante doit être effectuée, qui autrement surchargerait le thread principal.

En ce qui concerne les métriques Core Web Vitals, exécuter des tâches en dehors du thread principal peut être bénéfique. En particulier, le déchargement des tâches du thread principal vers les nœuds de calcul Web peut réduire les conflits dans le thread principal, ce qui peut améliorer la métrique de réactivité Interaction to Next Paint (INP) d'une page. Lorsque le thread principal a moins de travail à traiter, il peut répondre plus rapidement aux interactions utilisateur.

La réduction du travail des threads principaux, en particulier au démarrage, présente également un avantage potentiel pour la Largest Contentful Paint (LCP) en réduisant les longues tâches. L'affichage d'un élément LCP nécessite du temps de thread principal, que ce soit pour afficher du texte ou des images, qui sont des éléments LCP fréquents et courants. En réduisant le travail du thread principal dans son ensemble, vous pouvez vous assurer que l'élément LCP de votre page est moins susceptible d'être bloqué par un travail coûteux qu'un worker Web pourrait gérer à la place.

Utiliser des threads avec des nœuds de calcul Web

D'autres plates-formes acceptent généralement le travail en parallèle en vous permettant d'attribuer une fonction à un thread, qui s'exécute en parallèle avec le reste de votre programme. Vous pouvez accéder aux mêmes variables à partir des deux threads, et l'accès à ces ressources partagées peut être synchronisé avec des mutex et des sémaphores pour éviter les conditions de concurrence.

En JavaScript, nous pouvons obtenir des fonctionnalités à peu près similaires à celles des Web Workers, qui existent depuis 2007 et sont compatibles avec tous les principaux navigateurs depuis 2012. Les nœuds de calcul Web s'exécutent en parallèle avec le thread principal, mais contrairement aux threads OS, ils ne peuvent pas partager de variables.

Pour créer un nœud de travail Web, transmettez un fichier au constructeur du nœud de travail, qui commence à exécuter ce fichier dans un thread distinct:

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

Communiquez avec le worker Web en envoyant des messages à l'aide de l'API postMessage. Transmettez la valeur du message en tant que paramètre dans l'appel postMessage, puis ajoutez un écouteur d'événements de message au 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
  // ...
});

Pour renvoyer un message au thread principal, utilisez la même API postMessage dans le nœud de calcul Web et configurez un écouteur d'événements sur le thread 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);
});

Certes, cette approche est quelque peu limitée. Auparavant, les Web workers étaient principalement utilisés pour retirer une tâche importante du thread principal. Essayer de gérer plusieurs opérations avec un seul worker Web devient rapidement difficile: vous devez encoder non seulement les paramètres, mais aussi l'opération dans le message, et vous devez effectuer une comptabilité pour faire correspondre les réponses aux requêtes. C'est probablement pour cette raison que les nœuds de calcul Web n'ont pas été adoptés plus largement.

Toutefois, si nous pouvions réduire la difficulté de la communication entre le thread principal et les workers Web, ce modèle pourrait être adapté à de nombreux cas d'utilisation. Heureusement, il existe une bibliothèque qui fait exactement cela.

Comlink est une bibliothèque qui vous permet d'utiliser des web workers sans avoir à vous soucier des détails de postMessage. Comlink vous permet de partager des variables entre les workers Web et le thread principal, presque comme les autres langages de programmation compatibles avec le threading.

Pour configurer Comlink, importez-le dans un nœud de calcul Web et définissez un ensemble de fonctions à exposer au thread principal. Vous importez ensuite Comlink sur le thread principal, encapsulez le worker et accédez aux fonctions exposées:

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

La variable api sur le thread principal se comporte de la même manière que celle du web worker, sauf que chaque fonction renvoie une promesse pour une valeur plutôt que la valeur elle-même.

Quel code devez-vous déplacer vers un worker Web ?

Les nœuds de calcul Web n'ont pas accès au DOM ni à de nombreuses API telles que WebUSB, WebRTC ou Web Audio. Vous ne pouvez donc pas insérer dans un nœud de calcul des éléments de votre application qui dépendent de cet accès. Toutefois, chaque petit extrait de code déplacé vers un worker permet de gagner de l'espace sur le thread principal pour les éléments qui doivent y être, comme la mise à jour de l'interface utilisateur.

Les développeurs Web doivent faire face à un problème : la plupart des applications Web s'appuient sur un framework d'UI tel que Vue ou React pour orchestrer tous les éléments de l'application. Tout est un composant du framework et est donc intrinsèquement lié au DOM. Cela semble rendre difficile la migration vers une architecture OMT.

Toutefois, si nous passons à un modèle dans lequel les problèmes d'UI sont séparés des autres problèmes, comme la gestion de l'état, les web workers peuvent être très utiles, même avec des applications basées sur un framework. C'est exactement l'approche adoptée avec PROXX.

PROXX: une étude de cas sur l'OMT

L'équipe Google Chrome a développé PROXX en tant que clone du démineur qui répond aux exigences des progressive web apps (travail hors connexion et expérience utilisateur attrayante). Malheureusement, les premières versions du jeu ne fonctionnaient pas bien sur les appareils limités, comme les téléphones basiques. L'équipe a donc réalisé que le thread principal était un goulot d'étranglement.

L'équipe a décidé d'utiliser des web workers pour séparer l'état visuel du jeu de sa logique:

  • Le thread principal gère le rendu des animations et des transitions.
  • Un worker Web gère la logique de jeu, qui est purement computationnelle.

L'OMT a eu des effets intéressants sur les performances du feature phone de PROXX. Dans la version sans OMT, l'UI est figée pendant six secondes après l'interaction de l'utilisateur. Aucun retour n'est fourni, et l'utilisateur doit attendre six secondes complètes avant de pouvoir effectuer une autre action.

Temps de réponse de l'interface utilisateur dans la version non OMT de PROXX.

Toutefois, dans la version OMT, la mise à jour de l'interface utilisateur prend douze secondes. Bien que cela semble être une perte de performances, cela permet en fait d'augmenter les commentaires envoyés à l'utilisateur. Le ralentissement se produit parce que l'application envoie plus de trames que la version sans OMT, qui n'en envoie aucune. L'utilisateur sait donc que quelque chose se passe et peut continuer à jouer pendant que l'UI est mise à jour, ce qui améliore considérablement l'expérience de jeu.

Temps de réponse de l'interface utilisateur dans la version OMT de PROXX.

Il s'agit d'un compromis conscient: nous offrons aux utilisateurs d'appareils limités une expérience plus agréable sans pénaliser les utilisateurs d'appareils haut de gamme.

Implications d'une architecture OMT

Comme l'exemple PROXX le montre, l'OMT permet à votre application de s'exécuter de manière fiable sur un plus grand nombre d'appareils, mais pas de l'accélérer:

  • Vous ne faites que déplacer le travail du thread principal, pas le réduire.
  • Les frais généraux de communication supplémentaires entre le nœud de calcul Web et le thread principal peuvent parfois ralentir les choses.

Tenir compte des compromis

Étant donné que le thread principal est libre de traiter les interactions utilisateur telles que le défilement pendant l'exécution de JavaScript, il y a moins de frames abandonnés, même si le temps d'attente total peut être légèrement plus long. Il est préférable de faire patienter l'utilisateur un peu plutôt que de supprimer un frame, car la marge d'erreur est plus faible pour les frames supprimés: la suppression d'un frame se produit en millisecondes, alors que vous disposez de centaines de millisecondes avant qu'un utilisateur ne perçoive le temps d'attente.

En raison de l'imprévisibilité des performances sur les différents appareils, l'objectif de l'architecture OMT est de réduire les risques (rendre votre application plus robuste face à des conditions d'exécution très variables), et non de profiter des avantages de la parallélisation en termes de performances. L'amélioration de la résilience et de l'expérience utilisateur compensent largement la légère diminution de la vitesse.

Remarque concernant les outils

Les nœuds de travail Web ne sont pas encore courants. Par conséquent, la plupart des outils de module, comme webpack et Rollup, ne les prennent pas en charge par défaut. (c'est le cas de Parcel). Heureusement, il existe des plug-ins pour faire fonctionner les web workers avec webpack et Rollup:

Récapitulatif

Pour nous assurer que nos applications sont aussi fiables et accessibles que possible, en particulier sur un marché de plus en plus mondialisé, nous devons prendre en charge les appareils limités, par lesquels la plupart des utilisateurs accèdent au Web dans le monde entier. L'OMT offre un moyen prometteur d'améliorer les performances sur ces appareils sans nuire aux utilisateurs d'appareils haut de gamme.

L'OMT présente également d'autres avantages:

  • Il transfère les coûts d'exécution JavaScript vers un thread distinct.
  • Il déplace les coûts d'analyse, ce qui peut accélérer le démarrage de l'UI. Cela peut réduire First Contentful Paint ou même Time to Interactive, ce qui peut à son tour augmenter votre score Lighthouse.

Les nœuds de calcul Web ne doivent pas être effrayants. Des outils comme Comlink déchargent les travailleurs de certaines tâches et constituent un choix viable pour un large éventail d'applications Web.