Utiliser des API Web asynchrones à partir de WebAssembly

Les API d'E/S sur le Web sont asynchrones, mais elles sont synchrones dans la plupart des langages système. Lorsque vous compilez du code dans WebAssembly, vous devez relier un type d'API à un autre. Ce pont est Asyncify. Dans cet article, vous découvrirez quand et comment utiliser Asyncify, ainsi que son fonctionnement.

E/S dans les langues du système

Je vais commencer par un exemple simple en C. Imaginons que vous souhaitiez lire le nom de l'utilisateur à partir d'un fichier et le saluer avec le message "Bonjour (nom d'utilisateur) !" :

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

Bien que l'exemple ne fasse pas grand-chose, il illustre déjà un élément que vous trouverez dans une application de n'importe quelle taille : il lit certaines entrées du monde extérieur, les traite en interne et écrit les sorties dans le monde extérieur. Toutes ces interactions avec le monde extérieur se produisent via quelques fonctions communément appelées fonctions d'entrée-sortie, également raccourcies en E/S.

Pour lire le nom à partir de C, vous avez besoin d'au moins deux appels d'E/S essentiels: fopen pour ouvrir le fichier et fread pour lire les données qu'il contient. Une fois les données récupérées, vous pouvez utiliser une autre fonction d'E/S printf pour imprimer le résultat dans la console.

Ces fonctions semblent assez simples à première vue, et vous n'avez pas à vous soucier des machines impliquées pour lire ou écrire des données. Cependant, selon l'environnement, il peut y avoir beaucoup de choses à l'intérieur:

  • Si le fichier d'entrée se trouve sur un disque local, l'application doit effectuer une série d'accès à la mémoire et au disque pour localiser le fichier, vérifier les autorisations, l'ouvrir en lecture, puis le lire par bloc jusqu'à ce que le nombre d'octets demandé soit récupéré. Cette opération peut être assez lente, en fonction de la vitesse de votre disque et de la taille demandée.
  • Le fichier d'entrée peut également se trouver sur un emplacement réseau installé, auquel cas la pile réseau sera également impliquée, ce qui augmentera la complexité, la latence et le nombre de nouvelles tentatives potentielles pour chaque opération.
  • Enfin, même printf n'est pas certain d'imprimer des éléments dans la console et peut être redirigé vers un fichier ou un emplacement réseau. Dans ce cas, il doit suivre les étapes ci-dessus.

En résumé, les E/S peuvent être lentes et vous ne pouvez pas prédire la durée d'un appel particulier en jetant un coup d'œil rapide au code. Pendant l'exécution de cette opération, l'ensemble de votre application semble figé et ne répond pas à l'utilisateur.

Cela ne se limite pas à C ou C++. La plupart des langages système présentent toutes les E/S sous la forme d'API synchrones. Par exemple, si vous traduisez l'exemple en Rust, l'API peut sembler plus simple, mais les mêmes principes s'appliquent. Il vous suffit d'effectuer un appel et d'attendre de manière synchrone qu'il renvoie le résultat, pendant qu'il effectue toutes les opérations coûteuses et renvoie finalement le résultat en une seule invocation :

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

Mais que se passe-t-il lorsque vous essayez de compiler l'un de ces exemples en WebAssembly et de le traduire sur le Web ? Pour donner un exemple concret, à quoi pourrait correspondre l'opération de "lecture de fichier" ? Il devrait lire les données d'un stockage.

Modèle asynchrone du Web

Le Web propose différentes options de stockage que vous pouvez mapper, telles que le stockage en mémoire (objets JavaScript), localStorage, IndexedDB, le stockage côté serveur et une nouvelle API File System Access.

Cependant, seules deux de ces API, le stockage en mémoire et localStorage, peuvent être utilisées de manière synchrone. Il s'agit des options les plus contraignantes en termes de stockage et de durée de stockage. Toutes les autres options ne fournissent que des API asynchrones.

Il s'agit de l'une des propriétés fondamentales de l'exécution de code sur le Web : toute opération chronophage, y compris les E/S, doit être asynchrone.

