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 Web Workers.

JavaScript est un langage 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 elle peut devenir problématique lorsque nous devons effectuer des tâches lourdes telles que le traitement, l'analyse, le calcul ou l'analyse de données. Comme les applications diffusées sur le Web sont de plus en plus complexes, 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 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 l'exécution 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 de manière fluide tout en effectuant les opérations coûteuses sur un ou plusieurs threads d'arrière-plan.

Voici un exemple d'utilisation d'un worker, où un script de worker é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 dix ans. Cela signifie que les workers bénéficient d'une excellente compatibilité avec les navigateurs et sont bien optimisés, mais aussi qu'ils sont bien antérieurs aux modules JavaScript. Comme il n'existait pas de système de modules lorsque les workers ont été conçus, l'API permettant de charger du code dans un worker et de composer des scripts est restée semblable aux approches de chargement de scripts synchrones courantes en 2009.

Historique : nœuds de calcul classiques

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

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

Une fonction importScripts() est disponible dans les Web Workers pour charger du code supplémentaire, mais elle suspend 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 écrasées par les variables 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 web workers ont toujours eu un impact considérable sur l'architecture d'une application. Les développeurs ont dû créer des outils et des solutions de contournement astucieux pour pouvoir utiliser les Web Workers sans renoncer aux pratiques de développement modernes. Par exemple, les bundlers comme webpack intègrent une petite implémentation de chargeur de module dans le code généré qui utilise importScripts() pour le chargement du 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 nœuds de calcul du module

Un nouveau mode pour les Web Workers, offrant les avantages ergonomiques et de performances des modules JavaScript, est disponible dans Chrome 80. Il s'agit des Module Workers. Le constructeur Worker accepte désormais une nouvelle option {type:"module"}, qui modifie le chargement et l'exécution du script pour qu'ils correspondent à <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, worker, etc.), et 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 des modules met également en cache le code analysé, ce qui signifie que les modules utilisés sur le thread principal et dans un nœud de calcul ne doivent ê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 d'application de niveau supérieur d'un module JavaScript est undefined, tandis que dans les workers classiques, la valeur est le champ d'application global du worker. Heureusement, il existe depuis toujours une variable globale self qui fournit une référence à la portée globale. Elle 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'un des avantages majeurs des workers de module en termes de performances 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 lorsqu'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 précharger les scripts de Web Workers étaient limitées et pas nécessairement fiables. Les workers classiques avaient leur propre type de ressource "worker" pour le préchargement, mais aucun navigateur n'implémentait <link rel="preload" as="worker">. Par conséquent, la principale technique disponible pour précharger les Web Workers consistait à utiliser <link rel="prefetch">, qui reposait entièrement sur le cache HTTP. Lorsqu'il est utilisé en combinaison avec les en-têtes de mise en cache appropriés, cela permet d'éviter que l'instanciation du nœud de calcul n'ait à attendre le téléchargement du script du nœud de calcul. Cependant, contrairement à modulepreload, cette technique n'était pas compatible avec le préchargement des dépendances ni l'analyse préalable.

Qu'en est-il des nœuds de calcul 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. Cela continuera de fonctionner pour l'utilisation des workers partagés classiques. 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 pour 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 du service worker 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 que cela sera possible, vous pourrez 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 au service worker entraîne des complications supplémentaires. L'enregistrement du service worker doit comparer les scripts importés avec leurs versions précédentes mises en cache pour déterminer s'il faut déclencher une mise à jour. Cela doit être implémenté 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 recherche de mises à jour.

Ressources supplémentaires et lectures complémentaires