WebAssembly fait parler de lui, y compris en dehors du navigateur. Cet engouement n’est pas seulement lié à un environnement d’exécution WebAssembly isolé mais aussi parce qu’on peut exécuter du code WebAssembly depuis des langages comme Python, Ruby ou Rust.

Pour quoi faire ? Voici quelques raisons :

  • Rendre les modules « natifs » moins compliqués
    Les environnements d’exécution tels que Node ou CPython pour Python permettent également d’écrire des modules dans des langages bas niveau tels que C++. Une telle approche permet de profiter de la vitesse de ces langages bas niveaux. On peut ainsi utiliser des modules natifs en Node ou des modules d’extension en Python. Toutefois ces modules sont souvent difficiles à utiliser car ils doivent être compilés sur l’appareil de l’utilisateur. Avec un module « natif » WebAssembly, on obtient une bonne partie de cette vitesse sans compliquer la mise en œuvre.
  • Isoler plus facilement le code natif dans des bacs à sable
    D’un autre côté, pour des langages bas niveau tels que Rust, pas besoin d’utiliser WebAssembly pour gagner de la vitesse. En revanche, cela peut servir pour la sécurité. Comme nous en parlions lors de l’annonce de WASI, WebAssembly fournit un bac à sable léger par défaut et un langage comme Rust pourrait utiliser WebAssembly afin de placer ses modules natifs dans un bac à sable.
  • Partager du code natif à travers différentes plateformes
    Les développeurs peuvent s’épargner du temps et des coûts de maintenance s’ils peuvent réutiliser la même base de code sur différentes plateformes (entre une application web et une application pour le bureau par exemple). Cela concerne aussi bien les langages de script que les langages bas niveaux. De plus, WebAssembly apporte une solution sans ralentir quoi que ce soit sur les plateformes en question.

4 personnages représentant Python, Ruby, Rust et C++ disent : Nous aimons la vitesse de WebAssembly, la sécurité qu'il pourrait nous apporter et nous souhaitons tous que les développeurs puissent travailler efficacement

WebAssembly pourrait donc aider d’autres langages à résoudre des problèmes majeurs.

Malgré cela, convertir une valeur d’un type vers l’autre est possible en suivant certaines règles cette façon. WebAssembly peut être exécuté dans ces environnements mais ce n’est pas suffisant.

Aujourd’hui, WebAssembly ne dialogue avec l’extérieur qu’avec des nombres et ses fonctions peuvent être appelées depuis un autre langage et vice versa.

Mais si une fonction prend des arguments ou renvoie une valeur qui ne sont pas des nombres, ça devient vite compliqué. On peut alors :

  • Mettre à disposition un module dont l’API est ultra-compliqué et ne manipule que des nombres : tant pis pour l’utilisateur du module…
  • Ajouter du code intermédiaire (de la « glue ») pour chaque environnement dans lequel on souhaite que ce module puisse être exécuté : tant pis pour le développeur du module.

Faut-il s’en satisfaire ?

On devrait pouvoir fournir un seul module WebAssembly qui puisse être exécuté n’importe où… sans pour autant compliquer la vie de l’utilisateur du module ou de son développeur.

D'un côté un utilisateur de module qui se demande : Qu'est-ce que c'est encore que cette API ? et d'un autre côté, un développeur qui dit Pff, encore tout un tas de glue à créer pour que ça fonctionne. En dessous, les deux disent simplement : Attendez, ça fonctionne ??

Le même module WebAssembly pourrait utiliser des API riches et des types complexes afin de dialoguer avec :

  • Des modules s’exécutant dans leur environnement natif (ex. des modules Python s’exéutant dans un environnement Python)
  • D’autres modules WebAssembly écrits depuis d’autres langages sources (ex. un module Rust et un module Go s’exécutant de concert dans le navigateur)
  • Le système sous-jacent (ex. un module WASI fournissant une interface système avec le système d’exploitation ou avec les API du navigateur).

Un diagramme avec un fichier .wasm qui dialogue avec : des modules qui s'exécutent dans leurs environnements (ex. PHP, Ruby, Python) ; des modules écrits depuis d'autres langages sources (ex. Rust, Go) ; des systèmes hôtes qui fonctionnent directement avec le système d'exploitation. En haut : le slogan : Les types d'interfaçage WebAssembly : l'interopérabilité pour tous.

Avec une nouvelle proposition, nous pouvons voir comment cela peut fonctionner (et ça fonctionne :)). Voici par exemple une démo :

Voyons comment cela fonctionne. Mais avant regardons la situation actuelle et les problèmes que nous essayons de résoudre.

Discussion entre WebAssembly et JavaScript

WebAssembly ne se limite pas au Web mais jusqu’à présent, une grande partie du développement de WebAssembly concernait le Web.

En effet, on conçoit mieux lorsqu’on se concentre sur la résolution de problèmes concrets. Ce langage devait être exécuté sur le Web et c’était donc un point de départ pertinent.