En effet, le Web est historiquement monothread, et tout code utilisateur qui touche l'UI doit s'exécuter sur le même thread que l'UI. Il doit rivaliser avec les autres tâches importantes telles que la mise en page, le rendu et la gestion des événements pour le temps de processeur. Vous ne voulez pas qu'un code JavaScript ou WebAssembly puisse démarrer une opération de "lecture de fichier" et bloquer tout le reste (l'intégralité de l'onglet ou, par le passé, l'intégralité du navigateur) pendant une plage allant de quelques millisecondes à quelques secondes, jusqu'à ce qu'elle soit terminée.

Au lieu de cela, le code n'est autorisé à planifier qu'une seule opération d'E/S avec un rappel à exécuter une fois qu'elle est terminée. Ces rappels sont exécutés dans le cadre de la boucle d'événements du navigateur. Je ne vais pas entrer dans les détails ici, mais si vous souhaitez en savoir plus sur le fonctionnement de la boucle d'événements, consultez Tasks, microtasks, queues and schedules (Tâches, microtâches, files d'attente et planifications), qui explique ce sujet en détail.

Pour faire court, le navigateur exécute tous les éléments de code dans une sorte de boucle infinie, en les retirant de la file d'attente un par un. Lorsqu'un événement est déclenché, le navigateur met en file d'attente le gestionnaire correspondant. Lors de la prochaine itération de la boucle, il est retiré de la file d'attente et exécuté. Ce mécanisme permet de simuler la simultanéité et d'exécuter de nombreuses opérations parallèles tout en n'utilisant qu'un seul thread.

Il est important de se rappeler que, pendant l'exécution de votre code JavaScript (ou WebAssembly) personnalisé, la boucle d'événements est bloquée. Tant qu'elle l'est, il n'est pas possible de réagir à des gestionnaires, événements, E/S externes, etc. Le seul moyen d'obtenir les résultats d'E/S est d'enregistrer un rappel, de terminer l'exécution de votre code et de rendre le contrôle au navigateur afin qu'il puisse continuer à traiter les tâches en attente. Une fois l'E/S terminée, votre gestionnaire devient l'une de ces tâches et est exécuté.

Par exemple, si vous souhaitez réécrire les exemples ci-dessus dans du code JavaScript moderne et que vous décidez de lire un nom à partir d'une URL distante, vous devez utiliser l'API Fetch et la syntaxe async-await:

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

Même s'il semble synchrone, sous le capot, chaque await est essentiellement un sucre syntaxique pour les rappels :

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

Dans cet exemple de désucrage, qui est un peu plus clair, une requête est lancée et les réponses sont abonnées avec le premier rappel. Une fois que le navigateur reçoit la réponse initiale (uniquement les en-têtes HTTP), il appelle de manière asynchrone ce rappel. Le rappel commence à lire le corps sous forme de texte à l'aide de response.text() et s'abonne au résultat avec un autre rappel. Enfin, une fois que fetch a récupéré tout le contenu, il appelle le dernier rappel, qui affiche "Hello, (username)!" dans la console.

Grâce à la nature asynchrone de ces étapes, la fonction d'origine peut rendre le contrôle au navigateur dès que les E/S ont été planifiées, et laisser l'ensemble de l'UI réactif et disponible pour d'autres tâches, y compris le rendu, le défilement, etc., pendant que les E/S s'exécutent en arrière-plan.

Enfin, même les API simples telles que "sleep", qui fait attendre une application pendant un nombre spécifié de secondes, constituent également une forme d'opération d'E/S:

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

Bien sûr, vous pouvez le traduire de manière très simple, ce qui bloquera le thread actuel jusqu'à l'expiration du délai :

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

En fait, c'est exactement ce que fait Emscripten dans son implémentation par défaut de "sleep", mais cela est très inefficace, bloque l'ensemble de l'interface utilisateur et n'autorise aucun autre événement à être géré en attendant. En général, ne faites pas cela dans le code de production.

Au lieu de cela, une version plus idiomatique de "sleep" en JavaScript impliquerait d'appeler setTimeout() et de s'abonner avec un gestionnaire:

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

Quel est le point commun entre tous ces exemples et API ? Dans chaque cas, le code idiomatique dans le langage d'origine du système utilise une API de blocage pour les E/S, tandis qu'un exemple équivalent pour le Web utilise une API asynchrone. Lors de la compilation pour le Web, vous devez effectuer une transformation entre ces deux modèles d'exécution, et WebAssembly n'est pas encore capable de le faire.

Combler le fossé avec Asyncify

C'est là qu'intervient Asyncify. Asyncify est une fonctionnalité de compilation prise en charge par Emscripten qui permet de suspendre l'ensemble du programme et de le reprendre de manière asynchrone plus tard.

Graphique des appels décrivant une invocation de tâche asynchrone JavaScript -> WebAssembly -> API Web -> où Asyncify relie le résultat de la tâche asynchrone à WebAssembly

Utilisation en C / C++ avec Emscripten

Si vous souhaitez utiliser Asyncify pour implémenter un sommeil asynchrone pour le dernier exemple, procédez comme suit:

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});
…
puts("A");
async_sleep(1);
puts("B");

