Utiliser des API Web asynchrones à partir de WebAssembly

Les API E/S sur le Web sont asynchrones, mais elles le sont dans la plupart des langages système. Lorsque vous compilez du code vers 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, et comment il fonctionne en arrière-plan.

E/S dans les langues du système

Je vais commencer par un exemple simple en C. Supposons que vous souhaitiez lire le nom d'un utilisateur à partir d'un fichier et le saluer avec le message "Hello, (username)!" :

#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 cet exemple ne fasse pas grand-chose, il illustre déjà un élément que l'on trouve dans une application de toute taille: il lit certaines entrées du monde externe, les traite en interne et écrit des sorties dans le monde externe. Toutes ces interactions avec le monde extérieur s'effectuent via quelques fonctions communément appelées fonctions d'entrée/sortie, également abrégées 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 ses données. Une fois les données récupérées, vous pouvez utiliser une autre fonction d'E/S printf pour imprimer le résultat sur la console.

Ces fonctions semblent assez simples à première vue, et vous n'avez pas à vous soucier des machines utilisées pour lire ou écrire des données. Cependant, selon l'environnement, l'activité peut s'avérer particulièrement complexe:

  • 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 lire bloc par bloc jusqu'à ce que le nombre d'octets demandé soit récupéré. Cela peut être assez lent, 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é. Dans ce cas, la pile réseau sera désormais également impliquée, ce qui augmentera la complexité, la latence et le nombre de tentatives potentielles pour chaque opération.
  • Enfin, même printf n'est pas garanti pour imprimer des éléments sur la console et peut être redirigé vers un fichier ou un emplacement réseau. Dans ce cas, il doit suivre la même procédure que ci-dessus.

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

Cela ne se limite pas non plus au 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 de passer un appel et d'attendre de manière synchrone qu'il renvoie le résultat, le temps qu'il effectue toutes les opérations coûteuses et finisse par renvoyer le résultat dans un seul appel:

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 sur WebAssembly et de le traduire sur le Web ? Ou, pour fournir un exemple spécifique, en quoi l'opération de "lecture de fichier" pourrait-elle se traduire ? Il devrait lire des données à partir d'un espace de stockage.

Modèle Web asynchrone

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

Cependant, seules deux de ces API (l'espace de stockage en mémoire et l'API localStorage) peuvent être utilisées de manière synchrone. Toutes deux sont les 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 principales propriétés 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 d'autres tâches importantes telles que la mise en page, l'affichage et la gestion des événements pour le temps CPU. Vous ne voudriez pas qu'un extrait de code JavaScript ou WebAssembly puisse lancer une opération de "lecture de fichier" et bloquer tout le reste : l'intégralité de l'onglet ou, auparavant, tout le navigateur, pendant une période allant de quelques millisecondes à quelques secondes, jusqu'à la fin.

Au lieu de cela, le code n'est autorisé à programmer une opération d'E/S avec un rappel à exécuter qu'une fois l'opération terminée. Ces rappels sont exécutés dans la boucle d'événements du navigateur. Je n'entre pas dans les détails ici, mais si vous souhaitez en savoir plus sur le fonctionnement de la boucle d'événements, consultez la section Tâches, microtâches, files d'attente et planifications, qui explique en détail ce sujet.

Dans la version courte, le navigateur exécute tous les éléments de code sous la forme d'une boucle infinie, en les retirant de la file d'attente un par un. Lorsqu'un événement est déclenché, le navigateur met le gestionnaire en file d'attente et, à l'itération de boucle suivante, 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 garder à l'esprit ce mécanisme : pendant que votre code JavaScript (ou WebAssembly) personnalisé s'exécute, la boucle d'événements est bloquée. En revanche, il n'y a aucun moyen de réagir à des gestionnaires externes, des événements, des E/S, etc. Le seul moyen d'obtenir les résultats d'E/S consiste à enregistrer un rappel, à terminer l'exécution de votre code et à redonner le contrôle au navigateur afin qu'il puisse conserver les tâches en attente. Une fois les E/S terminées, 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);
}

Bien qu'il semble synchrone, en arrière-plan, chaque await est essentiellement du 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, 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 ce rappel de manière asynchrone. 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'interface utilisateur responsif et disponible pour d'autres tâches, telles que l'affichage, le défilement, etc., pendant que les E/S s'exécutent en arrière-plan.