On a ainsi obtenu un produit minimum viable (MVP) avec un périmètre bien défini. WebAssembly devait alors seulement être capable de dialoguer avec un autre langage : JavaScript.

Ce fut relativement facile à obtenir. Au sein du navigateur, WebAssembly et JS s’exécutent dans le même moteur et le moteur peut donc les aider à discuter efficacement. À droite, un bonhomme-fichier JS qui demande à un intermédiaire : Peux-tu demander au wasm de générer les pixels d'une image pour moi ?. L'intermédiaire répond : Sans problème. À gauche le bonhomme-fichier WASM attend.

L'intermédiaire au centre demande au fichier-bonhomme WASM : j'aimerais que tu exécutes imageGenerate.

Malgré tout, il y a un problème lorsque ces deux-là essaient de dialoguer : ils utilisent des types différents.

Actuellement, WebAssembly ne s’exprime qu’avec des nombres. JavaScript sait ce qu’est un nombre mais possède également quelques autres types.

Et même les nombres ne sont pas vraimeent les mêmes. WebAssembly possède quatre types de nombres : int32, int64, float32, float64. JavaScript possède quant à lui un seul type Number (BigInt sera bientôt un nouveau type numérique en JS).

La différence entre ces types ne s’arrête pas aux noms. Les valeurs sont aussi stockées différemment en mémoire.

Pour commencer, n’importe quelle valeur JavaScript (quel que soit son type) est placée dans une boîte (voir ce précédent article où j’expliquais le concept).

En revanche, WebAssembly utilise des types statiques pour les nombres et il n’utilise ni ne comprend les boîtes de JavaScript.

Cette différence rend le dialogue un peu compliqué. L'intermédiaire au milieu demande au fichier WASM : 'le fichier JS voudrait que tu ajoutes 5 et 7'. Le module WASM répond : 'Aucun problème, ça fait 9.2368828e
+18'. L'intermédiaire rétorque : 'Pardon ?!' Malgré cela, convertir une valeur d’un type vers l’autre est possible en suivant quelques règles simples.

Les règles simples sont facilement écrites et on peut les retrouver dans la spécification de l’API entre WebAssembly et JavaScript. Un livre ouvert où la page de gauche contient 'WASM vers JS' et où la page de droite contient 'int32 -> Number', 'int64 -> BigInt', 'float32 -> Number' et 'float64 -> Number'

Cette correspondance est inscrite dans les moteurs d’exécution.

C’est un peu comme si le moteur possédait un manuel. Lorsque le moteur doit passer des paramètres ou des valeurs de retour entre JavaScript et WebAssembly, il sort le manuel et le consulte afin de savoir comment convertir ces valeurs.

02-05-number-conversion.png, sept. 2019

Avoir aussi peu de types à gérer (uniquement des nombres) rend la chose facile. Ce fut une bonne chose pour un MVP et ça a réduit le nombre de questions difficiles à trancher.

En contrepartie, ce fut plus compliqué pour les développeurs d’utiliser WebAssembly. Pour passer des chaînes de caractères entre JavaScript et WebAssembly, il a fallu trouver une méthode pour transformer des chaînes de caractères en tableaux de nombres puis de faire l’opération inverse. Nous avions couvert sur ce sujet dans un précédent billet.

Un diagramme montrant comment la chaîne de caractères 'Hello' JavaScript est convertie en nombres puis placée dans un objet mémoire pour WebAssembly

Ce n’est pas difficile mais c’est laborieux. Des outils ont naturellement été construits afin de rendre cette conversion transparente.

Entre autres, on pourra trouver des outils tels que wasm-bindgen (en Rust) et Embind d’Emscripten qui enveloppent automatiquement le module WebAssembly avec du code JavaScript de liaison qui s’occupe de la traduction des chaînes de caractères en nombres.

Un nouveau fichier fait son apparition entre le fichier WASM et le fichier JS. Le fichier JS tout à droite dit 'Zut, je vais encore devoir passer une chaîne de caractères au WASM...'. Là le fichier JS pour le code de liaison intervient et dit 'Je peux aider, je vais placer la chaîne en mémoire et indiquer au module WASM son emplacement' Ces outils ont également permis d’effectuer des transformations pour des types de plus haut niveau comme des objets complexes avec des propriétés.

Cela fonctionne mais pour certains cas triviaux, ce n’est pas suffisant.

Imaginons qu’on veuille passer une chaîne de caractères entre deux scripts JS via un module WebAssembly. On doit avoir une fonction JavaScript qui passe une chaîne à une fonction WebAssembly puis le module WebAssembly doit passer cette chaîne à une autre fonction JavaScript.

Pour que tout cela fonctionne, il faut :

  1. Que la première fonction JavaScript passe la chaîne de caractères au code JS qui s’occupe de la liaison (“glue code”)
  2. Que le code de liaison transforme cette chaîne de caractères en nombre et passe ces nombres en mémoire linéaire
  3. Qu’il envoie un nombre (le pointeur vers le début de la zone mémoire) au module WebAssembly
  4. Que la fonction WebAssembly passe ce nombre au code de liaison JS de l’autre côté
  5. Que le code de liaison JavaScript retire ces nombres de la mémoire linéaire pour les décoder en chaîne de caractères
  6. Que le deuxième script de liaison fournisse cette chaîne à la deuxième fonction JS.

