Introduction aux workers

Dans ce dernier article de notre module sur le JavaScript asynchrone, nous présenterons les workers (qu'on pourrait traduire en français par travailleurs, moins usité), qui permettent d'exécuter certaines tâches dans un fil d'exécution séparé.

Prérequis : Notions informatiques de base, compréhension raisonnable des fondamentaux de JavaScript, notamment des fonctions et des gestionnaires d'évènements.
Objectif : Comprendre comment utiliser les web workers.

Dans le premier article de ce module, nous avons vu ce qui se passe lorsque vous avez une tâche synchrone de longue durée dans votre programme : c'est toute la fenêtre ne répond plus. La raison principale de ce problème est l'exécution du programme au sein d'un seul fil d'exécution (thread). Un fil d'exécution est une séquence d'instructions suivie par un programme. Parce que le programme s'exécute sur un seul fil, il ne peut faire qu'une seule chose à la fois : il attend donc la réponse de notre appel synchrone de longue durée et ne peut rien faire d'autre.

Les workers vous donnent la possibilité d'exécuter certaines tâches dans un fil d'exécution différent. Vous pouvez donc démarrer la tâche, puis continuer avec d'autres traitements (comme la gestion des actions de l'utilisateur).

Mais il y a un prix à payer pour cela. Avec le code parallélisé, vous ne savez jamais quand votre fil sera suspendu et quand l'autre fil aura une chance de s'exécuter. Ainsi, si les deux fils ont accès aux mêmes variables, il est possible qu'une variable change de manière inattendue à tout moment, ce qui provoque des bogues difficiles à trouver.

Pour éviter ces problèmes sur le Web, le code du fil principal et le code qui s'exécute dans le worker n'accèdent jamais directement aux variables de l'autre. Le code des workers et le code principal s'exécutent dans des mondes complètement séparés et n'interagissent qu'en s'envoyant des messages. Cela signifie notamment que les workers ne peuvent pas accéder au DOM (la fenêtre, le document, les éléments de la page, etc.).

Il existe trois types de workers :

  • Les workers dédiés
  • Les workers partagés
  • Les service workers

Dans cet article, nous allons voir un exemple décrivant le premier type, puis discuter brièvement des deux autres.

Utiliser les web workers

Vous souvenez-vous du premier article où nous avions une page qui calculait les nombres premiers ? Nous allons ici utiliser un worker pour calculer les nombres premiers afin que notre page reste réactive aux actions des utilisatrices et utilisateurs.

Le générateur de nombres premiers synchrone

Revoyons d'abord le code JavaScript de notre exemple précédent :

js
function generatePrimes(quota) {
  function isPrime(n) {
    for (let c = 2; c <= Math.sqrt(n); ++c) {
      if (n % c === 0) {
        return false;
      }
    }
    return true;
  }

  const primes = [];
  const maximum = 1000000;

  while (primes.length < quota) {
    const candidate = Math.floor(Math.random() * (maximum + 1));
    if (isPrime(candidate)) {
      primes.push(candidate);
    }
  }

  return primes;
}

document.querySelector("#generate").addEventListener("click", () => {
  const quota = document.querySelector("#quota").value;
  const primes = generatePrimes(quota);
  document.querySelector("#output").textContent =
    `Génération de ${quota} nombres premiers terminée !`;
});

document.querySelector("#reload").addEventListener("click", () => {
  document.querySelector("#user-input").value =
    'Essayez de taper ici immédiatement après avoir appuyé sur "Générer des nombres premiers"';
  document.location.reload();
});

Avec ce programme, après avoir appelé generatePrimes(), le navigateur ne répond plus du tout.

Génération de nombres premiers avec un worker

Pour cet exemple, commencez par faire une copie locale des fichiers présents dans https://github.com/mdn/learning-area/blob/main/javascript/asynchronous/workers/start. Quatre fichiers sont dans ce répertoire :

  • index.html
  • style.css
  • main.js
  • generate.js

Le fichier index.html et les fichiers style.css sont déjà complets :

html
<!doctype html>
<html lang="fr-FR">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Nombres premiers</title>
    <script src="main.js" defer></script>
    <link href="style.css" rel="stylesheet" />
  </head>

  <body>
    <label for="quota">Quantité de nombres premiers à générer :</label>
    <input type="text" id="quota" name="quota" value="1000000" />

    <button id="generate">Générer des nombres premiers</button>
    <button id="reload">Recharger</button>

    <textarea id="user-input" rows="5" cols="62">
Essayez de taper ici immédiatement après avoir appuyé sur "Générer des nombres premiers"</textarea
    >

    <div id="output"></div>
  </body>
</html>
css
textarea {
  display: block;
  margin: 1rem 0;
}

Les fichiers main.js et generate.js sont vides. Nous allons ajouter le code s'exécutant dans le fil principal à main.js d'une part et celui qui s'exécute via le worker à generate.js d'autre part.

Tout d'abord, nous pouvons voir que le code du worker est présent dans un fichier distinct du code principal. En regardant index.html ci-dessus, nous pouvons également observer que seul le code principal est inclus dans un élément <script>.

Copiez maintenant le code suivant dans main.js :

js
// On crée un nouveau worker en lui injectant le code présent dans le fichier "generate.js"
const worker = new Worker("./generate.js");

// Lorsque la personne clique sur "Générer des nombres premiers", on envoie un message au worker.
// La commande portée par le message est "generate", et le message contient également "quota"
// qui indique la quantité de nombres premiers à générer.
document.querySelector("#generate").addEventListener("click", () => {
  const quota = document.querySelector("#quota").value;
  worker.postMessage({
    command: "generate",
    quota,
  });
});

