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. Quand ? compilez le code dans WebAssembly, vous devez relier un type d'API à un autre. Ce pont est Asyncify. Dans ce post, 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 de l'utilisateur à partir d'un fichier, puis saluer par le message "Hello, (username)!" message:

#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 montre déjà quelque chose que vous trouverez dans une application. Il lit certaines entrées du monde externe, les traite en interne et écrit les sorties vers le monde extérieur. Toutes ces interactions avec le monde extérieur se font par le biais 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, puis fread pour lire ses données. Après avoir récupéré les donné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 préoccuper des machines impliquées pour lire ou écrire des données. Cependant, selon l'environnement, il peut y avoir il se passe 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 à la mémoire et au disque pour localiser le fichier, vérifier les autorisations, l'ouvrir pour 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.
  • Ou, le fichier d'entrée peut se trouver sur un emplacement réseau installé, auquel cas, le réseau interviendra également, ce qui augmentera la complexité, la latence et le nombre de nouvelles tentatives 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, auquel cas il devra suivre les étapes ci-dessus.

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

Cela ne se limite pas non plus à C ou C++. La plupart des langages système présentent toutes les E/S sous la forme de les 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, tandis qu'il effectue toutes les opérations coûteuses et renvoie 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 dans WebAssembly et de le traduire en sur le Web ? Ou, pour vous donner un exemple spécifique, qu’est-ce qui pourrait "lire un fichier" vers lequel l'opération est traduite ? Il devrait ont besoin de lire les données d'un espace de stockage.

Modèle asynchrone du Web

Le Web offre différentes options de stockage que vous pouvez mapper, telles que le stockage en mémoire (JS objets), 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 façon synchrone. Ces deux options sont les plus restrictives en termes de stockage et de durée. Tout tandis que 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, inclut des E/S, doit être asynchrone.

La raison en est que le Web est historiquement monothread et que tout code utilisateur qui touche l'UI doit s'exécuter sur le même thread que l'UI. Il doit entrer en concurrence avec d'autres tâches importantes, comme la mise en page, le rendu et la gestion des événements pour le temps CPU. Vous ne voudriez pas un élément de JavaScript ou WebAssembly de pouvoir lancer une "lecture de fichier" et bloquer tout le reste, l'onglet entier, ou, auparavant, l'intégralité du navigateur, pendant une période allant de quelques millisecondes à quelques secondes, jusqu'à la fin.

À la place, le code n'est autorisé qu'à planifier une opération d'E/S avec l'exécution d'un rappel une fois qu'il est terminé. Ces rappels sont exécutés dans le cadre de la boucle d'événements du navigateur. Je ne serai pas mais si vous voulez en savoir plus sur le fonctionnement de la boucle d'événements, CANNOT TRANSLATE Tâches, microtâches, files d'attente et planifications qui explique ce sujet en profondeur.

Dans sa version abrégée, 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 à l'itération 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 en utilisant uniquement un seul thread.

Ce mécanisme est important, même si votre code JavaScript personnalisé (ou WebAssembly) s'exécute, la boucle d'événements est bloquée et, bien qu'elle l'ait été, il n'existe aucun moyen de réagir les éventuels gestionnaires externes, événements, E/S, etc. Le seul moyen d'obtenir les résultats d'E/S est d'enregistrer terminer l'exécution du code et restituer la commande au navigateur pour qu'il puisse conserver le traitement des 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 à partir d'une URL distante, utilisez 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 un sucre syntaxique pour 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 (seul le code HTTP et invoque ce rappel de manière asynchrone. Le rappel commence à lire le corps du texte sous forme de texte en utilisant response.text() et s'abonne au résultat avec un autre rappel. Enfin, une fois que fetch a récupéré tout le contenu, elle invoque le dernier rappel, qui affiche "Hello, (username)!" vers console.

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

Dernier exemple, même des API simples comme "sleep", qui oblige une application à attendre 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 la 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");

C'est d'ailleurs exactement ce que fait Emscripten dans sa mise en œuvre par défaut "sommeil", mais c'est très inefficace, cela bloque l'ensemble de l'UI et ne permet pas de gérer d'autres événements pendant ce temps. En règle générale, ne le faites pas dans le code de production.

Une version plus idiomatique du mot "sommeil" en JavaScript impliquerait d'appeler setTimeout(), et S'abonner avec un gestionnaire:

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

Qu'est-ce qui est commun à tous ces exemples et API ? Dans chaque cas, le code idiomatique de l'original système de langage utilise une API de blocage pour les E/S, tandis qu'un exemple équivalent pour le Web utilise API asynchrone. Lors de la compilation sur le Web, vous devez passer d'un mode à l'autre d'exécution, et WebAssembly ne permet pas encore de le faire.

Servir de lien avec Asyncify

