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é et l'expérience utilisateur de votre application.

Surma
Surma

Au cours des 20 dernières années, le Web a considérablement évolué : il est passé de documents statiques avec peu de styles et d'images à des applications complexes et dynamiques. Cependant, un point est resté globalement inchangé: nous ne disposons que d'un thread par onglet du 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 la complexité des applications Web se complexifie, le thread principal devient un goulot d'étranglement important pour les performances. Pire encore, le temps nécessaire pour exécuter le code dans le thread principal pour un utilisateur donné est presque complètement imprévisible, car les fonctionnalités des appareils ont un impact considérable sur les performances. Cette situation imprévisible ne fera qu'augmenter lorsque les utilisateurs accéderont au Web à partir d'un ensemble d'appareils de plus en plus varié, des téléphones multifonctions hypercontrôlés aux appareils phares haute puissance et à fréquence d'actualisation élevée.

Si nous voulons que des applications Web sophistiquées respectent de manière fiable les consignes relatives aux performances telles que les Signaux Web essentiels, qui sont basées sur des données empiriques sur la perception et la psychologie humaines, nous avons besoin d'exécuter notre code en dehors du thread principal (OMT).

Pourquoi des workers Web ?

Par défaut, JavaScript est un langage monothread qui exécute des tâches sur le thread principal. Cependant, les workers Web fournissent une sorte de mécanisme de sortie à partir du 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'il ne permette pas d'accéder directement au DOM, ils peuvent s'avérer très utiles si un travail considérable doit être effectué pour submerger le thread principal autrement.

En ce qui concerne les Signaux Web essentiels, il peut être utile de travailler en dehors du thread principal. En particulier, le déchargement du travail du thread principal vers les workers Web peut réduire les conflits pour le thread principal, ce qui peut améliorer des métriques de réactivité importantes telles que Interaction to Next Paint (INP) et First Input Delay (FID). Lorsque le thread principal a moins de travail à traiter, il peut répondre plus rapidement aux interactions utilisateur.

La réduction du travail du thread principal, en particulier au démarrage, présente également un avantage potentiel pour le 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 pour l'affichage 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 éviter que l'élément LCP de votre page soit bloqué par des tâches coûteuses qu'un collaborateur Web pourrait gérer à la place.

Exécuter des threads avec des workers Web

D'autres plates-formes prennent généralement en charge le travail en parallèle en vous permettant de donner à 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 depuis les 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 aux workers Web, 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 du système d'exploitation, 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 lance l'exécution de ce fichier dans un thread distinct:

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

communiquer avec le nœud de calcul Web en lui envoyant des messages via 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 assez limitée. Historiquement, les workers Web ont principalement été utilisés pour retirer un seul élément de travail lourd du thread principal. La gestion de plusieurs opérations avec un seul nœud de calcul Web devient rapidement pénible: vous devez encoder non seulement les paramètres, mais aussi l'opération dans le message, et vous devez tenir des comptes pour faire correspondre les réponses aux demandes. Cette complexité explique probablement pourquoi les travailleurs Web n'ont pas été adoptés aussi largement.

Toutefois, si nous pouvions éliminer les difficultés de communication entre le thread principal et les workers Web, ce modèle pourrait convenir à de nombreux cas d'utilisation. Heureusement, il existe une bibliothèque dédiée à cet usage.

Comlink est une bibliothèque dont l'objectif est de vous permettre d'utiliser des Web workers sans avoir à vous préoccuper 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 les threads.

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

Quel code devez-vous transférer vers un collaborateur Web ?

Les Web workers n'ont pas accès au DOM ni à de nombreuses API telles que WebUSB, WebRTC ou Web Audio. Vous ne pouvez donc pas placer dans un worker des parties de votre application qui dépendent de cet accès. Pourtant, chaque petit morceau de code déplacé vers un worker achète davantage d'espace sur le thread principal pour les éléments qui doivent s'y trouver, 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'interface utilisateur comme Vue ou React pour tout orchestrer dans l'application. Tout est un composant du framework et est donc intrinsèquement lié au DOM. Cela semble compliquer la migration vers une architecture OMT.

