Principes de base des workers Web

Le problème: simultanéité JavaScript

Un certain nombre de goulots d'étranglement empêchent le portage d'applications intéressantes (par exemple, d'implémentations utilisant beaucoup de serveurs) vers JavaScript côté client. Parmi ceux-ci figurent la compatibilité des navigateurs, la saisie statique, l'accessibilité et les performances. Heureusement, cette dernière approche est en passe de disparaître, car les fournisseurs de navigateurs améliorent rapidement la vitesse de leurs moteurs JavaScript.

Une chose qui reste un obstacle pour JavaScript est en fait le langage lui-même. JavaScript est un environnement à thread unique, ce qui signifie que plusieurs scripts ne peuvent pas s'exécuter en même temps. Par exemple, imaginez un site qui doit gérer les événements d'interface utilisateur, interroger et traiter de grandes quantités de données d'API, et manipuler le DOM. Assez courant, n’est-ce pas ? Malheureusement, tout cela ne peut pas être simultané en raison des limites d'exécution JavaScript des navigateurs. L'exécution du script s'effectue dans un seul thread.

Les développeurs imitent la "simultanéité" à l'aide de techniques telles que setTimeout(), setInterval() et XMLHttpRequest, ainsi que de gestionnaires d'événements. Oui, toutes ces fonctionnalités s'exécutent de manière asynchrone. Le non-blocage ne signifie pas nécessairement la simultanéité. Les événements asynchrones sont traités une fois que le script en cours d'exécution a abouti. La bonne nouvelle, c'est que HTML5 nous offre quelque chose de mieux que ces astuces !

Présentation des Web Workers: intégrer les threads dans JavaScript

La spécification Web Workers définit une API pour générer des scripts d'arrière-plan dans votre application Web. Ils vous permettent, par exemple, de lancer des scripts de longue durée pour traiter des tâches qui utilisent beaucoup de ressources de calcul, sans bloquer l'interface utilisateur ou d'autres scripts pour gérer les interactions utilisateur. Ils vont nous aider à mettre fin à cette horrible boîte de dialogue "script qui ne répond pas" que nous avons tous aimés:

Boîte de dialogue du script qui ne répond pas
Boîte de dialogue courante avec un script qui ne répond pas

Les nœuds de calcul utilisent la transmission de messages de type thread pour atteindre le parallélisme. Ils sont parfaits pour maintenir l'actualisation de votre interface utilisateur, performante et réactive pour les utilisateurs.

Types de Web Workers

Il est à noter que la spécification traite de deux types de nœuds de calcul Web : les nœuds de calcul dédiés et les nœuds de calcul partagés. Cet article ne concerne que les nœuds de calcul dédiés. Je les appellerai "nœuds de calcul Web" ou "nœuds de calcul".

Premiers pas

Les Web Workers s'exécutent dans un thread isolé. Par conséquent, le code qu'ils exécutent doit être contenu dans un fichier distinct. Mais avant cela, la première chose à faire est de créer un objet Worker sur votre page principale. Le constructeur prend le nom du script de nœud de calcul:

var worker = new Worker('task.js');

Si le fichier spécifié existe, le navigateur génère un nouveau thread de nœud de calcul, qui est téléchargé de manière asynchrone. Le worker ne démarrera pas tant que le téléchargement et l'exécution du fichier ne seront pas terminés. Si le chemin d'accès au nœud de calcul renvoie une erreur 404, il échoue en mode silencieux.

Après avoir créé le worker, démarrez-le en appelant la méthode postMessage():

worker.postMessage(); // Start the worker.

Communiquer avec un worker via la transmission de messages

La communication entre une tâche et sa page parente s'effectue à l'aide d'un modèle d'événement et de la méthode postMessage(). Selon votre navigateur ou votre version, postMessage() peut accepter une chaîne ou un objet JSON comme argument unique. Les dernières versions des navigateurs récents acceptent la transmission d'un objet JSON.

Vous trouverez ci-dessous un exemple d'utilisation d'une chaîne pour transmettre "Hello World" à un worker dans doWork.js. Le nœud de calcul renvoie simplement le message qui lui est transmis.

Script principal:

var worker = new Worker('doWork.js');

worker.addEventListener('message', function(e) {
console.log('Worker said: ', e.data);
}, false);

worker.postMessage('Hello World'); // Send data to our worker.

doWork.js (le nœud de calcul):

self.addEventListener('message', function(e) {
self.postMessage(e.data);
}, false);

Lorsque postMessage() est appelé à partir de la page principale, notre nœud de calcul traite ce message en définissant un gestionnaire onmessage pour l'événement message. La charge utile du message (dans ce cas "Hello World") est accessible dans Event.data. Bien que cet exemple particulier ne soit pas très intéressant, il montre que postMessage() est également votre moyen de transmettre des données au thread principal. Pratique !

Les messages transmis entre la page principale et les workers sont copiés, mais ne sont pas partagés. Par exemple, dans l'exemple suivant, la propriété "msg" du message JSON est accessible aux deux emplacements. Il semble que l'objet soit transmis directement au nœud de calcul, même s'il s'exécute dans un espace dédié distinct. En réalité, l'objet est sérialisé au fur et à mesure qu'il est transmis au nœud de calcul, puis désérialisé à l'autre extrémité. La page et le nœud de calcul ne partagent pas la même instance. Par conséquent, un doublon est créé à chaque carte. La plupart des navigateurs implémentent cette fonctionnalité en codant/décodant automatiquement la valeur JSON à chaque extrémité.

Voici un exemple plus complexe qui transmet des messages à l'aide d'objets JSON.

Script principal:

<button onclick="sayHI()">Say HI</button>
<button onclick="unknownCmd()">Send unknown command</button>
<button onclick="stop()">Stop worker</button>
<output id="result"></output>

<script>
function sayHI() {
worker.postMessage({'cmd': 'start', 'msg': 'Hi'});
}

function stop() {
// worker.terminate() from this script would also stop the worker.
worker.postMessage({'cmd': 'stop', 'msg': 'Bye'});
}

function unknownCmd() {
worker.postMessage({'cmd': 'foobard', 'msg': '???'});
}

var worker = new Worker('doWork2.js');

worker.addEventListener('message', function(e) {
document.getElementById('result').textContent = e.data;
}, false);
</script>

doWork2.js:

self.addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    self.postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
    self.postMessage('WORKER STOPPED: ' + data.msg +
                    '. (buttons will no longer work)');
    self.close(); // Terminates the worker.
    break;
default:
    self.postMessage('Unknown command: ' + data.msg);
};
}, false);