C'est là que Asyncify entre en jeu. Asyncify est un fonctionnalité de temps de compilation prise en charge par Emscripten, qui permet de mettre en pause l'ensemble du programme en la relançant de manière asynchrone.

Un graphique des appels
de la description d&#39;un script JavaScript -> WebAssembly -> API Web -> Appel de tâche asynchrone, auquel Asyncify se connecte
le résultat de la tâche asynchrone dans WebAssembly

Utilisation en C / C++ avec Emscripten

Si vous souhaitez utiliser Asyncify pour implémenter une mise en veille asynchrone pour le dernier exemple, vous pouvez effectuer comme ceci:

#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 un 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 vous pouvez l'utiliser dans tout autre contexte acceptant les rappels. Enfin, vous pouvez appelez async_sleep() n'importe où, comme vous le souhaitez, comme le sleep() standard ou toute autre API synchrone.

Lors de la compilation de ce code, vous devez demander à Emscripten d'activer la fonctionnalité Asyncify. Pour ce faire, en transmettant -s ASYNCIFY ainsi que -s ASYNCIFY_IMPORTS=[func1, func2] avec une liste de fonctions de type tableau pouvant être asynchrones.

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

Cela permet à Emscripten de savoir que tout appel à ces fonctions peut nécessiter l'enregistrement et la restauration du pour que le compilateur injecte du code compatible autour de ces appels.

Maintenant, lorsque vous exécutez ce code dans le navigateur, vous verrez un journal de sortie fluide, comme prévu, avec B après un court délai après A.

A
B

Vous pouvez renvoyer des valeurs Asyncify également. Quoi il vous suffit de renvoyer le résultat de handleSleep() et de le transmettre à wakeUp(). . Par exemple, si, au lieu de lire un fichier, vous voulez récupérer un numéro à partir d'un ressource, vous pouvez utiliser un extrait comme celui ci-dessous pour émettre une requête, suspendre le code C et une fois le corps de la réponse récupéré, le tout 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 Promise telles que fetch(), vous pouvez même combiner Asyncify avec le code JavaScript async-await au lieu d'utiliser l'API basée sur le rappel. Pour cela, au lieu de Asyncify.handleSleep(), appelez Asyncify.handleAsync(). Ainsi, au lieu de planifier un 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, tout en conservant les avantages les 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 voulez implémenter Exemple : j'essaie d'obtenir le nom d'un utilisateur sous forme de chaîne à partir d'un fichier. Eh bien, vous pouvez le faire aussi !

Emscripten propose une fonctionnalité appelée Embind, qui permet de gérer les conversions entre les valeurs JavaScript et C++. Il est également compatible avec Asyncify. Vous pouvez appeler await() sur des Promise externes. Il agira comme await dans async-await. Code JavaScript:

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 déjà inclus par défaut.

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

Utilisation d'autres langues

Supposons que votre code Rust comporte un appel synchrone similaire, que vous souhaitez mapper à un API asynchrone sur le Web. Il s’avère que vous pouvez aussi le faire !

Tout d'abord, vous devez définir une telle fonction comme une importation standard via le bloc extern (ou votre la syntaxe du langage pour les fonctions étrangères).

extern {
    fn get_answer() -> i32;
}

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

Compilez votre code dans WebAssembly:

cargo build --target wasm32-unknown-unknown

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

Heureusement, la transformation Asyncify est complètement indépendante de la chaîne d'outils. Elle peut transformer des valeurs WebAssembly, quel que soit le compilateur avec lequel ils sont produits. La transformation est fournie séparément dans le cadre de l'optimiseur de wasm-opt de Binaryen chaîne d'outils et peut être appelé 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 des valeurs séparées par une virgule Liste des fonctions asynchrones, où l'état du programme doit être suspendu, puis réactivé.

Il ne vous reste plus qu'à fournir un code d'exécution compatible pour ce faire : suspendre et reprendre Code WebAssembly. Encore une fois, dans le cas C / C++, cela serait inclus par Emscripten, mais vous devez maintenant un code glue JavaScript personnalisé qui traiterait des fichiers WebAssembly arbitraires. Nous avons créé une bibliothèque rien que pour cela.

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

Elle simule une instanciation WebAssembly standard. API, mais sous son propre espace de noms. La seule mais qu'avec une API WebAssembly standard, vous ne pouvez fournir que des fonctions synchrones 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 fonction asynchrone de ce type, comme get_answer() dans l'exemple ci-dessus, à partir de côté WebAssembly, la bibliothèque détectera le Promise renvoyé, suspendra et enregistrera l'état de l'application WebAssembly, s'abonner à l'achèvement de la promesse, et plus tard, une fois le problème résolu, de restaurer facilement la pile et l'état de l'appel, et de poursuivre 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 potentiellement asynchrone, donc ils sont aussi encapsulés. 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 réellement terminé.

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 requête enregistre l'intégralité de l'état de l'application, y compris la pile d'appel et les éventuels en local. Ensuite, une fois l'opération terminée, il restaure toute la pile d'appel et de mémoire, reprend au même endroit et avec le même état que si le programme ne s'était jamais arrêté.

