Multi-threading côté client

Web workers

Lionel Médini

M1IF13 - Programmation Web avancée et mobile

Université Claude Bernard Lyon 1

cc-by-nc-sa

Position du problème

Faire faire plusieurs “trucs” à un navigateur “en même temps”

  • répondre aux actions utilisateur
  • envoyer une requête et traiter la réponse
  • faire un calcul compliqué et afficher les résultats au fur et à mesure

Rappels

Fonctionnement de JavaScript

  • Single-threaded
  • Event loop

Single thread

Moteur JS interne à un navigateur

→ Modèle d'exécution assez simple

Event loop

Principes de base d’un moteur JS (non optimisé)

Composants

  • Heap (tas) : région mémoire non structurée où les objets sont alloués
  • Stack (pile) : gère une pile de frames (appels de fonctions à traiter)
    • fonction
    • arguments
    • variables locales
  • Message queue (file) : liste de messages à traiter
    • Chaque message correspond à un appel de fonction (callback)

Event loop

Principes de base d’un moteur JS (non optimisé)

Fonctionnement de la queue

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

Traitement d'un message

  • déclenché quand la pile est vide
  • dans l'ordre d'arrivée des messages
  • création d'un frame dans la pile
  • “Run-to-completion”

Ajout de message

  • déclenché par un event listener
    • rappel : un événement sans listener est perdu (ex : click)

Event loop

Exemple 1 :

function timeoutLog() {
  console.log("Fin timeout");
}

function exemple1() {
  setTimeout(timeoutLog, 0);
  console.log("Fin test"); 
}

Exécution

Remarque : c'est la raison pour laquelle le deuxième argument de setTimeout (et setInterval) est une durée minimale et pas garantie…

Event loop

Exemple 2 :

monBouton.addEventListener('click', function () { console.log('Cliqué'); });

function exemple2() {
  let res = 0;
  for(let i=0; i < 1000000000; i++) {
    //calcul quelconque qui prend du temps mais n'ajoute rien à la pile
    res += 1 / (i * i);
  }
  console.log("Fini de calculer"); 
}

Exécution

Concurrence en single-thread

Plusieurs approches

  • Programmer en asynchrone
    • callbacks
    • promesses
  • Transformer du code synchrone en asynchrone
    • setTimeout, setInterval
    • async, defer

Dans tous les cas

  • L'ordre d'exécution est l'ordre d'arrivée des events dans la queue
  • On ne récupère les résultats qu'à la fin de l'exécution du script

Web workers

Objectifs

  • Permettre l'exécution de scripts
    • en tâche de fond dans un navigateur
    • indépendamment de l'interface utilisateur

Intérêt

  • L'utilisation de Web workers est indiquée si :
    • Un script
      • est consommateur de ressources et pourrait bloquer l'interface utilisateur
      • se déroule pendant une longue partie de l'utilisation de la page
    • Plusieurs scripts doivent fonctionner en parallèle

Aspects techniques

Les Web workers

  • possèdent leurs propres event loops
    • message queue, stack & heap
  • N'ont PAS accès
    • au DOM de la page
    • aux dispositifs de stockage (LocalStorage, SessionStorage)
  • ne sont pas interruptibles par des événements de l'interface utilisateur
  • communiquent avec la page principale à l'aide de messages

Limitations

  • L'utilisation de workers est coûteuse en ressources
    • Mémoire par instance
    • Puissance de calcul à la création
  • L'utilisation de Web workers n'est pas indiquée si :
    • Vos scripts doivent accéder à de nombreux éléments de la page
    • Vous devez faire tourner beaucoup de scripts en parallèle
    • Vos scripts ont une durée de vie courte

"For example, it would be inappropriate to launch one worker for each pixel of a four megapixel image."

Deux spécifications

  • Dedicated workers
    • Liés au script qui les a créés
    • Communiquent avec leur script “père” en utilisant un message port
  • Shared workers
    • Peuvent être partagés par tous les scripts de la même origine
    • Peuvent être créés avec un nom
    • N'importe quel autre script de la même origine peut obtenir une référence à un shared worker à l'aide de son nom ou de son URL
    • Communiquent avec d'autres scripts en utilisant des message ports

Communication avec un Web worker

  • Un MessageChannel est implicitement créé lors de l'instanciation / la connexion à un worker
  • Un MessagePort permet de communiquer de chaque côté
  • L'envoi de messages à travers ce channel est standardisée par l'interface Message

Exemple :

worker.onmessage = function (event) { ... };