Première étape : le fichier JS tout à gauche passe la chaîne 'Hello' au fichier JS de liaison à sa droite.

Deuxième étape, ce fichier avec le code de liaison fait le nécessaire pour convertir la chaîne en nombres en mémoire.

Troisième étape, le fichier avec le code liaison indique à l'intermédiaire : 'Veuillez envoyer l'index 2 au WASM'. L'intermédiaire réfléchit alors 'OK pour passer d'une valeur JSValue à un int32, je dois...'

Quatrième étape, le fichier WASM demande à l'intermédiaire : 'Peux-tu passer l'index 2 ?' pour le script JS à sa droite. L'intermédiaire réfléchit 'Alors pour passer d'un int32 à un Number, je dois...'

Cinquième étape, le deuxième fichier de glue JS (situé à droite du WASM) calcule pour convertir le tableau mémoire en chaîne de caractères. Il produit la valeur 'Hello'.

Sixième étape, le fichier de glue JS passe la valeur 'Hello' au script JS situé à sa droite

On a donc un code de liaison JS qui effectue “simplement” l’opération inverse de celle effectuée plus tôt pour la conversion. Cela fait beaucoup de travail pour en arriver là.

Si la chaîne de caractères pouvait directement être passée au module WebAssembly sans toutes ces transformations, ce serait bien plus simple.

WebAssembly ne pourrait pas manipuler cette valeur, il ne connaît pas ce type : on ne résout pas ce problème de compréhension.

Mais si on pouvait simplement passer la valeur au module WebAssembly comme un passe-plat, cela suffirait aux deux fonctions JavaScript, car elles savent quoi faire avec une valeur d’un tel type.

Il s’agit ici d’une raison de la proposition pour les types de référence WebAssembly. Cette proposition ajoute un nouveau type de base à WebAssembly intitulé anyref.

Avec une valeur anyref, un script JS fournirait au WebAssembly une référence objet (en fait un pointeur qui ne révèle pas l’adresse mémoire). Cette référence pointera vers l’objet sur le tas JS. Le module WebAssembly pourrait alors passer cette valeur à d’autres fonctions JS qui sauraient l’utiliser.

Première étape. Le fichier JS tout à gauche tend la valeur 'Hello' demande à l'intermédiaire 'Peux-tu passer cette chaîne de caractères au WASM ?'. L'intermédiaire réfléchit 'Hmm on dirait qu'il suffit de founir un pointeur au WASM, facile'

Deuxième étape. Le module WASM au centre, tendant la valeur 'Hello', demande à l'intermédiaire 'Peux-tu passer ça au module JavaScript ? Je ne sais pas ce que c'est mais le JS devrait comprendre'. L'intermédiaire réfléchit 'C'est déjà un pointeur vers un objet d'un tas de mémoire JS, plutôt simple'

Cela résout un problème d’interopérabilité avec JavaScript, mais il en existe d’autres dans le navigateur.

Un navigateur possède un ensemble beaucoup plus large de types et WebAssembly doit être capable d’inter-opérer avec ces types si on veut que les performances soient décentes.

Discussion directe entre WebAssembly et le navigateur

JavaScript ne représente qu’une partie du navigateur. Ce dernier possède de nombreuses autres fonctions qu’on peut utiliser : les API Web.

Sous le capot, les fonctions de ces API Web sont généralement écrites en C++ ou en Rust. Ces deux langages stockent chacun à leur façon les objets en mémoires.

Les paramètres et valeurs de retour de ces API Web sont décrites par de nombreux types. Il sera fastidieux de décrire des conversions pour chacun de ces types. Pour simplifier les choses, il existe un standard pour la structure de ces types : Web IDL.

Lorsque vous utilisez ces fonctions, c’est généralement depuis du code JavaScript. Cela signifie que vous passez des valeurs exprimées sur des types JavaScript. Comment un type JavaScript se retrouve converti en type Web IDL ?

À l’instar des correspondances établies entre les types WebAssembly et les types JavaScript, il existe des correspondances entre les types JavaScript et Web IDL.

Là encore, on peut voir cela comme un autre manuel qui explique comment passer de Web IDL à JavaScript. Là aussi ces correspondances font partie intégrant du moteur du navigateur.

Un livre ouvert où la page de gauche contient 'JS vers Web IDL' et où la page de droite contient 'String -> DOMString', 'String -> ByteString', 'String -> USVString', 'Object -> object'

Pour la plupart des types, la correspondance entre JavaScript et Web IDL est assez simple. Ainsi, un type tel que DOMString est compatible avec le type JS String car les deux ont une correspondance directe.

Que se passe-t-il lorsqu’on essaie d’appeler une API Web depuis du code WebAssembly ? Il y a un problème.