Dernier exemple, même des API simples telles que "sleep", qui oblige l'application à attendre un certain nombre de secondes, sont é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 bloquerait 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 cette méthode est très inefficace, elle bloque l'ensemble de l'interface utilisateur et n'autorise aucun autre événement en attendant. En règle générale, ne le faites pas 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);

Qu'est-ce qui est commun à tous ces exemples et à ces API ? Dans chaque cas, le code idiomatique du langage système d'origine utilise une API bloquante pour les E/S, tandis qu'un exemple équivalent pour le Web utilise une API asynchrone. Lors de la compilation sur le Web, vous devez passer d'une manière ou d'une autre à l'un de ces deux modèles d'exécution. WebAssembly n'a pas encore la possibilité de le faire.

Combler les disparités avec Asyncify

C'est là que Asyncify entre en jeu. Asyncify est une fonctionnalité prise en charge par Emscripten au moment de la compilation qui permet de suspendre l'ensemble du programme et de le reprendre ultérieurement de manière asynchrone.

Graphique d&#39;appel décrivant un appel JavaScript -> WebAssembly -> API Web -> appel de tâche asynchrone, 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 une veille asynchrone pour le dernier exemple, vous pouvez le faire 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 de code 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 pourrait ê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.

Lors de la compilation de ce type de 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 de fonctions sous forme de tableau qui peuvent être asynchrones.

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

Emscripten sait ainsi que tout appel à ces fonctions peut nécessiter l'enregistrement et la restauration de l'état. Le compilateur injectera donc du code compatible autour de ces appels.

Désormais, lorsque vous exécutez ce code dans le navigateur, vous obtenez un journal de sortie fluide, comme prévu, avec l'apparition de B peu de temps après A.

A
B

Vous pouvez également renvoyer les valeurs des fonctions Asyncify. Vous devez renvoyer le résultat de handleSleep() et le transmettre au rappel wakeUp(). Par exemple, si, au lieu de lire un fichier, vous souhaitez récupérer un numéro à partir d'une ressource distante, vous pouvez utiliser un extrait comme celui présenté ci-dessous pour émettre une requête, suspendre le code C et reprendre une fois le corps de la réponse récupéré, le tout de manière fluide 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 Promise comme fetch(), vous pouvez même combiner Asyncify avec la fonctionnalité d'attente asynchrone de JavaScript au lieu d'utiliser l'API basée sur le rappel. Pour cela, au lieu de Asyncify.handleSleep(), appelez Asyncify.handleAsync(). Ensuite, au lieu de 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 les 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 ne vous limite toujours qu'aux nombres. Comment faire si vous voulez implémenter l'exemple d'origine, dans lequel j'ai essayé d'obtenir le nom d'un utilisateur à partir d'un fichier sous forme de chaîne ? Eh bien, vous pouvez le faire aussi !

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 agir 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'indicateur de compilation, car il est déjà inclus par défaut.

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

Utilisation dans d'autres langues

Supposons que vous ayez un appel synchrone similaire quelque part dans votre code Rust et que vous souhaitiez le mapper à une API asynchrone sur le Web. Il s’avère que vous pouvez le faire aussi !

Tout d'abord, vous devez définir une fonction de ce type comme une importation standard via un 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 le code dans WebAssembly:

cargo build --target wasm32-unknown-unknown

Vous devez maintenant instrumenter le fichier WebAssembly avec du code pour stocker/restaurer la pile. Pour C/C++, Emscripten le ferait pour nous, mais ce n'est pas le cas ici. Le processus est donc un peu plus manuel.

Heureusement, la transformation Asyncify 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 il est produit. La transformation est fournie séparément dans le cadre de l'optimiseur wasm-opt de la chaîne d'outils binaire 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, puis réactivé.

Il ne vous reste plus qu'à fournir le code d'exécution compatible qui effectuera cette opération, à savoir suspendre et reprendre le code WebAssembly. Là encore, dans le cas C / C++, cela serait inclus par Emscripten, mais vous avez maintenant besoin d'un code JavaScript glue personnalisé qui gère les fichiers WebAssembly arbitraires. Nous avons créé une bibliothèque simplement pour cela.

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

