Enfin des web-workers developer-friendly
Après avoir réécrit RxJS dans un précédent article, je vous propose de réécrire une nouvelle librairie JavaScript. J’ai nommé Comlink.
Les web workers #
Les web workers sont une fonctionnalité JavaScript qui permet aux développeurs d’exécuter du code dans des threads séparés du thread principal de l’application web. Voici quelques-uns des avantages à utiliser les web workers :
- Amélioration des performances : Les web workers permettent de répartir la charge de travail sur plusieurs threads, ce qui peut améliorer les performances de l’application en libérant le thread principal pour d’autres tâches.
- Exploitation du matériel : Les web workers permettent de tirer parti des processeurs multi-cœurs et de l’accélération matérielle pour effectuer des tâches intensives en ressources de manière plus efficace.
- Gestion des tâches longues : Les web workers sont particulièrement utiles pour effectuer des tâches longues et intensives en ressources, telles que l’analyse de données, la conversion de formats de fichiers, la génération de graphiques, etc.
En somme, ils nous aident à améliorer les performances de nos applications en exploitant le multithreading.
Mais les web workers sont une fonctionnalité mal aimée des développeurs web et très peu utilisée. Cela est dû, selon moi, en grande partie aux difficultées à communiquer avec eux.
Communication #
Les web workers communiquent avec le reste du monde par message. Ils peuvent recevoir et envoyer des messages avec les threads environnants. Ces messages sont envoyés via la méthode postMessage()
et reçus via l’event listener onmessage
. Les messages peuvent être de n’importe quel type de données sérialisable, tels que des chaînes de caractères, des tableaux, des objets, etc.
Voici un exemple de code pour envoyer un message à un web worker :
app.js (main thread)
|
|
worker.js (thread séparé)
|
|
Pour une opération simple comme ici, l’utilisation d’un message bus n’est pas vraiment un problème. Mais la complexité augmente rapidement dès que l’on a plusieurs types de message à gérer. Imaginons un compteur que l’on veut déporter sur le worker. On veut avec ce compteur pouvoir:
- Accéder à la valeur courante
- L’incrémenter
- Le décrémenter
- Le remettre à zéro
Immédiatement, notre worker va devoir se mettre à différencier l’opération qu’il réalise en fonction du type de message reçu.
worker.js (thread séparé)
|
|
On observe que rapidement, même si ce n’est rien d’insurmontable, communiquer par message uniquement est une contrainte importante. Ecrire du code est plus pénible et moins lisible que lorsqu’on développe dans un thread unique ou l’on peut facilement appeler des fonctions et lire des variables.
Comlink #
Comlink est une librairie JS écrite par Google qui vise à simplifier les interactions entre main thread et web worker.
Comlink is a tiny library (1.1kB), that removes the mental barrier of thinking about postMessage and hides the fact that you are working with workers.
L’idée est de masquer entièrement l’API basée sur les messages pour proposer aux développeurs de développer comme si les méthodes et variables exposées par le worker étaient locales au main thread.
Prenons un exemple tout de suite pour bien comprendre.
main.js
|
|
worker.js
|
|
Dans le fichier main.js
, le worker est enveloppé pour exposer son contenu via la variable obj
. Par cette variable on accède à counter
et inc()
comme on l’aurait fait pour une fonction classique. Il y a une différence notable, il faut tout await
. Même l’accès à des propriétés ou les méthodes synchrone à première vue. En effet, Comlink masque l’envoi de messages asynchrones via postMessage()
, mais ils existent bel et bien. L’utilisation systématique de promesses est donc incontournable.
Si vous aussi en voyant ça vous vous demander “mais comment cette librairie peut-elle bien fonctionner ?”, alors ne bougez pas on va voir ça tout de suite.
Implémentons #
A la découverte des Proxy #
Il est temps d’implémenter notre propre version de Comlink. Reprenons le début du code et voyons ce dont nous avons a besoin.
|
|
On va devoir envelopper le Worker
pour que d’une manière ou d’une autre il se mette à exposer une propriété counter
et une méthode inc()
.
Evidemment un Worker
n’a pas nativement ces propriétés. Et les lui ajouter n’aurait pas vraiment de sens. On va devoir faire appel à une fonctionnalité JS relativement méconnue que sont les Proxys.
MDN définit les Proxys comme suit:
Un objet Proxy permet de créer un intermédiaire pour un autre objet qui peut intercepter et redéfinir certaines opérations fondamentales pour lui.
Notre idée donc ici est de créer un intermédiaire pour notre Worker
qui soit capable d’intercepter l’appel à counter
et inc()
. En interceptant cet appel nous pourrons fournir notre propre implémentation qui sous le capot utilisera l’API postMessage()
, et retournera la réponse sans que l’utilisateur n’ait conscience de rien.
La création d’un objet Proxy se fait avec deux paramètres. Le target, l’objet original devant lequel on veut placer un intermédiaire et un handler, un objet qui définit les opérations qui seront interceptées et comment celles-ci seront redéfinies.
Le target est utile quand on veut modifier un comportement existant, en ajoutant un log à chaque appel par exemple. Dans notre cas on part complètement de zéro: le worker qu’on enveloppe n’a aucune des propriétés et méthodes que l’on va appeler sur lui. Nous pouvons donc arbitrairement choisir un object vide comme target.
Essayons
|
|
Pour intercepter les opérations, notre handler va pouvoir définir différents pièges ou trap. Ces pièges vont intercepter les appels aux Object internal methods, les méthods internes à chaque objet, et nous permettre de modifier leur comportement.
Pour bien comprendre comment ça fonctionne, imaginons l’object suivant.
const foo = { bar: 1 };
Voyons maintenant les chemins possibles lorsqu’on appelle foo.bar.func()
.
Le chemin théorique d’abord qui tombera en erreur puisque bar
n’a pas de propriété func()
. Puis le chemin que l’on va pouvoir générer grâce aux proxys ensuite, qui lui va simuler que tout existe comme prévu et pouvoir avoir le comportement que l’on désire.
[[Get]](bar)"| B(foo.bar) B -->|"[[Get]](func)"| C[undefined] C-->|"[[Call]]"| D{"Erreur
foo.bar.func is
not a function"} A -->|"Chemin grâce aux proxys
Trap [[Get]]"| E(Proxy 1) E -->|"Trap [[Get]]"| F(Proxy 2) F -->|"Trap [[Call]]"| G(Call worker)
Il faut bien comprendre que notre trap est appelé à la place des méthodes qu’il piège. Il nous donne donc la possibilité de faire “comme si” foo.bar.func
existait, même si l’objet bar
n’a pas de propriété func
.
C’est exactement le principe que l’on va utiliser quand on eveloppera notre worker: on va faire comme si une propriété counter
et une méthode inc()
existaient.
Côté main thread #
Reprenons l’implémentation. On vient de le voir, il va falloir créer des proxys. Et pas qu’un seul: si j’appelle a.b.c()
alors que a
n’a aucune de ces propriétés, il va falloir qu’à chaque niveau d’appel nous ayons un proxy capable de faire comme si. Créons donc un helper createProxy()
pour nous aider.
|
|
Ce proxy va d’abord devoir implémenter les traps pour l’accès aux propriétés d’un objet. C’est le trap get()
.
|
|
L’accès à une propriété doit gérer deux cas:
- Soit c’est la propriété que l’on souhaite obtenir et il faut retourner une valeur
Mais comment savoir dans quel cas nous nous trouvons ? Nous allons nous aider de la contrainte asynchrone mentionnée plus haut. On l’a dit, de par la nature de postMessage()
nous aurons obligatoirement le résultat via une promesses.
Ainsi, tout appel à a.b
sera forcément de la forme a.b.then()
. Et ce même si vous l’écrivez sous la await a.b
. Ce n’est que du sucre syntaxique qui cache un appel à then()
.
Et puisqu’on est certain que si l’utilisateur souhaite accéder à a.b
il écrira a.b.then()
, on peut intercepter l’accès à .then
et déduire que la propriété précédente était la propriété à laquelle on souhaite accéder.
- Soit c’est une étape intermédiaire et on souhaite accéder à une propriété plus profonde ou une fonction.
Dans ce cas là il faut retourner un nouveau Proxy. Celui-ci sera chargé d’intercepter l’appel du niveau suivant. Une seule subtilité, il faut bien sûr garder le chemin parcouru jusque là.
Voyons comment tout ca se retranscrit dans le code.
|
|
Réfléchissons désormais à quoi pourrait ressembler le fait de récupérer une valeur. J’ai appelé la fonction dans le code ci-dessus requestResponseMessage
.
Puisque c’est cette fonction qui est chargée de récupérer la valeur, c’est elle qui masque l’envoi d’un message au worker. Elle a les contraintes suivantes:
- C’est là que notre appel à
postMessage()
se fait pour transmettre la demande au worker. - Il faut démarrer un listener pour attendre la réponse du worker. Cette réponse est asynchrone donc retournée sous la forme d’une promesse.
- Comme le worker peut gérer plusieurs demandes en parallèle, on a besoin d’identifier notre demande. On utilise un uuid.
|
|
On y est presque, mais il reste quand même un problème. Souvenez-vous, dans la méthode createProxy()
, pour savoir s’il fallait retourner une valeur on a “rusé” en testant si la propriété que l’on lisait était le then
. Pour être cohérent avec ça, il ne faut pas retourner notre promesse de réponse directement mais la propriété .then
de notre promesse.
Et il y a une petite subtilité supplémentaire: pour que then()
fonctionne correctement, il a besoin d’avoir pour contexte la promesse initiale. En effet, en interne, then()
utilise this
en partant du principe que ce this
correspond à la promesse sur laquelle il travaille. Il faut donc utiliser bind()
pour créer une nouvelle fonction avec ces caractéristiques.
|
|
Pour les accès aux propriétés, c’est bon ! Passons désormais aux appels de fonction. Souvenez-vous, on a besoin de obj.inc()
.
Pour ça il va falloir faire appel à un nouveau trap appelé apply()
. Ici rien de sorcier. On intercepte l’appel et on renvoie directement la promesse. then
sera bien appelé par la suite, donc il n’y a aucune manipulation particulière à réaliser.
Une nouvelle petite subtilité tout de même. On réalise ici qu’utiliser un objet vide comme target de notre Proxy ne fonctionne pas. Ce n’est pas documenté sur MDN, mais la spec précise bien le suivant:
A Proxy exotic object only has a [[Call]] internal method if the initial value of its [[ProxyTarget]] internal slot is an object that has a [[Call]] internal method.
C’est à dire qu’un proxy ne peut intercepter un [[Call]]
que si son target a lui même une méthode interne [[Call]]
. Or un objet vide n’est pas callable. Notre trap apply()
ne va donc pas fonctionner. Heureusement, le fix est facile: au lieu d’utiliser un objet vide, nous allons utiliser… une fonction vide ! Qui elle est bien sûr callable
|
|
Notre code côté main thread est terminé et nous sommes presque prêts à tester notre code. Pour que ça fonctionne et même si on ne regarde pas encore le côté worker, on a tout de même besoin d’un code minimal pour vérifier que nos appels fonctionnent.
worker.js
|
|
Ca y est, nous sommes désormais capable d’intercepter correctement les appels aux propriétés et aux méthodes et de les transférer au Worker pour demander une réponse. Il ne nous reste plus qu’à gérer cette réponse correctement côté worker.
Côté worker #
Pas de magie particulière côté worker. Pour rappel, voici le code qu’on essaye de faire fonctionner.
|
|
La méthode expose
va écouter les messages du main thread, analyser les paramètres de la demande et les convertir en opérations locales afin d’y répondre. Elle prend bien entendu en paramètre l’objet à exposer, qui sera l’objet sur lequel elle va “travailler”.
Il n’y a rien de particulier à expliquer ici, donc je vous mets directement le code commenté.
|
|
Récapitulons tout de même brièvement:
- On démarre le listener pour écouter les messages provenant du main thread.
- Grâce aux paramètres passés par le message, on sait exactement quelle opération il va falloir appliquer.
- Comme le résultat de cette opération peut être une promesse et qu’il nous faut retourner au main thread une valeur sérialisable, on enveloppe le résultat calculé dans un
Promise.resolve()
pour s’assurer qu’on aura bien une valeur disponible. - Il ne nous reste plus qu’à renvoyer la valeur au main thread. En n’oubliant pas d’ajouter l’UUID à notre réponse pour qu’il sache à quoi nous répondons.
Et c’est terminé ! Nous venons de développer notre Comlink maison.
Conclusion #
Evidemment, Comlink c’est plus que ça. D’abord par ce que nous sommes loins de gérer tous les cas: erreurs, constructeurs, assignation de valeur à des propriétés… Les cas possibles sont nombreux. L’API de Comlink est également plus large que simplement les méthodes expose
et wrap
, ceci pour permettre de gérer des cas plus complexes. Pour découvrir tout ça je vous invite à consulter le code source sur GitHub.
Mais le but souhaité est je crois atteint. Nous avons compris les grands principes qui régissaient le fonctionnement de Comlink, et nous avons le savons traduit dans le code en quelques lignes. Cette découverte nous a permis de rentrer en profondeur dans le fonctionnement des Proxys, et de leur trouver un cas d’usage parfait - ce qui n’est pas si évident !