Cette méthode est très semblable à la fonctionnalité "async-await" de JavaScript que nous avons présentée précédemment, mais, contrairement à JavaScript 1, ne nécessite aucune syntaxe particulière ni prise en charge de l'environnement d'exécution du langage. fonctionne 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 transforme ce code en un pseudo-code, un code réel, est plus complexe que celle-ci):

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 que du code transformé est exécuté, seule la partie précédant async_sleep() est évaluée. Dès que l'opération asynchrone est planifiée, Asyncify enregistre tous les paramètres locaux et détend la pile revenant de chaque fonction jusqu'en haut, pour redonner le contrôle au navigateur. boucle d'événements.

Ensuite, une fois async_sleep() résolu, le code de compatibilité Asyncify remplace mode par REWINDING, et à nouveau la fonction. Cette fois, l'exécution normale branche est ignorée, car elle l'a déjà fait. la dernière fois et que je veux éviter d'imprimer "A" deux fois, pour accéder directement "retour arrière" ou une autre branche. Une fois atteinte, elle restaure toutes les locales stockées, puis passe de mode à "normal" et continue l'exécution comme si le code n'avait jamais été arrêté en premier lieu.

Coûts de transformation

Malheureusement, la transformation Asyncify n'est pas entièrement sans frais, car elle doit injecter un grand nombre le code permettant de stocker et de restaurer tous ces éléments locaux, en parcourant la pile d'appel sous différents modes, etc. Il tente de modifier uniquement les fonctions marquées comme asynchrones dans la commande et leurs appelants potentiels, mais la surcharge de la taille du code peut toujours augmenter d'environ 50% avant compression.

Graphique affichant du code
sur la taille de l&#39;infrastructure pour différents benchmarks, allant de près de 0% dans des conditions d&#39;affinage à plus de 100% dans le pire des cas.
cas

Ce n'est pas idéal, mais c'est dans de nombreux cas acceptable lorsque la solution alternative n'est pas ou devoir faire des réécritures importantes du code d'origine.

Assurez-vous de toujours activer les optimisations pour les builds finaux afin d'éviter que cela devienne encore plus élevé. Vous pouvez Cochez également la case Optimisation spécifique à Asyncify d'assistance afin de réduire les frais généraux en limitant les transformations aux fonctions spécifiées et/ou uniquement aux appels de fonction directs. Un un coût mineur sur les performances d'exécution, mais il est limité aux appels asynchrones eux-mêmes. Toutefois, par rapport par rapport au coût du travail réel, c'est généralement négligeable.

Démonstrations concrètes

Maintenant que vous avez examiné 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 l'API File System Access asynchrone. Il donne accès un véritable système de fichiers hôte à 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 cible de compilation les langages système, et expose toutes sortes de systèmes de fichiers et d'autres opérations dans un environnement traditionnel format synchrone.

Et si vous pouviez les mapper les uns aux autres ? Vous pouvez alors compiler n'importe quelle application dans le langage source de votre choix. avec n'importe quelle chaîne d'outils compatible avec la cible WASI, et exécutez-la dans un bac à sable sur le Web, ce qui lui permet de fonctionner sur de vrais fichiers d'utilisateurs ! C'est possible avec Asyncify.

Dans cette démonstration, j'ai compilé une caisse coreutils Rust avec quelques correctifs mineurs pour WASI, transmis via la transformation Asyncify et implémenté l'authentification liaisons à partir de WASI à l'API File System Access côté JavaScript. Une fois combiné avec Xterm.js, qui fournit une interface système réaliste s'exécutant dans et d'utiliser de vrais fichiers d'utilisateurs, comme sur un vrai terminal.

Découvrez-le 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 utilisent des API plus spécifiques sur le Web.

Par exemple, avec Asyncify, il est possible de mapper libusb : probablement la bibliothèque native la plus populaire pour travailler avec Périphériques USB : vers une API WebUSB, qui donne un accès asynchrone à ces périphériques sur le Web. Une fois mappés et compilés, j'ai obtenu des tests libusb standards et des exemples à exécuter avec les outils choisis directement dans le bac à sable d'une page Web.

Capture d&#39;écran de libusb
résultat de débogage sur une page Web contenant des informations sur l&#39;appareil photo Canon connecté

Mais il s'agit probablement d'une histoire pour un autre article de blog.

Ces exemples démontrent la puissance d'Asyncify pour combler le fossé et transférer tous les d'applications Web, ce qui vous permet de bénéficier d'un accès multiplate-forme, du système de bac à sable la sécurité, le tout sans perdre de fonctionnalités.