Objets transférables

La plupart des navigateurs implémentent l'algorithme de clonage structuré, qui vous permet de transmettre des types plus complexes dans et hors des workers, tels que File, Blob, ArrayBuffer et des objets JSON. Toutefois, lors de la transmission de ces types de données à l'aide de postMessage(), une copie est toujours effectuée. Par conséquent, si vous transmettez un fichier volumineux de 50 Mo (par exemple), le transfert de ce fichier entre le nœud de calcul et le thread principal engendre une surcharge notable.

Le clonage structuré est idéal, mais une copie peut prendre des centaines de millisecondes. Pour combattre les attaques de performances, vous pouvez utiliser des objets transférables.

Avec les objets transférables, les données sont transférées d'un contexte à un autre. Il s'agit d'une copie zéro, ce qui améliore considérablement les performances d'envoi de données à un nœud de calcul. Considérez cela comme une référence de passement si vous êtes du monde C/C++. Cependant, contrairement à la méthode pass par référence, la "version" du contexte d'appel n'est plus disponible une fois transférée vers le nouveau contexte. Par exemple, lors du transfert d'un ArrayBuffer de votre application principale vers un worker, le ArrayBuffer d'origine est effacé et n'est plus utilisable. Son contenu est transféré (littéralement, silencieuse) vers le contexte du nœud de calcul.

Pour utiliser des objets transférables, utilisez une signature postMessage() légèrement différente:

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

Le cas du nœud de calcul, le premier argument concerne les données et le second est la liste des éléments à transférer. Le premier argument ne doit pas nécessairement être un ArrayBuffer. Il peut s'agir, par exemple, d'un objet JSON:

worker.postMessage({data: int8View, moreData: anotherBuffer},
                [int8View.buffer, anotherBuffer]);

Le point important est que le deuxième argument doit être un tableau de ArrayBuffers. Voici votre liste d'éléments transférables.

Pour en savoir plus sur les utilisateurs transférables, consultez notre post sur developer.chrome.com.

Environnement de nœud de calcul

Champ d'application des nœuds de calcul

Dans le contexte d'un nœud de calcul, self et this font tous deux référence au champ d'application global du nœud de calcul. Ainsi, l'exemple précédent pourrait également être écrit comme suit:

addEventListener('message', function(e) {
var data = e.data;
switch (data.cmd) {
case 'start':
    postMessage('WORKER STARTED: ' + data.msg);
    break;
case 'stop':
...
}, false);

Vous pouvez également définir le gestionnaire d'événements onmessage directement (bien que addEventListener soit toujours recommandé par les ninjas JavaScript).

onmessage = function(e) {
var data = e.data;
...
};

Fonctionnalités disponibles pour les workers

En raison de leur comportement multithread, les Web Workers n'ont accès qu'à un sous-ensemble des fonctionnalités JavaScript:

  • L'objet navigator
  • L'objet location (lecture seule)
  • XMLHttpRequest
  • setTimeout()/clearTimeout() et setInterval()/clearInterval()
  • Le cache d'application
  • Importer des scripts externes à l'aide de la méthode importScripts()
  • Générer d'autres workers Web

Les nœuds de calcul n'ont PAS accès aux éléments suivants:

  • Le DOM (non sécurisé)
  • L'objet window
  • L'objet document
  • L'objet parent

Chargement de scripts externes

Vous pouvez charger des bibliothèques ou des fichiers de script externes dans un nœud de calcul à l'aide de la fonction importScripts(). Cette méthode utilise zéro, une ou plusieurs chaînes représentant les noms de fichiers des ressources à importer.

Cet exemple charge script1.js et script2.js dans le nœud de calcul:

worker.js:

importScripts('script1.js');
importScripts('script2.js');

Ce qui peut également être écrit sous la forme d'une instruction d'importation unique:

importScripts('script1.js', 'script2.js');

Sous-nœuds de calcul

Les nœuds de calcul ont la possibilité de générer des nœuds de calcul enfants. C'est la solution idéale pour scinder davantage de tâches importantes au moment de l'exécution. Les sous-nœuds de calcul s'accompagnent toutefois de quelques mises en garde:

  • Les nœuds de calcul secondaires doivent être hébergés dans la même origine que la page parente.
  • Les URI des sous-nœuds de calcul sont résolus en fonction de l'emplacement du collaborateur parent (et non de la page principale).

N'oubliez pas que la plupart des navigateurs génèrent des processus distincts pour chaque nœud de calcul. Avant de créer une ferme d'employés, méfiez-vous de l'encombrement excessif des ressources système de l'utilisateur. En effet, les messages transmis entre les pages principales et les nœuds de calcul sont copiés, et non partagés. Voir Communiquer avec un worker via la transmission de messages.

Pour savoir comment générer un nœud de calcul secondaire, consultez cet exemple dans la spécification.

Nœuds de calcul intégrés

Comment procéder si vous souhaitez créer votre script de nœud de calcul à la volée ou créer une page autonome sans avoir à créer des fichiers de nœuds de calcul distincts ? Avec Blob(), vous pouvez "intégrer" votre nœud de calcul dans le même fichier HTML que votre logique principale en créant un handle d'URL dans le code du nœud de calcul sous forme de chaîne:

var blob = new Blob([
"onmessage = function(e) { postMessage('msg from worker'); }"]);

// Obtain a blob URL reference to our worker 'file'.
var blobURL = window.URL.createObjectURL(blob);

var worker = new Worker(blobURL);
worker.onmessage = function(e) {
// e.data == 'msg from worker'
};
worker.postMessage(); // Start the worker.

URL des objets blob

La magie opère grâce à l'appel de window.URL.createObjectURL(). Cette méthode crée une chaîne d'URL simple qui peut être utilisée pour référencer des données stockées dans un objet DOM File ou Blob. Exemple :

blob:http://localhost/c745ef73-ece9-46da-8f66-ebes574789b1

Les URL d'objets blob sont uniques et durent toute la durée de vie de votre application (par exemple, jusqu'au déchargement de l'élément document). Si vous créez de nombreuses URL blob, il est recommandé de libérer les références qui ne sont plus nécessaires. Vous pouvez libérer explicitement une URL Blob en la transmettant à window.URL.revokeObjectURL():

window.URL.revokeObjectURL(blobURL);

Dans Chrome, il existe une page intéressante pour afficher toutes les URL d'objets blob créés: chrome://blob-internals/.

Exemple complet

Pour aller plus loin, nous pouvons assimiler la façon dont le code JS du nœud de calcul est intégré à notre page. Cette technique utilise un tag <script> pour définir le nœud de calcul:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>

<div id="log"></div>

<script id="worker1" type="javascript/worker">
// This script won't be parsed by JS engines
// because its type is javascript/worker.
self.onmessage = function(e) {
    self.postMessage('msg from worker');
};
// Rest of your worker code goes here.
</script>

<script>
function log(msg) {
    // Use a fragment: browser will only render/reflow once.
    var fragment = document.createDocumentFragment();
    fragment.appendChild(document.createTextNode(msg));
    fragment.appendChild(document.createElement('br'));

    document.querySelector("#log").appendChild(fragment);
}

var blob = new Blob([document.querySelector('#worker1').textContent]);

var worker = new Worker(window.URL.createObjectURL(blob));
worker.onmessage = function(e) {
    log("Received: " + e.data);
}
worker.postMessage(); // Start the worker.
</script>
</body>
</html>

À mon avis, cette nouvelle approche est un peu plus claire et plus lisible. Il définit un tag de script avec id="worker1" et type='javascript/worker' (afin que le navigateur n'analyse pas le code JS). Ce code est extrait en tant que chaîne à l'aide de document.querySelector('#worker1').textContent et transmis à Blob() pour créer le fichier.

Chargement de scripts externes

Lorsque vous utilisez ces techniques pour intégrer votre code de nœud de calcul, importScripts() ne fonctionne que si vous fournissez un URI absolu. Si vous tentez de transmettre un URI relatif, le navigateur renverra une erreur de sécurité. En effet, le nœud de calcul (désormais créé à partir d'une URL blob) sera résolu avec un préfixe blob:, alors que votre application s'exécutera à partir d'un autre schéma (probablement http://). L'échec est donc dû à des restrictions multi-origines.

Une façon d'utiliser importScripts() dans un nœud de calcul intégré consiste à "injecter" l'URL actuelle de votre script principal en la transmettant au nœud de calcul intégré et en créant manuellement l'URL absolue. Cela permettra d'importer le script externe à partir de la même origine. En supposant que votre application principale s'exécute à partir de http://example.com/index.html:

...
<script id="worker2" type="javascript/worker">
self.onmessage = function(e) {
var data = e.data;

if (data.url) {
var url = data.url.href;
var index = url.indexOf('index.html');
if (index != -1) {
    url = url.substring(0, index);
}
importScripts(url + 'engine.js');
}
...
};
</script>
<script>
var worker = new Worker(window.URL.createObjectURL(bb.getBlob()));
worker.postMessage(<b>{url: document.location}</b>);
</script>

de gestion des erreurs

Comme pour toute logique JavaScript, vous devez gérer les erreurs générées par vos workers Web. Si une erreur se produit pendant l'exécution d'un worker, une ErrorEvent est déclenchée. L'interface contient trois propriétés utiles pour comprendre le problème: filename - le nom du script de nœud de calcul à l'origine de l'erreur, lineno - le numéro de la ligne où l'erreur s'est produite et message - une description explicite de l'erreur. Voici un exemple de configuration d'un gestionnaire d'événements onerror pour afficher les propriétés de l'erreur:

<output id="error" style="color: red;"></output>
<output id="result"></output>

<script>
function onError(e) {
document.getElementById('error').textContent = [
    'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join('');
}

function onMsg(e) {
document.getElementById('result').textContent = e.data;
}

var worker = new Worker('workerWithError.js');
worker.addEventListener('message', onMsg, false);
worker.addEventListener('error', onError, false);
worker.postMessage(); // Start worker without a message.
</script>

Exemple: workerWithError.js tente d'exécuter 1/x, où x n'est pas défini.

// À FAIRE: DevSite – Exemple de code supprimé, car il utilisait des gestionnaires d'événements intégrés

workerWithError.js:

self.addEventListener('message', function(e) {
postMessage(1/x); // Intentional error.
};

Un mot sur la sécurité

Restrictions avec accès local

En raison des restrictions de sécurité de Google Chrome, les nœuds de calcul ne s'exécuteront pas localement (par exemple, depuis file://) dans les dernières versions du navigateur. Au lieu de cela, ils échouent silencieusement. Pour exécuter votre application à partir du schéma file://, exécutez Chrome avec l'indicateur --allow-file-access-from-files défini.

Les autres navigateurs n'imposent pas la même restriction.

Considérations relatives à la même origine

Les scripts de nœud de calcul doivent être des fichiers externes ayant le même schéma que leur page d'appel. Ainsi, vous ne pouvez pas charger un script à partir d'une URL data: ou javascript:, et une page https: ne peut pas démarrer de scripts de nœud de calcul commençant par des URL http:.

Cas d'utilisation

Quel genre d'application utiliserait les Web workers ? Voici quelques idées supplémentaires pour stimuler la perte de mémoire:

  • Préchargement et/ou mise en cache des données pour une utilisation ultérieure
  • Mise en surbrillance de la syntaxe du code ou autre mise en forme de texte en temps réel.
  • Vérificateur orthographique.
  • Analyser des données vidéo ou audio
  • E/S en arrière-plan ou interrogation de services Web.
  • Traitement de grands tableaux ou de réponses JSON généreuses.
  • Filtrage d'images dans <canvas>.
  • Mettre à jour de nombreuses lignes d'une base de données Web locale

Pour en savoir plus sur les cas d'utilisation impliquant l'API Web Workers, consultez la présentation des workers.

Démonstrations

Références