Il simule une API d'instanciation WebAssembly standard, mais sous 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();

Une fois que vous essayez d'appeler une fonction asynchrone (comme get_answer() dans l'exemple ci-dessus) depuis le côté WebAssembly, la bibliothèque détecte le Promise renvoyé, suspend et enregistre l'état de l'application WebAssembly, s'abonne à l'achèvement de la promesse, puis, une fois le problème résolu, restaure facilement la pile et l'état d'appel, et 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, toutes les exportations deviennent également potentiellement asynchrones et sont donc également encapsulées. Vous avez peut-être remarqué dans l'exemple ci-dessus que vous devez appliquer (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 ?

Lorsqu'Asyncify détecte un appel à l'une des fonctions ASYNCIFY_IMPORTS, il lance une opération asynchrone, enregistre l'intégralité de l'état de l'application, y compris la pile d'appel et les éventuelles données locales temporaires, puis, lorsque cette opération est terminée, restaure toute la pile de mémoire et d'appel, et reprend au même endroit et avec le même état que si le programme n'était jamais arrêté.

Cette fonctionnalité est assez semblable à la fonctionnalité async-await de JavaScript que j'ai présentée précédemment. Toutefois, contrairement à JavaScript, elle ne nécessite aucune prise en charge spéciale de la syntaxe ou de l'environnement d'exécution du langage. Elle consiste à transformer 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 (le 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");

Au départ, mode est défini sur NORMAL_EXECUTION. En conséquence, la première fois qu'un tel code transformé est exécuté, seule la partie menant à async_sleep() sera évaluée. Dès que l'opération asynchrone est planifiée, Asyncify enregistre toutes les données locales et détache la pile en revenant de chaque fonction jusqu'en haut, redonnant ainsi le contrôle à la boucle d'événements du navigateur.

Ensuite, une fois async_sleep() résolu, le code d'assistance d'Asyncify remplace mode par REWINDING et appelle à nouveau la fonction. Cette fois, la branche "normal execution" (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. Au lieu de cela, elle arrive directement à la branche "rewinding". Une fois l'opération atteinte, il restaure toutes les ressources locales stockées, rétablit le mode "normal" et poursuit l'exécution comme si le code n'avait jamais été arrêté.

Coûts de transformation

Malheureusement, la transformation Asyncify n'est pas entièrement sans frais, car elle doit injecter une grande partie du code de soutien pour stocker et restaurer toutes ces données locales, naviguer dans la pile d'appel dans différents modes, etc. Elle tente de modifier uniquement les fonctions marquées comme asynchrones sur la ligne de commande, ainsi que leurs appelants potentiels, mais la surcharge de la taille du code peut toujours s'élever à environ 50% avant compression.

Graphique montrant la surcharge de la taille du code pour différentes analyses comparatives, 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 n'est pas d'avoir l'ensemble des fonctionnalités ou de devoir effectuer des réécritures importantes du code d'origine.

Veillez à toujours activer les optimisations pour les compilations finales afin d'éviter qu'elles ne soient encore supérieures. Vous pouvez également cocher 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. De plus, les performances d'exécution ont un coût minime, mais celui-ci est limité aux appels asynchrones eux-mêmes. Cependant, par rapport au coût du travail réel, il est généralement négligeable.

Démonstrations pratiques

Maintenant que vous avez examiné des exemples simples, passons à des scénarios plus complexes.

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

En revanche, 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, puis l'exécuter dans un bac à sable sur le Web, tout en lui permettant de fonctionner sur des fichiers utilisateur réels. Avec Asyncify, c'est possible.

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

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

Les cas d'utilisation d'Asyncify ne se limitent pas non plus 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, à 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, à une API WebUSB, qui fournit un accès asynchrone à ces appareils sur le Web. Une fois mappés et compilés, j'ai obtenu des tests libusb standards et des exemples à exécuter sur les appareils de ma sélection, directement dans le bac à sable d'une page Web.

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

Cependant, c'est probablement l'histoire d'un autre article de blog.

Ces exemples montrent à quel point Asyncify peut être efficace pour combler ces lacunes et transférer toutes sortes d'applications sur le Web. Vous pouvez ainsi 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 les fonctionnalités.