À l’heure actuelle, il n’existe pas de correspondance entre les types WebAssembly et les types Web IDL. Cela signifie que même pour les types simples comme les nombres, l’appel doit passer par JavaScript.

Voici ce qui se produit :

  1. WebAssembly passe la valeur au JavaScript
  2. Pour ce faire, le moteur convertit la valeur en un type JavaScript et la place sur le tas de la mémoire JavaScript
  3. La valeur JavaScript est ensuite passée à la fonction de la Web API. Ici, le moteur convertit la valeur JS en un type Web IDL et la place sur une autre zone mémoire, le tas du renderer.

Première étape, le module WASM à gauche tend la valeur 38.

Deuxième étape, l'intermédiaire réfléchit 'Pour commencer, on passe du type int32 au type Number et on place la valeur sur le tas de la mémoire pour JS'. La valeur 38 est ajoutée au tas de la mémoire.

Troisième étape, l'intermédiaire réfléchit 'et ensuite on convertit ça en double... et je peux enfin exécuter la fonction'. La valeur 38 est ajoutée au tas du renderer avec le type double.

Ce n’est pas optimal : plus de tâches à effectuer et plus de mémoire consommée.

Une solution a priori évidente consisterait à créer des correspondances entre WebAssembly et Web IDL. Toutefois, ce n’est pas aussi trivial qu’il y paraît.

Pour les types Web IDL simples tels que booleanet unsigned long (un nombre), il existe des correspondances évidentes entre WebAssembly et Web IDL.

Mais une bonne partie des paramètres utilisées par les API Web ont des types complexes. Une API peut, par exemple, prendre un dictionnaire (comme un objet avec des propriétés) ou une série (un tableau) en entrée.

Pour créer une correspondance directe entre les types WebAssembly et les types Web IDL, il faudrait ajouter des types de plus haut niveau. C’est ce que nous faisons avec la proposition d’ajout d’un ramasse-miettes à WebAssembly. Grâce à ceci, les modules WebAssembly pourront créer des objets pour le ramasse-miettes tels que des structures et des tableaux qui pourront servir aux correspondances pour les types Web IDL.

Mais si la seule façon d’interagir avec les API Web consiste à utiliser les objets du ramasse-miettes, cela complique la tâche pour les langages tels que Rust et C++ qui n’utilisent pas les objets du ramasse-miettes en temps normal. À chaque interaction avec une API Web, il faudrait créer un objet du ramasse-miettes et copier les valeurs depuis la mémoire linéaire dans l’objet.

Le résultat ainsi obtenu est légèrement mieux que la situation actuelle avec le code de liaison JavaScript.

On ne souhaite pas avoir de code de liaison JavaScript pour construire les objets du ramasse-miettes : c’est un gaspillage de temps et de ressources. Réciproquement, on ne veut pas que le module WebAssembly construise ces objets pour les mêmes raisons.

On souhaite qu’appeler les API Web soit aussi simple pour les langages qui utilisent une mémoire linéaire (tels que Rust ou C++) que pour les langages qui utilisent un ramasse-miettes intégré. Il faut donc également une méthode pour créer une correspondance entre les objets en mémoire linéaire et les types Web IDL.

Mais il y a un hic. Chaque langage représente des choses en mémoire linéaire de façon différente. On ne peut pas choisir une de ces représentations spécifiquement, tous les autres langages en pâtiraient.

Un bonhomme au centre, entourés de langages comme C++, Kotlin, Go, D, Rust, Haskell et qui s'exclame : 'Je choisis... celui-là' en pointant Rust. Une annotation rouge indique avec une flèche 'Mauvaise idée'

Bien que l’organisation mémoire soit différente, il y a certains concepts abstraits qui sont généralement partagés.

Ainsi, pour les chaînes de caractères, un langage possède souvent un pointeur vers le début de la chaîne de caractères et sa longueur. Si la chaîne de caractères possède une représentation plus complexe, il est généralement utile de convertir les chaînes vers ce format pour appeler des API externes.

De cette façon, on peut réduire la chaîne en un type que WebAssembly comprend : deux valeurs i32.

La chaîne de caractères 'Hello' encodée en mémoire et deux valeurs 'offset=2' et 'length=5' avec deux flèches vers elles et l'indication 'des types que WebAssembly comprend'

Là encore, un petit hic. WebAssembly est un langage fortement typé. Pour des raions de sécurité, le moteur vérifie que le code appelant passe des valeurs dont les types correspondent à ceux attendus par l’appelé.

Cela empêche les attaquants d’exploiter des incohérences de type pour détourner le moteur.

Si vous appelez une fonction qui utilise une chaîne de caractère et que vous tentez de lui passer un entier, le moteur vous criera dessus. Et ça tombe bien, c’est ce qu’il devrait faire.

Le module WASM tend un entier (57) et dit au moteur 'tiens, voilà un entier, utilise le avec une fonction qui prend une chaîne en entrée'. Le moteur rétorque : 'tu essaies quoi au juste ? de permettre aux attaquants de me pirater moi ?!'

