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

Il est désormais plus facile de déplacer les tâches lourdes vers des threads d'arrière-plan grâce aux modules JavaScript dans les nœuds de calcul Web.

JavaScript est monothread, ce qui signifie qu'il ne peut effectuer qu'une seule opération à la fois. Cette approche est intuitive et fonctionne bien dans de nombreux cas sur le Web, mais peut devenir problématique lorsque nous devons effectuer des tâches lourdes telles que le traitement, l'analyse, le calcul ou l'analyse des données. À mesure que des applications de plus en plus complexes sont fournies sur le Web, le besoin de traitement multithread augmente.

Sur la plate-forme Web, la principale primitive pour le threading et le parallélisme est l'API Web Workers. Les workers sont une abstraction légère au-dessus des threads du système d'exploitation qui exposent une API de transmission de messages pour la communication entre les threads. Cela peut être extrêmement utile lors de calculs coûteux ou d'opérations sur de grands ensembles de données, car cela permet au thread principal de s'exécuter sans problème tout en effectuant les opérations coûteuses sur un ou plusieurs threads d'arrière-plan.

Voici un exemple typique d'utilisation d'un nœud de calcul, où un script de nœud de calcul écoute les messages du thread principal et répond en renvoyant ses propres messages :

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

L'API Web Worker est disponible dans la plupart des navigateurs depuis plus de 10 ans. Bien que cela signifie que les workers sont bien optimisés et bénéficient d'une excellente compatibilité avec les navigateurs, cela signifie également qu'ils sont bien antérieurs aux modules JavaScript. Comme il n'existait pas de système de modules lorsque les nœuds de calcul ont été conçus, l'API de chargement de code dans un nœud de calcul et de composition de scripts est restée semblable aux approches de chargement de scripts synchrones courantes en 2009.

Historique : nœuds de calcul classiques

Le constructeur Worker accepte une URL de script classique, qui est relative à l'URL du document. Elle renvoie immédiatement une référence à la nouvelle instance de worker, qui expose une interface de messagerie ainsi qu'une méthode terminate() qui arrête et détruit immédiatement le worker.

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

Une fonction importScripts() est disponible dans les workers Web pour charger du code supplémentaire, mais elle met en pause l'exécution du worker afin de récupérer et d'évaluer chaque script. Il exécute également des scripts dans le champ d'application global comme une balise <script> classique, ce qui signifie que les variables d'un script peuvent être remplacées par celles d'un autre.

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

C'est pourquoi les workers Web ont toujours eu un impact disproportionné sur l'architecture d'une application. Les développeurs ont dû créer des outils et des solutions de contournement ingénieux pour pouvoir utiliser des Web Workers sans renoncer aux pratiques de développement modernes. Par exemple, les bundlers tels que webpack intègrent une petite implémentation de chargeur de module dans le code généré qui utilise importScripts() pour le chargement de code, mais encapsulent les modules dans des fonctions pour éviter les collisions de variables et simuler les importations et exportations de dépendances.

Saisir les employés du module

Un nouveau mode pour les nœuds de calcul Web, avec les avantages ergonomiques et de performances des modules JavaScript, est disponible dans Chrome 80. Il s'agit des nœuds de calcul de module. Le constructeur Worker accepte désormais une nouvelle option {type:"module"}, qui modifie le chargement et l'exécution du script pour correspondre à <script type="module">.

const worker = new Worker('worker.js', {
  type: 'module'
});

Comme les workers de module sont des modules JavaScript standards, ils peuvent utiliser des instructions d'importation et d'exportation. Comme pour tous les modules JavaScript, les dépendances ne sont exécutées qu'une seule fois dans un contexte donné (thread principal, nœud de calcul, etc.). Toutes les importations futures font référence à l'instance de module déjà exécutée. Le chargement et l'exécution des modules JavaScript sont également optimisés par les navigateurs. Les dépendances d'un module peuvent être chargées avant l'exécution du module, ce qui permet de charger des arborescences de modules entières en parallèle. Le chargement de modules met également en cache le code analysé, ce qui signifie que les modules utilisés sur le thread principal et dans un worker n'ont besoin d'être analysés qu'une seule fois.