EM_JS est une macro qui permet de définir des extraits JavaScript comme s'il s'agissait de fonctions C. À l'intérieur, utilisez une fonction Asyncify.handleSleep() qui indique à Emscripten de suspendre le programme et fournit un gestionnaire wakeUp() qui doit être appelé une fois l'opération asynchrone terminée. Dans l'exemple ci-dessus, le gestionnaire est transmis à setTimeout(), mais il peut être utilisé dans tout autre contexte acceptant les rappels. Enfin, vous pouvez appeler async_sleep() n'importe où, comme sleep() standard ou toute autre API synchrone.

Lorsque vous compilez ce code, vous devez demander à Emscripten d'activer la fonctionnalité Asyncify. Pour ce faire, transmettez -s ASYNCIFY ainsi que -s ASYNCIFY_IMPORTS=[func1, func2] avec une liste sous forme de tableau de fonctions qui peuvent être asynchrones.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

Cela permet à Emscripten de savoir que les appels à ces fonctions peuvent nécessiter l'enregistrement et la restauration de l'état. Le compilateur injectera donc du code d'assistance autour de ces appels.

Lorsque vous exécutez ce code dans le navigateur, un journal de sortie fluide s'affiche, comme vous pouvez vous y attendre, avec B après un court délai après A.

A
B

Vous pouvez également renvoyer des valeurs à partir des fonctions Asyncify. Vous devez renvoyer le résultat de handleSleep() et transmettre le résultat au rappel wakeUp(). Par exemple, si vous souhaitez extraire un numéro à partir d'une ressource distante au lieu de le lire à partir d'un fichier, vous pouvez utiliser un extrait de code comme celui ci-dessous pour envoyer une requête, suspendre le code C et reprendre une fois le corps de la réponse récupéré. Tout cela se fait de manière transparente, comme si l'appel était synchrone.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

En fait, pour les API basées sur des promesses telles que fetch(), vous pouvez même combiner Asyncify avec la fonctionnalité async-await de JavaScript au lieu d'utiliser l'API basée sur les rappels. Pour cela, appelez Asyncify.handleAsync() au lieu de Asyncify.handleSleep(). Ensuite, au lieu d'avoir à planifier un rappel wakeUp(), vous pouvez transmettre une fonction JavaScript async et utiliser await et return à l'intérieur, ce qui rend le code encore plus naturel et synchrone, sans perdre aucun des avantages des E/S asynchrones.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

En attente de valeurs complexes

Mais cet exemple vous limite toujours aux chiffres. Que se passe-t-il si vous souhaitez implémenter l'exemple d'origine, dans lequel j'ai essayé d'obtenir le nom d'un utilisateur sous forme de chaîne à partir d'un fichier ? Vous pouvez aussi le faire.

Emscripten fournit une fonctionnalité appelée Embind qui vous permet de gérer les conversions entre les valeurs JavaScript et C++. Il est également compatible avec Asyncify. Vous pouvez donc appeler await() sur des Promise externes, et il se comportera comme await dans le code JavaScript async-await :

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

Lorsque vous utilisez cette méthode, vous n'avez même pas besoin de transmettre ASYNCIFY_IMPORTS en tant qu'option de compilation, car elle est déjà incluse par défaut.

Tout fonctionne bien dans Emscripten. Qu'en est-il des autres chaînes d'outils et des autres langages ?

Utilisation d'autres langues

Supposons que vous ayez un appel synchrone similaire quelque part dans votre code Rust que vous souhaitez mapper sur une API asynchrone sur le Web. Vous pouvez aussi le faire !

Tout d'abord, vous devez définir une fonction de ce type en tant qu'importation standard via le bloc extern (ou la syntaxe du langage de votre choix pour les fonctions étrangères).

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