Il nous faut donc une façon pour un module de dire au moteur quelque chose comme “Je sais que Document.createElement() prend une chaîne de caractères, mais je vais l’appeler et vous envoyer deux entiers. Prenez ces deux entiers pour créer un objet DOMString à partir des données en mémoire linéaire. Le premier entier sera l’adresse de départ de la chaîne de caractères et le second correspondra à sa longueur.”

C’est tout l’objectif de la proposition pour les types d’interfaçage Web IDL. On fournit à un module WebAssembly une façon d’indiquer une correspondance entre les types qu’il utilise et les types Web IDL.

Ces correspondances ne sont pas enregistrées en dur dans le moteur. C’est le module qui fournit un petit livret expliquant les correspondances qu’il utilise.

Le module WASM s'incline pour tendre un livret au moteur et lui dit 'Voici quelques notes qui t'indiqueront comment traduire mes types en types d'interfaçage et vice versa'.

Le moteur a donc une méthode pour dire “pour cette fonction, la vérification des types pour les chaînes de caractères consistera à vérifier deux entiers”.

Le couplage entre le module et ce livret d’explication est aussi utile pour une autre raison.

Parfois, un module qui stocke normalement ses chaînes en mémoire linéaire pourra vouloir utilise une anyref ou un type du ramasse-miettes pour un cas spécifique. C’est le cas notamment pour un module qui passe un objet qu’il a obtenu d’une fonction JavaScript (un nœud du DOM par exemple) vers une API Web.

Ainsi, un module doit pouvoir choisir au cas par cas entre les fonctions (voire entre les arguments) la façon dont la correspondance de type est gérée. La correspondance étant fournie par le module, ce dernier peut décrire une correspondance sur-mesure.

Le module WASM indique au moteur : 'Attention à bien lire. Pour certaines fonctions qui utilisent des DOMString, je te fournirai deux nombres mais pour les autres je t'enverrai simplement la valeur DOMString que m'a fourni le JavaScript.

Comment faire pour générer ce livret ?

Le compilateur prend en charge cette opération. Il ajoute une section spécifique au module WebAssembly. Pour la plupart des chaînes de compilation des différents langages, le développeur n’aura pas un grand travail supplémentaire.

Prenons un exemple avec la chaîne de compilation Rust et comment celle-ci gère le passage d’une chaîne de caractères à la fonction alert.

#[wasm_bindgen]
extern "C" {
  fn alert(s: &str);
}

Le développeur doit juste indiquer au compilateur d’ajouter cette fonction au livret avec l’annotation #[wasm_bindgen]. Par défaut, le compilateur considèrera qu’il s’agit d’une chaîne de caractères représentée en mémoire linéaire et ajoutera la bonne correspondance. Si on avait souhaité la gérer différemment (comme un anyref par exemple), on aurait écrit une autre annotation à destination du compilateur.

Grâce à ça, on peut enlever le code JavaScript intermédiaire pour la liaison. Le passage de valeur entre WebAssembly et les API Web est plus rapide. De plus, cela fait moins de JavaScript à distribuer.

Au passage, aucun compromis n’a été effectué quant aux langages pris en charges. On peut utiliser n’importe quel langage qui compile vers WebAssembly. Tous ces langages peuvent définir leur correspondance vers les types Web IDL, peu importe qu’ils utilisent une mémoire linéaire, des objets de ramasse-miettes ou les deux.

En prenant un peu de recul sur cette solution, on peut voir qu’elle résout un bien plus grand problème.

WebAssembly : un langage pour tous leur parler

Revenons à la promesse que nous évoquions au début de ce billet.

Existe-t-il une méthode réaliste afin que WebAssembly puisse parler à ces différents systèmes quels que soient les types qu’ils utilisent ?

Un diagramme avec un module WASM à droite et trois doubles flèches qui partent vers : des modules PHP/Python/Ruby qui s'exécutent dans leurs environnements ; des modules Rust/Go qui sont écrits avec un autre langage ; des systèmes hôtes qui fonctionnent directement avec le système d'exploitation

Quelles sont les options ?

On pourrait essayer de créer des correspondances inscrites en dur dans le moteur (à la façon de ce qui est fait entre WebAssembly et JavaScript d’une part et entre JavaScript et WebIDL d’autre part).

Mais pour ce faire, il faudrait une correspondance spécifique par langage. Le moteur aurait à prendre en charge chacune de ces correspondances explicitement et les mettre à jour à chaque changement de chaque langage. Bref, c’est la pagaille.

C’est de cette façon que furent conçus les premiers compilateurs. Il existait une trajectoire différente entre chaque langage source et chaque langage machine. Nous en parlions plus en détails dans un des premiers billets sur WebAssembly.

Un diagramme avec différents langages sources sur la gauche vers différentes plateformes matérielles (x86 / ARM) représentées par une créature protéiforme

On ne veut pas avoir quelque chose d’aussi compliqué. On veut que chaque langage puisse parler à chaque plateforme. Et en même temps, on veut que cette approche soit extensible.