// Lorsque le worker renvoie un message au fil principal. Grâce aux données
// du message, on met à jour la zone de sortie avec un texte, indiquant aussi
// le quantité de nombres premiers générés.
worker.addEventListener("message", (message) => {
  document.querySelector("#output").textContent =
    `Génération de ${message.data} nombres premiers terminée !`;
});

document.querySelector("#reload").addEventListener("click", () => {
  document.querySelector("#user-input").value =
    'Essayez de taper ici immédiatement après avoir appuyé sur "Générer des nombres premiers"';
  document.location.reload();
});
  1. Tout d'abord, nous créons le worker en utilisant le constructeur Worker(). Nous lui passons un lien pointant vers le script du worker. Dès que le worker est créé, le script correspondant est exécuté.

  2. Ensuite, comme dans la version synchrone, nous ajoutons un gestionnaire d'événements click au bouton "Générer des nombres premiers". En revanche, plutôt que d'appeler une fonction generatePrimes(), nous envoyons un message au travailleur en utilisant postMessage(). L'argument de cette fonction est le message à transmettre. Ici nous transmettons un objet JSON contenant deux propriétés :

    commande

    Une chaîne de caractères indiquant la tâche demandée au worker (au cas où notre worker pourrait faire plus d'une chose)

    quota

    La quantité de nombres premiers à générer.

  3. Ensuite, nous ajoutons un gestionnaire d'évènements message au worker. C'est ainsi qu'il peut nous dire quand il a terminé et transmettre les données résultantes. Notre gestionnaire prend les données de la propriété data du message et les écrit dans l'élément de sortie (les données sont exactement les mêmes que quota, donc c'est un peu inutile, mais cela illustre le principe).

  4. Enfin, nous implémentons le gestionnaire d'événements click pour le bouton "Recharger". C'est la même chose que dans la version synchrone.

Passons maintenant au code du worker. Copiez le code suivant dans generate.js :

js
// On écoute les messages du fil principal.
// Si la commande de message est "generate", on appelle `generatePrimes()`
addEventListener("message", (message) => {
  if (message.data.command === "generate") {
    generatePrimes(message.data.quota);
  }
});

// On génère des nombres premiers (très inefficacement)
function generatePrimes(quota) {
  function isPrime(n) {
    for (let c = 2; c <= Math.sqrt(n); ++c) {
      if (n % c === 0) {
        return false;
      }
    }
    return true;
  }

  const primes = [];
  const maximum = 1000000;

  while (primes.length < quota) {
    const candidate = Math.floor(Math.random() * (maximum + 1));
    if (isPrime(candidate)) {
      primes.push(candidate);
    }
  }

  // Lorsque c'est terminé, on envoie un message au fil principal
  // incluant la quantité de nombres premiers générés.
  postMessage(primes.length);
}

N'oubliez pas que ce code s'exécute dès que le script principal crée le worker.

Le worker commence par écouter les messages provenant du script principal. Il le fait en utilisant addEventListener(), qui est une fonction globale dans un worker. À l'intérieur du gestionnaire d'évènements message, la propriété data de l'évènement contient une copie de l'argument transmis par le script principal. Si le script principal a passé la commande generate, nous appelons generatePrimes(), en transmettant la valeur quota des données de l'évènement message.

La fonction generatePrimes() utilise le même algorithme que la version synchrone, sauf qu'au lieu de renvoyer une valeur, nous envoyons un message au script principal lorsque le calcul est terminé. Nous utilisons la fonction postMessage() pour cela. Comme addEventListener(), il s'agit d'une fonction globale dans le contexte d'un worker. Comme nous l'avons déjà vu, le script principal écoute ce message et mettra à jour le DOM lorsque le message sera reçu.

Note : Pour exécuter ce site, vous devrez exécuter un serveur web local, car les URL de type file:// ne sont pas autorisées à charger des workers. Consultez notre guide pour configurer un serveur de test local. Une fois que cela aura été fait, vous devriez pouvoir cliquer sur "Générer des nombres premiers" et faire en sorte que votre page principale reste réactive.

Si vous rencontrez des problèmes lors de la création ou de l'exécution de l'exemple, vous pouvez voir la version finale sur https://github.com/mdn/learning-area/blob/main/javascript/asynchronous/workers/finished et l'essayer en direct sur https://mdn.github.io/learning-area/javascript/asynchronous/workers/finished.

Les autres types de workers

Le worker que nous venons de créer était ce qu'on appelle un worker dédié (dedicated worker en anglais). Cela signifie qu'il est utilisé par une seule instance de script.

Il existe cependant d'autres types de workers :

  • Les workers partagés qui peuvent être partagés par plusieurs scripts différents s'exécutant dans différentes fenêtres.
  • Les qui agissent comme des serveurs intermédiaires, mettant en cache les ressources afin que les applications web puissent fonctionner même hors ligne. Il s'agit d'un élément clé des applications web progressives (PWA).

Conclusion

Dans cet article, nous avons présenté les web workers, qui permettent à une application web de décharger des tâches sur un fil d'exécution séparé. Le fil d'exécution principal et le worker ne partagent pas de variables directement, mais communiquent avec des messages, reçus par l'autre côté en tant qu'évènements message.

Bien qu'ils ne puissent pas utiliser toutes les API auxquelles le document a accès (le DOM notamment), les workers peuvent être un moyen efficace de garder l'application principale réactive.

Voir aussi