Compilez votre code en WebAssembly:

cargo build --target wasm32-unknown-unknown

Vous devez maintenant instrumenter le fichier WebAssembly avec du code permettant de stocker/restaurer la pile. Pour C/C++, Emscripten s'en chargerait, mais il n'est pas utilisé ici. Le processus est donc un peu plus manuel.

Heureusement, la transformation Asyncify elle-même est complètement indépendante de la chaîne d'outils. Il peut transformer des fichiers WebAssembly arbitraires, quel que soit le compilateur avec lequel ils sont produits. La transformation est fournie séparément dans le cadre de l'optimiseur wasm-opt de la chaîne d'outils Binaryen et peut être appelée comme suit :

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

Transmettez --asyncify pour activer la transformation, puis utilisez --pass-arg=… pour fournir une liste de fonctions asynchrones séparées par une virgule, où l'état du programme doit être suspendu et repris ultérieurement.

Il ne reste plus qu'à fournir le code d'exécution qui le fera réellement : suspendre et reprendre le code WebAssembly. Encore une fois, dans le cas C / C++, ce code serait inclus par Emscripten, mais vous avez maintenant besoin d'un code glue JavaScript personnalisé capable de gérer des fichiers WebAssembly arbitraires. Nous avons créé une bibliothèque à cet effet.

Vous le trouverez sur GitHub à l'adresse https://github.com/GoogleChromeLabs/asyncify ou sur npm sous le nom asyncify-wasm.

Elle simule une API d'instanciation WebAssembly standard, mais avec son propre espace de noms. La seule différence est que, avec une API WebAssembly standard, vous ne pouvez fournir que des fonctions synchrones en tant qu'importations, tandis que sous le wrapper Asyncify, vous pouvez également fournir des importations asynchrones:

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});
…
await instance.exports.main();

Lorsque vous essayez d'appeler une telle fonction asynchrone (comme get_answer() dans l'exemple ci-dessus) du côté WebAssembly, la bibliothèque détecte le Promise renvoyé, suspend et enregistre l'état de l'application WebAssembly, s'abonne à la finalisation de la promesse et, une fois résolu, restaure de manière transparente la pile d'appels et l'état, puis poursuit l'exécution comme si rien ne s'était passé.

Étant donné que n'importe quelle fonction du module peut effectuer un appel asynchrone, tous les exportations deviennent également potentiellement asynchrones. Elles sont donc encapsulées également. Vous avez peut-être remarqué dans l'exemple ci-dessus que vous devez await le résultat de instance.exports.main() pour savoir quand l'exécution est vraiment terminée.

Comment tout cela fonctionne-t-il en arrière-plan ?

Lorsque Asyncify détecte un appel à l'une des fonctions ASYNCIFY_IMPORTS, il lance une opération asynchrone, enregistre l'état complet de l'application, y compris la pile d'appels et les éventuels locaux temporaires, puis, une fois cette opération terminée, restaure toute la mémoire et la pile d'appels, et reprend à partir du même endroit et avec le même état que si le programme ne s'était jamais arrêté.

Cette fonctionnalité est très semblable à la fonctionnalité async-await en JavaScript que j'ai présentée précédemment. Cependant, contrairement à celle de JavaScript, elle ne nécessite aucune syntaxe ni prise en charge spéciale du langage au moment de l'exécution. Elle fonctionne plutôt en transformant des fonctions synchrones simples au moment de la compilation.

Lors de la compilation de l'exemple de sommeil asynchrone présenté précédemment:

puts("A");
async_sleep(1);
puts("B");

Asyncify prend ce code et le transforme à peu près comme suit (pseudo-code, la transformation réelle est plus complexe que cela):

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

Initialement, mode est défini sur NORMAL_EXECUTION. Par conséquent, la première fois qu'un tel code transformé est exécuté, seule la partie menant à async_sleep() est évaluée. Dès que l&#39;opération asynchrone est planifiée, Asyncify enregistre tous les locaux et déroule la pile en revenant de chaque fonction jusqu&#39;en haut, ce qui redonne le contrôle à la boucle d&#39;événements du navigateur.