Il nous faut donc une autre approche et on peut s’inspirer des architectures des compilateurs modernes. Pour ceux-ci, il y a une division entre le front-end et le back-end. La partie front-end porte sur le langage source traduit en une représentation intermédiaire abstraite. La partie back-end part de cette représentation intermédiaire jusqu’au code machine cible.

Contrairement au diagramme précédent, les flèches convergent vers une zone intermédiaire avec 'IR' de là repartent de nouvelles flèches vers les plateformes.

C’est de cette méthode dont s’inspirent les types Web IDL. Quand on le regarde d’un autre angle, Web IDL ressemble un peu à une représentation intermédiaire.

Ceci étant posé, Web IDL est assez spécifique au Web. Et il existe de nombreux cas d’usage pour WebAssembly en dehors du Web. Web IDL n’est donc pas la représentation intermédiaire qu’il faut.

Malgré cela, pouvons-nous nous inspirer de Web IDL et créer un nouvel ensemble de types abstraits ?

C’est ainsi qu’on arrive à la proposition pour les types d’interfaçage WebAssembly.

Un diagramme avec un module WASM à gauche, une liste de langages qui compilent vers WASM et des doubles flèches qui pointent vers le texte 'Types d'interfaçage WebAssembly'. De là repartent des doubles flèches vers des langages, des environnements, des systèmes d'exploitation, etc.

Ces types ne sont pas des types concrets. Ils ne ressemblent pas aux types qu’on trouve aujourd’hui dans WebAssembly comme int32 ou float64. On ne peut pas les manipuler avec des opérations en WebAssembly.

On n’ajoutera par exemple pas de méthode de concaténation de chaînes de caractères dans WebAssembly. Toutes les opérations seront effectuées sur les types concrets à chaque extrêmité.

La clef de voûte de ce fonctionnement est la copie des valeurs d’un côté à l’autre. Plutôt que de partager une représentation commune, les deux parties utilisent les types d’interfaçage pour copier les valeurs.

Le module WASM dit 'vu que c'est une chaîne de caractères en mémoire linéaire, je sais comment la manipuler' et le navigateur dit 'vu que c'est une DOMString, je sais comment la manipuler'. Sous le capôt, le moteur copie le tampon de mémoire linéaire WASM dans le tas du renderer.

Il existe un point qui pourrait constituer une exception à cette règle : les nouvelles valeurs de référence (telles que anyref) que nous avons mentionnées plus haut. Dans ce cas, c’est le pointeur vers l’objet qui est copié entre les deux côtés. Les deux pointeurs pointent donc vers la même chose. En théorie, cela peut vouloir dire qu’ils ont besoin de partager une représentation.

Dans les cas où la référence ne fait que “traverser” un module WebAssembly (comme l’exemple que nous avons vu avec anyref), les deux interlocuteurs n’ont pas à partager une représentation. Le module n’est pas supposé comprendre ce type mais simplement le passer entre les fonctions.

Il existe cependant des scénarios où on souhaite que les interlocuteurs partagent une représentation. Par exemple, la proposition pour le ramasse-miettes ajoute une méthode pour créer des défintions de type afin que les deux parties puissent partager des représentations. Dans ces cas, le choix de la représentation et de ce qu’il faut partager est effectué par les développeurs qui conçoivent l’API.

Cette approche rend le dialogue beaucoup plus simple entre un module WebAssembly et de nombreux langages.

Dans certains cas (comme celui du navigateur), la correspondance entre les types d’interfaçage et les types du système sous-jacent sera inscrite en dur.

Ainsi, une partie des correspondances est construite à la compilation tandis que l’autre est fourni au moteur lors du chargement du contenu.

Le moteur lit le livret et dit 'OK, donc ça ça correspond à une chaîne ? Je peux donc utiliser mes correspondances en dur afin de la convertir en DOMString pour la fonction qui le demande'

Dans les autres cas, par exemple quand deux modules WebAssembly échangent entre eux, les deux envoient leurs livrets d’instruction qui décrivent chacun leurs correspondances entre les types de fonction et les types abstraits.

Un module Rust compilé en WASM et un module Go compilé en WASM fournissent chacun un livret au moteur qui dit : 'OK, voyons voir comment assembler tout ça'.

Ce n’est pas la seule chose nécessaire pour que des modules écrits avec différents langages sources se parlent (nous reviendrons sur ce sujet) mais c’est un grand pas dans cette direction.

À quoi ressemblent ces types d’interfaçage ?

Avant d’aller plus loin dans les détails, rappelons que cette proposition est toujours en cours de développement. Le résultat final pourrait s’avérer complètement différent.

Deux bonhommes sont en train de placer des plots de chantier et l'un tient un panneau avec 'À utiliser avec précaution'.

De plus, tout est géré par le compilateur. Même après que cette proposition ait été finalisée, vous aurez uniquement à connaître les annotations attendues par la chaîne de compilation pour les mettre dans votre code (à la façon de ce que nous avons fait avec wasm-bindgen plus haut). Il n’est pas vraiment nécessaire de savoir comment ça fonctionne sous le capot.

