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 en 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 intuitive fonctionne bien dans de nombreux cas sur le Web, mais elle peut s'avérer problématique lorsque nous devons effectuer des tâches lourdes telles que le traitement, l'analyse, le calcul ou l'analyse de données. À mesure que de plus en plus d'applications complexes sont proposées sur le Web, le traitement multithread devient de plus en plus nécessaire.

Sur la plate-forme Web, la principale primitive du threading et du parallélisme est l'API Web Workers. Les nœuds de calcul constituent 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 interthread. Cela peut s'avérer extrêmement utile lors de l'exécution de calculs coûteux ou de l'utilisation d'ensembles de données volumineux, ce qui permet au thread principal de s'exécuter correctement tout en effectuant les opérations coûteuses sur un ou plusieurs threads en arrière-plan.

Voici un exemple type 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 dix ans. Cela signifie que les travailleurs 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'y avait pas de système de modules lors de la conception des workers, l'API permettant de charger du code dans un worker et de composer des scripts est restée semblable aux approches de chargement de script synchrones courantes en 2009.

Historique: nœuds de calcul classiques

Le constructeur de nœud de calcul utilise une URL de script classique, qui est relative à l'URL du document. Il 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 web workers pour charger du code supplémentaire, mais elle met en pause l'exécution du worker afin d'extraire et d'évaluer chaque script. Elle 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 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';
}

Pour cette raison, les Web Workers ont toujours eu un impact disproportionné sur l'architecture d'une application. Les développeurs ont dû créer des outils et des solutions astucieuses 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 nœuds de calcul de module

Un nouveau mode pour les travailleurs Web, qui offre les avantages des modules JavaScript en termes d'ergonomie et de performances, est disponible dans Chrome 80, appelé "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 qu'ils correspondent à <script type="module">.

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

Les nœuds de calcul de module étant 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 tous les futurs importations 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 entiers en parallèle. Le chargement de module 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 code de chargement paresseux 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 d'excellentes performances, l'ancienne méthode importScripts() n'est pas disponible dans les workers de module. Si vous passez aux modules JavaScript pour les workers, tout le code est chargé en mode strict. Autre changement notable : la valeur de this dans le champ d'application de premier niveau d'un module JavaScript est undefined, tandis que dans les workers classiques, la valeur correspond au champ d'application global du worker. Heureusement, il y a toujours eu un self global qui fait référence au champ d'application global. 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

Les workers de module offrent une amélioration substantielle des performances, car ils permettent 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 à la fois par le thread principal et les nœuds de calcul du 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 travail 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 précharger des travailleurs Web était d'utiliser <link rel="prefetch">, qui reposait entièrement sur le cache HTTP. En l'utilisant avec les en-têtes de mise en cache appropriés, il a été possible d'éviter que l'instanciation du nœud de calcul n'ait à attendre pour télécharger le script du nœud de calcul. 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 workers partagés ?

Les workers partagés ont été mis à jour pour prendre en charge les modules JavaScript à partir de Chrome 83. Comme pour les nœuds de travail dédiés, la création d'un nœud de travail partagé avec l'option {type:"module"} charge désormais le script de nœud de travail 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 d'utiliser le 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 des services workers ?

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, ce changement n'a pas encore été implémenté dans les navigateurs. Vous pourrez alors instancier un service worker à l'aide d'un module JavaScript à l'aide du 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'importation de modules JavaScript dans un service worker présente certaines complications supplémentaires. L'enregistrement du service worker doit comparer les scripts importés à leurs versions précédentes mises en cache pour déterminer s'il doit déclencher une mise à jour. Cette opération doit être implémentée pour les modules JavaScript lorsqu'ils sont utilisés pour les services workers. De plus, les services workers doivent pouvoir contourner le cache pour les scripts dans certains cas lors de la recherche de mises à jour.

Ressources supplémentaires et articles complémentaires