Le passage aux modules JavaScript permet également d'utiliser l'importation dynamique pour le chargement différé du code sans bloquer l'exécution du worker. L'importation dynamique est beaucoup plus explicite que l'utilisation de importScripts() pour charger les dépendances, car les exportations du module importé sont renvoyées au lieu de s'appuyer sur des variables globales.

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

Pour garantir des performances optimales, l'ancienne méthode importScripts() n'est pas disponible dans les workers de module. Le passage des workers aux modules JavaScript signifie que tout le code est chargé en mode strict. Autre changement notable : la valeur de this dans le champ de premier niveau d'un module JavaScript est undefined, alors que dans les workers classiques, la valeur correspond au champ global du worker. Heureusement, il y a toujours eu un self global qui fournit une référence à la portée globale. Il est disponible dans tous les types de workers, y compris les service workers, ainsi que dans le DOM.

Précharger les nœuds de calcul avec modulepreload

L'une des améliorations substantielles des performances apportées par les workers de module est la possibilité de précharger les workers et leurs dépendances. Avec les workers de module, les scripts sont chargés et exécutés en tant que modules JavaScript standards, ce qui signifie qu'ils peuvent être préchargés et même pré-analysés à l'aide de modulepreload :

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

Les modules préchargés peuvent également être utilisés par le thread principal et les workers de module. Cela est utile pour les modules importés dans les deux contextes ou dans les cas où il n'est pas possible de savoir à l'avance si un module sera utilisé sur le thread principal ou dans un worker.

Auparavant, les options disponibles pour le préchargement des scripts de nœuds de calcul Web étaient limitées et pas nécessairement fiables. Les nœuds de calcul classiques disposaient de leur propre type de ressource "worker" pour le préchargement, mais aucun navigateur n'a implémenté <link rel="preload" as="worker">. Par conséquent, la principale technique disponible pour le préchargement des Web Workers consistait à utiliser <link rel="prefetch">, qui reposait entièrement sur le cache HTTP. Lorsqu'il est utilisé en combinaison avec les bons en-têtes de mise en cache, cela permet d'éviter que l'instanciation du worker n'ait à attendre le téléchargement du script du worker. Toutefois, contrairement à modulepreload, cette technique n'était pas compatible avec le préchargement des dépendances ni avec l'analyse préalable.

Qu'en est-il des travailleurs partagés ?

Les workers partagés ont été mis à jour pour prendre en charge les modules JavaScript à partir de Chrome 83. Comme pour les workers dédiés, la construction d'un worker partagé avec l'option {type:"module"} charge désormais le script du worker en tant que module plutôt qu'en tant que script classique :

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

Avant la prise en charge des modules JavaScript, le constructeur SharedWorker() n'attendait qu'une URL et un argument name facultatif. Cette méthode continuera de fonctionner pour l'utilisation classique des workers partagés. Toutefois, la création de workers partagés de module nécessite l'utilisation du nouvel argument options. Les options disponibles sont les mêmes que celles d'un nœud de calcul dédié, y compris l'option name qui remplace l'argument name précédent.

Qu'en est-il du service worker ?

La spécification des service workers a déjà été mise à jour pour accepter un module JavaScript comme point d'entrée, en utilisant la même option {type:"module"} que les workers de module. Toutefois, cette modification n'a pas encore été implémentée dans les navigateurs. Une fois cela fait, il sera possible d'instancier un service worker à l'aide d'un module JavaScript en utilisant le code suivant :

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

Maintenant que la spécification a été mise à jour, les navigateurs commencent à implémenter le nouveau comportement. Cela prend du temps, car l'intégration de modules JavaScript dans le service worker est associée à des complications supplémentaires. L'enregistrement du service worker doit comparer les scripts importés avec leurs versions mises en cache précédentes pour déterminer s'il faut déclencher une mise à jour. Cette opération doit être implémentée pour les modules JavaScript lorsqu'ils sont utilisés pour les service workers. De plus, les service workers doivent pouvoir contourner le cache pour les scripts dans certains cas lors de la vérification des mises à jour.

Ressources supplémentaires et articles à lire