Vu que les détails exposés par la proposition sont assez clairs, profitons-en pour voir comment tout cela s’articule.

Le problème à résoudre

Le problème consiste à traduire des valeurs entre différents types lorsqu’un module dialogue avec un autre module (ou avec un hôte comme le navigateur).

On a quatre endroits où on peut avoir besoin de traduire :

  • Pour les fonctions exportées
    • la réception de paramètres depuis l’appelant
    • l’envoi des valeurs de retour vers l’appelant
  • Pour les fonctions importées
    • le passage des paramètres à la fonction
    • la réception des valeurs de retour

On peut voir chacun de ces cas comme un mouvement sur deux directions :

  • La montée pour les valeurs qui quittent le module. Elles passent d’un type concret à un type d’interfaçage.
  • La descente pour les valeurs qui arrivent dans le module. Elles passent d’un type d’interfaçage à un type concret.

Un schéma avec deux modules WASM qui s'échangent des infos. Les valeurs envoyées remontent et les valeurs reçues redescendent le long des flèches

Indiquer au moteur les transformations à effectuer entre les types concrets et les types d’interfaçage

Il faut donc une méthode pour indiquer au moteur les transformations à appliquer aux paramètres et aux valeurs de retour d’une fonction. Comment faire ?

En définissant un adaptateur d’interface.

Prenons l’exemple d’un module Rust compilé en WebAssembly. Ce module exporte une fonction greeting_ qui peut être appelée sans paramètre et qui renvoie un message de salutation.

Voici ce qu’on aurait actuellement (avec le format textuel WebAssembly).

L'équivalent texte WebAssembly avec une annotation sur le retour : la fonction renvoie deux entiers.

Pour le moment, la fonction renvoie deux entiers.

Mais on voudrait qu’elle renvoie une valeur pour le type d’interfaçage string. On ajoute donc quelque chose qu’on appelle un adaptateur d’interface.

Si un moteur prend en charge les types d’interfaçage, lorsqu’il verra un adaptateur d’interface, il enveloppera le module dans cette interface.

Le code précédent est grisé et une partie avec l'interface est ajoutée. L'annotation indique que celle-ci renvoie une chaîne de caractères.

Le module n’exporte plus la fonction greeting_ mais la fonction greeting qui enveloppe l’originale. La nouvelle fonction greeting renvoie une chaîne de caractères et plus deux entiers.

On obtient une compabilité ascendante, car les moteurs qui ne comprennent pas les types d’interface exporteront la fonction originale greeting_ (celle qui renvoie deux entiers).

Comment l’adaptateur d’interface explique au moteur comment transformer deux entiers en une chaîne ?

Il utilise une séquence d’instructions d’adaptateur.

Le code précédent est grisé et deux nouvelles lignes sont ajoutée. La première indique l'appel de la fonction adaptée et la deuxième indique comment adapter la valeur de retour afin de produire une chaîne de caractères

Les instructions d’adaptateur présentées dans cette image sont deux exemples d’un ensemble d’instructions qui sont définies dans cette proposition.

Voici ce que font les instructions précédentes :

  1. Utiliser l’instruction d’adaptateur call-export afin d’appeler la méthode originale greeting_. C’est la fonction exportée par le module original qui renvoie deux nombres. Ces deux nombres sont placés sur la pile.
  2. Utiliser l’instruction d’adaptateur memory-to-string qui convertit les nombres en une séquence d’octets qui composent la chaînes de caractères. On doit ici préciser "mem" à la suite car un module WebAssembly pourrait demain avoir plusieurs espaces mémoire. On indique ainsi au moteur l’espace mémoire à consulter. Le moteur prend alors les deux nombres sur le dessus de la pile (qui correspondent au pointeur et à la longueur) et les utilise afin de déterminer les octets à utiliser.

Cela ressemble un peu à un langage de programmation, mais il n’y a pas de contrôle du flux d’instructions ici (pas de boucles ou d’instructions conditionnelles). Il s’agit d’un langage déclaratif qui nous permet de fournir des instructions au moteur.

À quoi cela ressemblerait-il si notre fonction prenait une chaîne en paramètre (le nom de la personne à saluer par exemple).

Eh bien c’est assez proche. On modifie l’interface de la fonction d’adaptation afin d’ajouter le paramètre et on ajoute ensuite deux instructions d’adaptateur.

Les lignes précédentes sont grisées. On voit l'ajout d'un premier fragment pour le paramètre qui est une chaîne de caractères. Ensuite une ligne ajoute une référence sur la pile pour référencer la chaîne passée en argument. Enfin, la troisième ligne indique comment interpréter les octets de la chaînes pour les placer en mémoire linéaire.

Voilà ce que font ces nouvelles instructions :

  1. Utiliser l’instruction arg.get afin d’obtenir une référence à l’objet qu’est la chaîne de caractères qu’on place sur la pile.
  2. Utiliser l’instruction string-to-memory afin de récupérer les octets de cet objet pour les placer en mémoire linéaire. Là encore, on précise l’espace mémoire dans lequel inscrire ces octets. On précise également comment allouer ces octets. Pour cela on fournit une fonction d’allocation (qui pourrait être un export fourni par le module).

