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

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

Ces 20 dernières années, le Web a considérablement évolué : il est passé des documents statiques avec quelques styles et images à des applications complexes et dynamiques. 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.

En conséquence, 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. Pire encore, 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 considérable 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 relatives aux performances telles que les Core Web Vitals, qui s'appuient sur des données empiriques sur la perception humaine et la psychologie, nous avons besoin de moyens d'exécuter notre code en dehors du thread principal (OMT).

Pourquoi des nœuds de calcul Web ?

Par défaut, JavaScript est un langage monothread 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 workers Web soit limité et qu'ils n'offrent pas un accès direct au DOM, ils peuvent s'avérer extrêmement utiles s'il y a un travail important à effectuer et qui, autrement, surcharger le thread principal.

En ce qui concerne les Core Web Vitals, il peut être avantageux de travailler en dehors du thread principal. 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 dans le thread principal, soit pour afficher du texte ou des images, qui sont des éléments LCP fréquents et courants. En réduisant globalement le travail du thread principal, 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 travailleur Web pourrait gérer à la place.

Exécuter des threads avec des nœuds de calcul Web

D'autres plates-formes prennent généralement en charge le travail parallèle en vous permettant d'attribuer à un thread une fonction 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 les mutex et les sémaphores pour éviter les conditions de concurrence.

Avec JavaScript, nous pouvons obtenir des fonctionnalités à peu près similaires avec les 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 calcul Web, transmettez un fichier au constructeur du nœud de calcul, qui commence à exécuter ce fichier dans un thread distinct:

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

Communiquer avec le nœud de calcul 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 nœud de calcul:

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 nœud de calcul Web devient rapidement problématique: vous devez encoder non seulement les paramètres, mais aussi l'opération dans le message, et vous devez tenir une traçabilité pour faire correspondre les réponses aux requêtes. Cette complexité explique sans doute pourquoi les Web workers n'ont pas été adoptés à plus grande échelle.

Toutefois, si nous pouvions éliminer une partie des difficultés de communication entre le thread principal et les workers Web, ce modèle pourrait convenir à de nombreux cas d'utilisation. Et heureusement, il existe une bibliothèque qui fait ça !

Comlink est une bibliothèque dont l'objectif est de vous permettre d'utiliser des Web workers sans avoir à vous soucier des détails de postMessage. Comlink vous permet de partager des variables entre les nœuds de calcul Web et le thread principal, comme dans les autres langages de programmation compatibles avec les threads.

Pour configurer Comlink, vous devez l'importer dans un nœud de calcul Web et définir un ensemble de fonctions à exposer au thread principal. Vous importerez ensuite Comlink sur le thread principal, encapsuler le worker et accéder 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 du thread principal se comporte de la même manière que celle du Worker Web, sauf que chaque fonction renvoie une promesse pour une valeur plutôt que pour la valeur elle-même.

Quel code devez-vous transférer vers un nœud de calcul 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. Pourtant, chaque petit extrait de code déplacé vers un nœud de calcul acquiert plus d'espace sur le thread principal pour les éléments qui doivent être présents, comme la mise à jour de l'interface utilisateur.

L'un des problèmes pour les développeurs Web est que la plupart des applications Web s'appuient sur un framework d'UI tel que Vue ou React pour tout orchestrer dans l'application. tout est un composant du framework et est donc intrinsèquement lié au DOM. Il semble donc difficile de migrer vers une architecture OMT.

Cependant, si nous passons à un modèle dans lequel les problèmes d'interface utilisateur sont séparés des autres préoccupations, comme la gestion de l'état, les nœuds de calcul Web 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 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 ont enregistré des performances médiocres sur les appareils soumis à des contraintes, comme les feature phones, ce qui a conduit l'équipe à réaliser 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 nœud de calcul Web gère la logique de jeu, qui est purement informatisée.

L'OMT a eu des effets intéressants sur les performances de PROXX pour les feature phones. Dans la version non OMT, l'interface utilisateur est figée pendant six secondes après que l'utilisateur a interagi avec elle. Il n'y a pas de retour d'information et l'utilisateur doit attendre les six secondes complètes avant de pouvoir faire autre chose.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">.
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 entraîne en fait une augmentation du retour d'information pour l'utilisateur. Le ralentissement est dû au fait que l'application envoie plus d'images que la version non OMT, qui n'en envoie aucun. L'utilisateur sait donc qu'il se passe quelque chose et peut continuer à jouer au fur et à mesure que l'interface utilisateur se met à jour, ce qui améliore considérablement le jeu.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">.
Temps de réponse de l'interface utilisateur dans la version OMT de PROXX.

Il s'agit d'un compromis délibéré: nous offrons aux utilisateurs d'appareils restreints une expérience optimale sans pénaliser les utilisateurs d'appareils haut de gamme.

Implications d'une architecture OMT

Comme le montre l'exemple de PROXX, OMT permet à votre application de s'exécuter de manière fiable sur un plus large éventail d'appareils, mais ne la rend pas plus rapide:

  • Vous déplacez simplement la tâche du thread principal, sans la réduire.
  • Besoins supplémentaires en termes de communication entre le nœud de calcul Web et le thread principal peut parfois ralentir légèrement les choses.

Tenez 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 pertes de frames, même si le temps d'attente total peut être légèrement plus long. Il est préférable de faire attendre l'utilisateur un peu plutôt que de supprimer un frame, car la marge d'erreur est plus faible pour les frames supprimés: l'abandon d'une image se fait en quelques millisecondes, alors que vous disposez de centaines de millisecondes avant qu'un utilisateur perçoive le temps d'attente.

En raison de l'imprévisibilité des performances entre les appareils, l'objectif de l'architecture OMT est en réalité de réduire les risques, c'est-à-dire de rendre votre application plus robuste face à des conditions d'exécution très variables, et non les avantages en termes de performances de la parallélisation. L'augmentation de la résilience et les améliorations de l'expérience utilisateur valent plus que tout un petit compromis en termes de vitesse.

Remarque concernant les outils

Les nœuds de calcul Web n'étant pas encore très répandus, la plupart des outils de module (webpack et Rollup, par exemple) ne sont pas directement compatibles avec eux. (c'est le cas de Parcel). Heureusement, il existe des plug-ins permettant aux nœuds de calcul Web de fonctionner avec webpack et Rollup:

Récapitulatif

Pour que nos applications soient aussi fiables et accessibles que possible, en particulier dans un marché de plus en plus mondialisé, nous devons prendre en charge des appareils dont l'accès est limité. C'est grâce à eux que la plupart des utilisateurs accèdent au Web à travers le monde. OMT offre un moyen prometteur d'améliorer les performances sur ces appareils sans nuire aux utilisateurs des appareils haut de gamme.

En outre, l'OMT présente des avantages secondaires:

  • Elle déplace les coûts d'exécution de JavaScript vers un thread distinct.
  • Cela réduit les coûts d'analyse, ce qui signifie que l'UI peut démarrer plus rapidement. Cela pourrait réduire First Contentful Paint ou même Time to Interactive, ce qui peut à son tour augmenter note Lighthouse.

Les travailleurs Web n'ont pas besoin d'être effrayants. Des outils tels que Comlink facilitent la tâche des employés et en font un choix judicieux pour un large éventail d'applications Web.

Image principale tirée de Unsplash, de James Peacock.