Ensuite, une fois que async_sleep() est résolu, le code d'assistance Asyncify remplace mode par REWINDING et appelle à nouveau la fonction. Cette fois, la branche "exécution normale" est ignorée, car elle a déjà effectué la tâche la dernière fois et je souhaite éviter d'imprimer "A" deux fois. Elle passe directement à la branche "rembobinage". Une fois cette valeur atteinte, toutes les variables locales stockées sont restaurées, le mode est rétabli en "normal" et l'exécution se poursuit comme si le code n'avait jamais été arrêté.

Coûts de transformation

Malheureusement, la transformation Asyncify n'est pas totalement gratuite, car elle doit injecter un bon nombre de codes de support pour stocker et restaurer tous ces locaux, naviguer dans la pile d'appels sous différents modes, etc. Il ne tente de modifier que les fonctions marquées comme asynchrones sur la ligne de commande, ainsi que tous leurs appelants potentiels, mais le coût supplémentaire lié à la taille du code peut toujours atteindre environ 50 % avant la compression.

Graphique illustrant l&#39;impact sur la taille du code pour différents benchmarks, allant de près de 0% dans des conditions affinées à plus de 100% dans le pire des cas

Ce n'est pas idéal, mais dans de nombreux cas, c'est acceptable lorsque l'alternative consiste à ne pas avoir du tout la fonctionnalité ou à devoir réécrire de manière significative le code d'origine.

Veillez à toujours activer les optimisations pour les builds finaux afin d'éviter que ce nombre ne soit encore plus élevé. Vous pouvez également consulter les options d'optimisation spécifiques à Asyncify pour réduire les frais généraux en limitant les transformations aux fonctions spécifiées et/ou aux appels de fonction directs uniquement. Les performances d'exécution occasionnent également un coût mineur, mais elles sont limitées aux appels asynchrones eux-mêmes. Toutefois, par rapport au coût de la tâche réelle, il est généralement négligeable.

Démonstrations concrètes

Maintenant que vous avez vu les exemples simples, je vais passer à des scénarios plus complexes.

Comme indiqué au début de l'article, l'une des options de stockage sur le Web est une API File System Access asynchrone. Il permet d'accéder à un système de fichiers hôte réel à partir d'une application Web.

D'autre part, il existe une norme de facto appelée WASI pour les E/S WebAssembly dans la console et côté serveur. Il a été conçu comme une cible de compilation pour les langages système et expose toutes sortes de systèmes de fichiers et d'autres opérations sous une forme synchrone traditionnelle.

Et si vous pouviez les mettre en correspondance ? Vous pouvez ensuite compiler n'importe quelle application dans n'importe quel langage source avec n'importe quelle chaîne d'outils compatible avec la cible WASI et l'exécuter dans un bac à sable sur le Web, tout en lui permettant de fonctionner sur de vrais fichiers utilisateur. C'est possible avec Asyncify.

Dans cette démonstration, j'ai compilé la crate Rust coreutils avec quelques correctifs mineurs pour WASI, transmis via la transformation Asyncify et implémenté des liaisons asynchrones de WASI à l'API File System Access côté JavaScript. Une fois combiné au composant de terminal Xterm.js, il fournit un shell réaliste exécuté dans l'onglet du navigateur et fonctionnant sur des fichiers utilisateur réels, comme un terminal réel.

Découvrez-la en direct sur https://wasi.rreverser.com/.

Les cas d'utilisation d'Asyncify ne se limitent pas aux minuteurs et aux systèmes de fichiers. Vous pouvez aller plus loin et utiliser des API plus spécialisées sur le Web.

Par exemple, avec l'aide d'Asyncify, il est également possible de mapper libusb (probablement la bibliothèque native la plus populaire pour travailler avec des appareils USB) sur une API WebUSB, qui permet d'accéder de manière asynchrone à ces appareils sur le Web. Une fois la carte et la compilation effectuées, j'ai pu exécuter des tests et des exemples libusb standards sur les appareils choisis directement dans l'environnement de bac à sable d'une page Web.

Capture d&#39;écran de la sortie de débogage libusb sur une page Web, affichant des informations sur l&#39;appareil photo Canon connecté

Mais c'est probablement une histoire pour un autre article de blog.

Ces exemples montrent à quel point Asyncify peut être performant pour combler le fossé et porter toutes sortes d'applications sur le Web, ce qui vous permet de bénéficier d'un accès multiplate-forme, d'un système de bac à sable et d'une meilleure sécurité, le tout sans perdre de fonctionnalités.