Toutefois, si nous passons à un modèle dans lequel les préoccupations de l'interface utilisateur sont séparées des autres tâches, comme la gestion de l'état, les workers Web peuvent s'avérer 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. Il répond aux exigences des Progressive Web App (travail hors connexion, expérience utilisateur attrayante, etc.). Malheureusement, les premières versions du jeu n'ont pas fonctionné sur des appareils limités comme les feature phones, ce qui a conduit l'équipe à se rendre compte que le thread principal constituait un goulot d'étranglement.

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

  • Le thread principal gère l'affichage des animations et des transitions.
  • Un nœud de calcul Web gère la logique de jeu, qui est purement informatique.

L'OMT a eu des effets intéressants sur les performances des feature phones PROXX. Dans la version non OMT, l'interface utilisateur est bloquée pendant six secondes après que l'utilisateur a interagi avec elle. Aucun retour n'est émis, l'utilisateur doit attendre les six secondes complètes avant de pouvoir effectuer autre chose.

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

En revanche, dans la version OMT, la mise à jour de l'interface utilisateur prend douze secondes. Bien que cela ressemble à une perte de performances, cela entraîne en réalité une augmentation du retour d'information de l'utilisateur. Le ralentissement est dû au fait que l'application envoie plus de cadres que la version non OMT, qui n'en livre aucun. L'utilisateur est donc au courant de ce qui se passe et peut continuer à jouer pendant la mise à jour de l'interface utilisateur, 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.

C'est un compromis conscient: nous offrons aux utilisateurs d'appareils limités une expérience qui se sent mieux 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 une plus grande variété d'appareils, mais elle ne l'accélère pas:

  • Vous déplacez simplement des tâches depuis le thread principal, sans les réduire.
  • La surcharge de communication entre le nœud de calcul Web et le thread principal peut parfois ralentir légèrement.

Prendre en compte les compromis

Étant donné que le thread principal est libre de traiter les interactions des utilisateurs comme le défilement pendant l'exécution de JavaScript, le nombre de frames abandonnés est moins important, 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 d'abandonner une image, car la marge d'erreur est plus faible pour les images ignorées: l'abandon d'une image se produit en quelques millisecondes, alors qu'il vous reste des centaines de millisecondes avant qu'un utilisateur ne perçoive le temps d'attente.

En raison de l'imprévisibilité des performances sur tous les appareils, l'objectif de l'architecture OMT est de réduire les risques, en rendant votre application plus robuste dans des conditions d'exécution très variables. Il ne s'agit pas des avantages de la parallélisation en termes de performances. L'augmentation de la résilience et les améliorations apportées à l'expérience utilisateur valent plus que la peine d'un petit compromis en termes de vitesse.

Remarque concernant les outils

Les workers Web ne sont pas encore très répandus. C'est pourquoi la plupart des outils de module, tels que webpack et Rollup, ne sont pas prêts à l'emploi. (en revanche, Parcel le fera !) Heureusement, il existe des plug-ins permettant aux workers Web de fonctionner 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 accepter des problèmes d'appareils limités, car ils sont utilisés par la plupart des utilisateurs dans le monde. OMT offre un moyen prometteur d'améliorer les performances sur ces appareils sans nuire aux utilisateurs d'appareils haut de gamme.

De plus, OMT présente des avantages secondaires:

  • Elle transfère les coûts d'exécution JavaScript vers un thread distinct.
  • Il affecte les coûts d'analyse, ce qui peut entraîner le démarrage plus rapide de l'interface utilisateur. Cela peut réduire le nombre de First Contentful Paint ou même le Time to Interactive, ce qui peut à son tour augmenter votre score Lighthouse.

Les Web workers n'ont pas besoin d'être effrayants. Des outils tels que Comlink éliminent la charge de travail des employés et en font un choix viable pour un large éventail d'applications Web.

Image principale de Unsplash, de James Paacock.