Une architecture hors du thread principal peut améliorer considérablement la fiabilité et l'expérience utilisateur de votre application.
Au cours des 20 dernières années, le Web a considérablement évolué, passant de documents statiques avec quelques styles et images à des applications complexes et dynamiques. Cependant, une chose est restée largement inchangée : nous n'avons qu'un seul thread par onglet de navigateur (à quelques exceptions près) pour effectuer le rendu de nos sites et exécuter notre JavaScript.
Par conséquent, le thread principal est devenu incroyablement surchargé. À mesure que les applications Web deviennent plus complexes, le thread principal devient un goulot d'étranglement important pour les performances. Pire encore, le temps nécessaire pour exécuter le code sur le thread principal pour un utilisateur donné est presque totalement imprévisible, car les capacité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 diversifié, allant des téléphones mobiles aux ressources très limitées aux appareils phares puissants à taux de rafraîchissement élevé.
Si nous voulons que des applications Web sophistiquées respectent de manière fiable les consignes de performances, comme les métriques Core Web Vitals (qui sont basées sur des données empiriques concernant la perception et la psychologie humaines), nous avons besoin de moyens pour exécuter notre code en dehors du thread principal.
Pourquoi utiliser des workers Web ?
JavaScript est, par défaut, un langage à thread unique qui exécute des tâches sur le thread principal. Toutefois, les nœuds de calcul Web offrent une sorte d'échappatoire au thread principal en permettant aux développeurs de créer des threads distincts pour gérer le travail en dehors du thread principal. Bien que la portée des workers Web soit limitée et qu'ils n'offrent pas d'accès direct au DOM, ils peuvent être extrêmement utiles si une quantité considérable de travail doit être effectuée, ce qui submergerait autrement le thread principal.
En ce qui concerne les Core Web Vitals, il peut être avantageux d'exécuter le travail en dehors du thread principal. En particulier, le déchargement du travail du thread principal vers les workers Web peut réduire la contention pour 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 des utilisateurs.
La réduction du travail du thread principal, en particulier au démarrage, peut également avoir un impact positif sur le Largest Contentful Paint (LCP) en réduisant les tâches longues. Le rendu d'un élément LCP nécessite du temps sur le thread principal, que ce soit pour le rendu de texte ou d'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 des tâches coûteuses qu'un Web Worker pourrait gérer à la place.
Exécuter des threads avec des nœuds de calcul Web
Les autres plates-formes prennent généralement en charge le travail 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 depuis les deux threads. 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 à partir des Web Workers, qui existent depuis 2007 et sont compatibles avec tous les principaux navigateurs depuis 2012. Les Web Workers s'exécutent en parallèle du thread principal, mais contrairement au threading OS, ils ne peuvent pas partager de variables.
Pour créer un Web Worker, transmettez un fichier au constructeur du Worker, 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 Web Worker 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. Historiquement, les nœuds de calcul Web ont principalement été utilisés pour déplacer une seule tâche lourde hors du thread principal. Essayer de gérer plusieurs opérations avec un seul Web Worker devient rapidement difficile : vous devez encoder non seulement les paramètres, mais aussi l'opération dans le message, et vous devez tenir des registres pour faire correspondre les réponses aux requêtes. Cette complexité est probablement la raison pour laquelle les Web Workers n'ont pas été adoptés plus largement.
Toutefois, si nous pouvions supprimer certaines difficultés de communication entre le thread principal et les workers Web, ce modèle pourrait être idéal pour de nombreux cas d'utilisation. Heureusement, il existe une bibliothèque qui fait exactement cela !
Comlink : simplifier l'utilisation des nœuds de calcul Web
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 Web Workers et le thread principal presque comme d'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 Web Worker ?
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 les éléments de votre application qui dépendent de cet accès. Toutefois, chaque petit extrait de code déplacé vers un worker offre plus de marge de manœuvre sur le thread principal pour les éléments qui doivent y figurer, comme la mise à jour de l'interface utilisateur.
Pour les développeurs Web, l'un des problèmes est que la plupart des applications Web s'appuient sur un framework d'UI comme Vue ou React pour orchestrer tout dans 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 les applications basées sur des frameworks. C'est exactement l'approche adoptée avec PROXX.
PROXX : une étude de cas OMT
L'équipe Google Chrome a développé PROXX, un clone du Démineur qui répond aux exigences des progressive web apps, y compris en termes de fonctionnement hors connexion et d'expérience utilisateur attrayante. Malheureusement, les premières versions du jeu étaient peu performantes sur les appareils limités comme les téléphones classiques, ce qui a permis à l'équipe de se rendre compte que le thread principal était 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 le rendu des animations et des transitions.
- Un Web Worker gère la logique du jeu, qui est purement computationnelle.
OMT a eu des effets intéressants sur les performances de PROXX sur les téléphones classiques. Dans la version sans OMT, l'UI est figée pendant six secondes après l'interaction de l'utilisateur. Aucun commentaire n'est fourni, et l'utilisateur doit attendre les six secondes complètes avant de pouvoir faire autre chose.
Dans la version OMT, en revanche, le jeu met douze secondes pour effectuer une mise à jour de l'UI. Bien que cela puisse sembler entraîner une perte de performances, cela permet en réalité d'améliorer les commentaires fournis à l'utilisateur. Le ralentissement se produit parce que l'application envoie plus de frames que la version sans OMT, qui n'en envoie aucun. L'utilisateur sait donc qu'il se passe quelque chose et peut continuer à jouer pendant que l'UI se met à jour, ce qui améliore considérablement l'expérience de jeu.
Il s'agit d'un compromis conscient : nous offrons aux utilisateurs d'appareils limités une expérience qui semble meilleure sans pénaliser les utilisateurs d'appareils haut de gamme.
Implications d'une architecture OMT
Comme le montre l'exemple PROXX, OMT permet à votre application de s'exécuter de manière fiable sur un plus grand nombre d'appareils, mais ne la rend pas plus rapide :
- Vous ne faites que déplacer le travail du thread principal, et non le réduire.
- La surcharge de communication supplémentaire entre le nœud de calcul Web et le thread principal peut parfois ralentir légèrement les choses.
Peser le pour et le contre
Comme le thread principal est libre de traiter les interactions utilisateur telles que le défilement pendant l'exécution de JavaScript, le nombre d'images perdues est moins élevé, même si le temps d'attente total peut être légèrement plus long. Il est préférable de faire attendre un peu l'utilisateur plutôt que de supprimer une frame, car la marge d'erreur est plus petite pour les frames supprimées : la suppression d'une frame se produit en millisecondes, tandis que vous avez des 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, c'est-à-dire de rendre votre application plus robuste face à des conditions d'exécution très variables, et non d'améliorer les performances grâce à la parallélisation. L'augmentation de la résilience et les améliorations de l'UX valent largement tout petit compromis en termes de vitesse.
Remarque concernant les outils
Les Web Workers ne sont pas encore très répandus. La plupart des outils de module, comme webpack et Rollup, ne les prennent donc pas en charge par défaut. (mais Parcel, oui !) Heureusement, il existe des plug-ins pour faire fonctionner les Web Workers avec webpack et Rollup :
- worker-plugin pour webpack
- rollup-plugin-off-main-thread pour 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 aux ressources limitées, car c'est ainsi que la plupart des utilisateurs accèdent au Web dans le monde. L'OMT offre un moyen prometteur d'améliorer les performances sur ces appareils sans affecter négativement les utilisateurs d'appareils haut de gamme.
L'OMT présente également des avantages secondaires :
- Il transfère les coûts d'exécution JavaScript vers un thread distinct.
- Il déplace les coûts d'analyse, ce qui signifie que l'UI peut démarrer plus rapidement. Cela peut réduire le First Contentful Paint ou même le Time to Interactive, ce qui peut à son tour augmenter votre score Lighthouse.
Les Web Workers ne doivent pas être effrayants. Des outils comme Comlink permettent aux workers de fonctionner sans problème et d'être un choix viable pour un large éventail d'applications Web.