Si vous souhaitez en savoir plus sur ce fonctionnement, vous pouvez consulter cette explication qui va plus en détails.

Envoyer les instructions au moteur

Comment envoyer tout cela au moteur ?

Ces annotations sont ajoutées au fichier binaire dans une section spécifique (custom).

Un fichier divisé en deux parties : la première (la plus grande) contient les sections « connues » avec le code et les données : la seconde, « spécifique », contient les adaptateurs d'interface.

Si un moteur sait exploiter les types d’interfaçage, il pourra utiliser cette section. Sinon, il pourra l’ignorer et vous pourrez utiliser une prothèse (polyfill) afin de lire la section et écrire du code de liaison.

Quelles différences avec CORBA, Protocol buffers, etc. ?

Il existe actuellement d’autres standards qui semblent résoudre ce même problème dont CORBA, Protocol buffers, Cap’n Proto.

En quoi ceux-ci sont différents ? Ils résolvent un problème beaucoup plus difficile.

Ils ont été conçus afin de pouvoir interagir avec un système avec lequel on ne partage pas de mémoire (soit parce qu’il s’agit d’un autre processus ou d’une toute autre machine sur le réseau).

Cela signifie qu’il faut pouvoir envoyer cette représentation intermédiaire par-delà cette frontière.

Ces standards visent à définir un format de sérialisation qui puisse efficacement voyager sur cette frontière. C’est là un des aspects essentiels de ces standards.

Deux machines qui se parlent avec chacune un module WASM. Entre ces deux machines une flèche annotée « IR » pour la représentation intermédiaire qui voyage sur cet axe.

Bien que le problème semble similaire, il s’agit en fait de l’exact inverse.

Avec les types d’interfaçage, la représentation intermédiaire (l’« IR ») ne quitte jamais le moteur. Elle n’est même pas visible pour les modules.

Les modules ne voient que ce le moteur leur fournit à la fin (ce qui a été copié sur leur mémoire linéaire ou fourni comme référence). Il n’est pas nécessaire d’indiquer au moteur l’organisation de ces types, car elle n’est pas définie.

Ce qui est défini, en revanche, est la façon de parler au moteur. Il s’agit du langage déclaratif utilisé pour écrire ce livret envoyé au moteur.

Les deux modules WASM ne sont plus reliés par une flèche, l'IR ne voyage plus le long d'un axe.

De cet aspect déclaratif découle un effet de bord appréciable : le moteur peut détecter lorsqu’une « traduction » entre types est superflue. Ainsi si les deux modules qui discutent utilisent le même type, le moteur évitera cette double transformation.

Le moteur, entouré d'un module Rust compilé en WASM et d'un module Go compilé en WASM, dit 'Ohoh, vous utilisez tous les deux une mémoire linéaire pour cette chaîne de caractères. Dans ce cas, je vais juste faire une rapide copie entre vos mémoires

Comment utiliser tout ça aujourd’hui ?

Comme nous l’avons indiqué plus haut, il s’agit d’une proposition au stade encore expérimental. Certaines choses risquent de changer rapidement et il serait risqué d’utiliser tout ça en production.

Ceci étant posé, si vous souhaitez manipuler tout ça, nous avons implémenté le nécessaire sur l’ensemble de la chaîne de compilation : de la production de code à la consommation :

  • La chaîne de compilation Rust
  • wasm-bindgen
  • L’environnement d’exécution WebAssembly Wasmtime

Comme nous maintenons ces outils et que nous travaillons sur le standard, nous pouvons maintenir le nécessaire pendant le développement du standard.

Bien que tout ça continue d’évoluer, nous nous assurons de synchroniser ces évolutions avec ces outils. Ainsi, tant que vous utilisez des versions à jour de ces outils, vous ne devriez pas rencontrer trop de problèmes.

Un bonhomme avec un casque et à proximité de plots de chantier qui dit « faites simplement attention à bien rester sur le chemin balisé ».

Voici donc les nombreuses façons dont vous pouvez utiliser tout ça aujourd’hui. Pour une version à jour, vous pouvez consulter ce dépôt de démonstrations.

Remerciements

  • Merci à l’équipe qui a assemblé toutes ces pièces pour tous ces langages et tous ces environnements d’exécution : Alex Crichton, Yury Delendik, Nick Fitzgerald, Dan Gohman et Till Schneidereit
  • Merci aux porteurs de cette proposition et à leurs collègues pour leur travail dessus : Luke Wagner, Francis McCabe, Jacob Gravelle, Alex Crichton et Nick Fitzgerald
  • Merci à mes merveilleux collègues : Luke Wagner et Till Schneidereit pour leurs retours et contributions inestimables à cet article.

À propos de Lin Clark

Lin travaille au sein de l’équipe ‘Advanced Development’ de Mozilla et notamment sur Rust et WebAssembly.