worker.postMessage({localObject //to be cloned}, [buffer //to be transfered]);

Voir cours Web messaging pour plus de détails

Dedicated workers

Exemple

<body>
  <p>The highest prime number discovered so far is:
    <output id="result"></output>
  </p>
  <script>
    var worker = new Worker('worker.js');
    worker.onmessage = function (event) {
      document.getElementById('result').textContent = event.data;
    };
  </script>
</body>

Exemple

    • Le worker calcule les nombres premiers et envoie un message à l'interface à chaque résultat trouvé
let n = 1;
search: while (true) {
  n += 1;
  for (var i = 2; i <= Math.sqrt(n); i += 1)
    if (n % i == 0)
     continue search;
  // found a prime!
  postMessage(n);
}

Spécification (1/2)

L'interface WebIDL Worker (dedicated worker)

  • Hérite de EventTarget (interface DOM de gestion d'événements)
  • Implémente AbstractWorker (commune aux deux types de workers mais ne contient qu'une méthode onerror)
  • Définit l' “extérieur” d'un dedicated worker (la façon dont il est créé par un autre script)

Spécification (2/2)

  • Constructeur
    • Worker(DOMString scriptURL [, WorkerOptions options]) : crée un dedicated worker si l'URL correspond à un script valide
      • Les options permettent de spécifier un type d'importation de dépendances, un mode de gestion des credentials ou un nom
  • Attributs
    • onmessage : permet d'associer un handler d'événements “simple” au worker
  • Méthodes
    • terminate() : supprime les tâches de la file, arrête le script et vide la file de messages
    • postMessage() : envoie des données (chaînes de caractères, objets…) au worker

Cycle de vie

  • Création
    • Utilisation du constructeur de l'objet JavaScript
    • Paramètre
      • URL relative du fichier JS contenant le script
        nécessite un serveur Web pour vérifier qu'il provient bien de la même origine
var worker = new Worker('task.js');

ou

var worker = new Worker('task.js', { type: "module" });
  • Destruction
  • Depuis le script “père” : worker.terminate()
  • Depuis le worker : self.close() (cf. interface DedicatedWorkerGlobalScope)

Communication

Avec un worker depuis le script “père”

  • Récupération de données

avec onmessage

worker.onmessage = function (event) {
  ... //Récupération des données avec event.data 
};

avec addEventListener()

worker.addEventListener('message', function(event) { ... }, false);
  • Envoi de données
worker.postMessage('Hello World');

Communication

Avec le script “père” depuis un worker

  • Récupération de données

avec onmessage

onmessage = function (event) {
  ... //Récupération des données avec event.data 
};

avec addEventListener()

addEventListener('message', function(event) { ... }, false);
  • Envoi de données
postMessage('Hello World');

Communication

Avec le script “père” depuis un worker

  • Remarques
    • l'utilisation de onmessage et postMessage() sous-entend la référence à l'objet this (propriétés de l'objet Worker)
    • On peut aussi y accéder par le scope du worker en utilisant l'objet self : self.onmessage et self.postMessage()

Shared workers

Exemple

<pre id="log">Log:</pre>
<script>
  const worker = new SharedWorker('test.js');
  const log = document.getElementById('log');
  worker.port.addEventListener('message', function(e) {
    log.textContent += '\n' + e.data;
  }, false);
  worker.port.start();
  worker.port.postMessage('ping');
</script>
<iframe src="inner.html"></iframe>

Exemple

<pre id=log>Inner log:</pre>
<script>
  const worker = new SharedWorker('test.js');
  const log = document.getElementById('log');
  worker.port.onmessage = function(e) {
    log.textContent += '\n' + e.data;
  };
</script>

Exemple

let count = 0;
onconnect = function(e) {
  count += 1;
  var port = e.ports[0];
  port.postMessage('Hello World! You are connection #' + count);
  port.onmessage = function(e) {
    port.postMessage('pong');
  }
}

Spécification (1/2)

L'interface WebIDL SharedWorker

  • Hérite de EventTarget (interface DOM de gestion d'événements)
  • Implémente AbstractWorker (commune aux deux types de workers mais ne contient qu'une méthode onerror)
  • Définit l' “extérieur” d'un shared worker (la façon dont il est créé par un autre script)

Spécification (2/2)

  • Constructeur
    • SharedWorker(DOMString scriptURL, optional DOMString name) : crée un shared worker correspondant au nom name si l'URL correspond à un script valide
  • Attributs
    • port : renvoie le MessagePort associé au worker ; toutes les opérations de gestion (start() et close()) et de communication (onmessage() et postMessage()) sont faites à partir de cet objet

Cycle de vie

Création d'un shared worker

  • Utilisation du constructeur de l'objet JavaScript
  • Paramètres
    • URL relative du fichier JS contenant le script
      • nécessite un serveur Web pour vérifier qu'il provient bien de la même origine
    • String (optionnel) donnant un nom au worker
var worker = new Worker('task.js', 'worker1');

Cycle de vie

Création d'un shared worker

  • Remarques
    • Cas de plusieurs appels au même constructeur depuis des scripts différents
      1. Le premier appel crée le worker et le MessagePort associé
      2. Les suivants pointeront sur la même instance du worker et créent un nouveau MessagePort
    • Démarrage du MessagePort depuis le script appelant
      • Nécessaire uniquement par connexion avec addEventListener
worker.port.start();

Cycle de vie

Fermeture des ports

  • Utilisation de la méthode close() du MessagePort
  • Depuis le script appelant
worker.port.close();
  • Depuis le worker
var port = e.ports[0];
port.close();

Échange de messages

  • Côté script appelant
worker.port.onmessage;
... 
worker.port.postMessage()

Même fonctionnement que pour les dedicated workers

Échange de messages

  • Côté worker
    • Réception d'un événement connect par le worker
    • Accès au MessagePort à travers l'événement connect :

Premier (et seul) élément du tableau ports

let port;
onconnect = function(e) {
  port = e.ports[0];
  ...
}
...
port.onmessage = function(e) {
  port.postMessage(...); // not e.ports[0].postMessage!
  // e.target.postMessage('pong'); would work also
}

Notion de scope

The global scope is the "inside" of a worker.

Spécification

L'interface WebIDL WorkerGlobalScope

  • Hérite de EventTarget
  • Attributs
    • WorkerGlobalScope self : renvoie le scope du worker (permet d'y associer des handlers d'événements)
    • WorkerLocation location : renvoie l'URL absolue du script utilisé pour initialiser le worker (origine)
  • Méthode
    • close() : arrête les tâches courantes et met l'objet WorkerGlobalScope à null
  • Événements
    • onerror, onlanguagechange, onoffline, ononline
  • Remarque :
    • Interfaces dérivées : DedicatedWorkerGlobalScope et SharedWorkerGlobalScope

Spécification

L'interface WebIDL DedicatedWorkerGlobalScope

  • Hérite de WorkerGlobalScope
  • Spécifique aux dedicated workers
  • Méthode
    • postMessage() : …

Spécification

L'interface WebIDL SharedWorkerGlobalScope

  • Hérite de WorkerGlobalScope
  • Spécifique aux shared workers
  • Attributs
    • name : nom donné à la création du worker
    • applicationCache : gestion de l'application offline par le navigateur
    • onconnect : callback de l'événement connect;
      • appelé à chaque première connexion d'un autre script;
      • permet de gérer les connexions multiples au même worker

Autres APIs

Spécification

L'interface WebIDL WorkerUtils

  • Permet l'accès à des fonctionnalités (APIs) supplémentaires depuis les workers
  • Spécifique aux shared workers
  • Attribut
    • WorkerNavigator navigator : permet d'obtenir des informations sur le navigateur (user-agent id, online)
  • Méthode
    • importScripts(DOMString... urls) : permet de télécharger des bibliothèques de code (avec restriction d'origine)
importScripts('script1.js', 'script2.js');

Remarque : on peut aussi utiliser la directive JS import si on a déclaré un worker de type “module”

TIP

Synchronisation de données avec un shared worker

  • La spec recommande d'utiliser l'API IndexedDB pour partager des données entre un SW et ses clients

Service Workers

Principes

  • Un service worker est un type de worker particulier
    (hérite de EventTarget)
  • Il possède les mêmes caractéristiques de base que les dedicated & shared worker (URL, type, nom, message port…)
  • Il joue un rôle de proxy local
    • “intelligent” :
      • intercepte les requêtes réseau
      • sert les ressources en cache
      • cache et met à jour ces ressources de façon asynchrone quand l'appareil est connecté
    • pour des “clients” (scripts, browsing contexts) :
      • de la même origine
      • à l'intérieur du scope du worker

Cycle de vie

Source Digital Ocean

Exemple

Initialisation d'un worker (côté “client”)

if ('serviceWorker' in navigator) {
	navigator.serviceWorker.register('./sw.js', { scope: '/base_url/' })
	  .then(function(registration) {
		console.log('ServiceWorker registration successful with scope', registration.scope);
	})
	  .catch(function(error){
		console.log('ServiceWorker registration failed ', error);
	});
}

Remarques :

  • Tester la fonctionnalité navigator.serviceWorker (de type ServiceWorkerContainer) avant de l'utiliser
  • l'objet ServiceWorker possède un attribut onStateChange qui renvoie le ServiceWorkerState (état de fonctionnement : “installing”, “activated”…) du worker
  • Il vaut mieux placer le worker le plus “haut” possible dans la hiérarchie de fichiers, pour avoir le scope le plus large possible

Exemple

Initialisation du cache (côté worker)

const CACHE_NAME = 'v1';
const CACHE_URLs = [ '/base_url/', '/base_url/index.html', '/base_url/css/style.css', '/base_url/js/app.js','https://code.jquery.com/jquery-3.4.1.min.js', ... ];

self.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function(cache) {
      return cache.addAll(CACHE_URLs);
    })
  );
});

Remarques :

  • L'objet cache est de type CacheStorage ; l'interface CacheStorage permet d'accéder à un tableau d'objets Cache
  • Les ressources doivent être requêtées depuis une page / un script faisant partie du scope du worker

Exemple (simple)

Interception d'un FetchEvent et renvoi des ressources en cache (côté worker)

self.addEventListener('fetch', function(event) {
  console.log('Intercepted fetch request: ', event.request.url);
  event.respondWith(
    caches.match(event.request).then(function(response) {
        if (response) {
          console.log('Response found in cache.');
          return response;
        }
        console.log('Response NOT found in cache. Fetching...');
        return fetch(event.request);
      })
    );
});

Remarques :

  • caches.match() cherche dans tous les caches si la réponse est disponible
  • event.respondWith() intercepte un fetch() et renvoie une promesse

Exemple (plus complet)

Interception d'un FetchEvent, renvoi des ressources en cache et caching des autres (côté worker)

self.addEventListener('fetch', (e) => {
  e.respondWith(
    caches.match(e.request).then((r) => {
      if (response) {  return response;  }
      
      console.log('Response NOT found in cache. Fetching...');
      return fetch(e.request).then((response) => {
        return caches.open(cacheName).then((cache) => {
          console.log('...and caching new resource: '+e.request.url);
          cache.put(e.request, response.clone());
          return response;
        });
      });
    })
  );
});

Remarques :

  • Le nom du cache sert à mettre à jour les ressources cachées
  • Il faut cloner la réponse à la requête fetch

Spécification

L'interface WebIDL ServiceWorker

    • Gestion du cycle de vie : install et activate
    • Fonctionnels :
      • Interception des requêtes (spécifique) fetch
      • Mise à jour (du cache, d'une autre ressource ; générique) : push, sync
    • Autres : message, messageerror
  • Méthode
    • self.serviceWorker.postMessage()

Stratégies de gestion du cache (1/4)

Compromis entre fraîcheur / performance / offline

  • Cache sinon Fetch :

return response || fetch(event.request)

  • Fetch sinon cache :

fetch(event.request).catch(function() {
      return caches.match(event.request);
})

Stratégies de gestion du cache (2/4)

Mise à jour du cache asynchrone

  • Peut être déclenchée
    • à intervalles réguliers (timeouts)
    • en fonction d'événements (passage online, click…)

Mise à jour du contenu de la page

  • Workflows
    • à la réception d'une réponse réseau (classique)
    • à l'initiative des scripts de la page
    • en passant par le service worker (postMessage)
    • directement du cache à la page (notification)

Plus de détails ici

Stratégies de gestion du cache (3/4)

Utilisation d'un Service Worker avec IndexedDB (source)

  • Le DOM et les objets WebStorage ne sont pas accessibles
  • IndexedDB l'est (transactions)

Ajout de données

self.addEventListener('activate', function(event) {
  event.waitUntil(
      idb.open('products', 1, function(upgradeDB) {
        var store = upgradeDB.createObjectStore('beverages', {keyPath:'id'});
        store.put({id: 123, name: 'coke', price: 10.99, quantity: 200});
      });
    );
});
Récupération de données
function readDB() {
  idb.open('products', 1).then(function(db) {
    var tx = db.transaction(['beverages'], 'readonly');
    var store = tx.objectStore('beverages');
    return store.getAll();
  }).then(function(items) { ... });
}

Stratégies de gestion du cache (4/4)

Fallbacks

caches.match(event.request).then(function(response) {
  // récupérer la réponse depuis Fetch / Cache
  if (response.status === 404) {
    return caches.match('pages/404.html');
  }
  return response;
}).catch(function() {
  return caches.match('/offline.html');

Éléments de sécurité

  • Les Serviceworkers ne fonctionnent qu'en HTTPS (ou en localhost)
  • Les requêtes vers le cache proviennent du service worker ; à ce titre, ce sont des requêtes CORS provenant de l'origine de la page → les réponses sont refusées si elles ne contiennent pas les “bons” headers CORS
  • En préparation : headers & type MIME spécifiques pour les service workers…

Références