Utiliser des threads WebAssembly à partir de C, C++ et Rust

Découvrez comment importer des applications multithread écrites dans d'autres langages sur WebAssembly.

La prise en charge des threads WebAssembly est l'un des ajouts de performances les plus importants à WebAssembly. Il vous permet d'exécuter des parties de votre code en parallèle sur des cœurs distincts, ou d'exécuter le même code sur des parties indépendantes des données d'entrée en les adaptant au nombre de cœurs de l'utilisateur, ce qui réduit considérablement la durée d'exécution globale.

Dans cet article, vous allez apprendre à utiliser les threads WebAssembly pour transférer sur le Web des applications multithread écrites dans des langages tels que C, C++ et Rust.

Fonctionnement des threads WebAssembly

Les threads WebAssembly ne constituent pas une fonctionnalité distincte, mais une combinaison de plusieurs composants qui permet aux applications WebAssembly d'utiliser les paradigmes de multithreading traditionnels sur le Web.

Web Workers

Le premier composant est les Workers standards que vous connaissez et appréciez de JavaScript. Les threads WebAssembly utilisent le constructeur new Worker pour créer des threads sous-jacents. Chaque thread charge une colle JavaScript, puis le thread principal utilise la méthode Worker#postMessage pour partager le WebAssembly.Module compilé, ainsi qu'un WebAssembly.Memory partagé (voir ci-dessous) avec ces autres threads. Cela établit la communication et permet à tous ces threads d'exécuter le même code WebAssembly sur la même mémoire partagée sans passer à nouveau par JavaScript.

Les Web Workers existent depuis plus de 10 ans, sont largement pris en charge et ne nécessitent aucun indicateur spécial.

SharedArrayBuffer

La mémoire WebAssembly est représentée par un objet WebAssembly.Memory dans l'API JavaScript. Par défaut, WebAssembly.Memory est un wrapper autour d'un ArrayBuffer, un tampon d'octets bruts auquel un seul thread peut accéder.

> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }

Pour prendre en charge le multithreading, WebAssembly.Memory a également obtenu une variante partagée. Lorsqu'il est créé avec un indicateur shared via l'API JavaScript ou par le binaire WebAssembly lui-même, il devient un wrapper autour d'un SharedArrayBuffer. Il s'agit d'une variante de ArrayBuffer qui peut être partagée avec d'autres threads et lue ou modifiée simultanément des deux côtés.

> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }

Contrairement à postMessage, normalement utilisé pour la communication entre le thread principal et les Web Workers, SharedArrayBuffer ne nécessite pas de copier des données ni même d'attendre la boucle d'événements pour envoyer et recevoir des messages. Au lieu de cela, toutes les modifications sont vues par tous les threads presque instantanément, ce qui en fait une cible de compilation bien meilleure pour les primitives de synchronisation traditionnelles.

L'historique de SharedArrayBuffer est complexe. Elle a été initialement distribuée dans plusieurs navigateurs mi-2017, mais a dû être désactivée début 2018 en raison de la découverte de vulnérabilités Spectre. La raison particulière était que l'extraction de données dans Spectre repose sur des attaques de synchronisation, c'est-à-dire pour mesurer le temps d'exécution d'un extrait de code particulier. Pour rendre ce type d'attaque plus difficile, les navigateurs ont réduit la précision des API de minutage standards telles que Date.now et performance.now. Cependant, la mémoire partagée, combinée à une simple boucle de compteur exécutée dans un thread distinct, représente également un moyen très fiable d'obtenir une durée de haute précision, et elle est beaucoup plus difficile à atténuer sans limiter considérablement les performances d'exécution.

Au lieu de cela, Chrome 68 (mi-2018) a réactivé SharedArrayBuffer à l'aide de l'isolation de sites, une fonctionnalité qui applique des processus différents à différents sites Web et rend l'utilisation des attaques par canal auxiliaire beaucoup plus difficile, comme Spectre. Toutefois, cette atténuation était toujours limitée aux ordinateurs Chrome, car l'isolation de sites est une fonctionnalité assez coûteuse. Elle ne pouvait pas être activée par défaut pour tous les sites utilisant des appareils mobiles à faible mémoire, et elle n'était pas encore mise en œuvre par d'autres fournisseurs.

Jusqu'en 2020, Chrome et Firefox intègrent tous deux l'isolation de sites et offrent aux sites Web une méthode standard pour activer cette fonctionnalité avec des en-têtes COOP et COEP. Un mécanisme d'activation permet d'utiliser l'isolation de sites même sur les appareils à faible consommation d'énergie, où il serait trop coûteux de l'activer pour tous les sites Web. Pour l'activer, ajoutez les en-têtes suivants au document principal de la configuration de votre serveur:

Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

Une fois que vous l'activez, vous avez accès à SharedArrayBuffer (y compris à WebAssembly.Memory reposant sur un SharedArrayBuffer), à des minuteurs précis, à des mesures de la mémoire et à d'autres API qui nécessitent une origine isolée pour des raisons de sécurité. Pour en savoir plus, consultez la page Faciliter l'isolement multi-origine de votre site Web à l'aide de COOP et COEP.

WebAssembly Atoms

Bien que SharedArrayBuffer permette à chaque thread de lire et d'écrire dans la même mémoire, pour une communication correcte, vous devez vous assurer qu'ils n'effectuent pas d'opérations conflictuelles en même temps. Par exemple, il est possible qu'un thread commence à lire des données à partir d'une adresse partagée, tandis qu'un autre thread y écrit des données. Par conséquent, le premier thread recevra un résultat corrompu. Cette catégorie de bugs est appelée "conditions de concurrence". Afin d'éviter les conditions de concurrence, vous devez synchroniser ces accès d'une manière ou d'une autre. C'est là que les opérations atomiques entrent en jeu.

WebAssembly atomique est une extension de l'ensemble d'instructions WebAssembly qui permet de lire et d'écrire de petites cellules de données (généralement des entiers de 32 et 64 bits) de manière "atomique". Autrement dit, de manière à garantir qu'aucun deux thread ne lit ni n'écrit en même temps dans la même cellule, ce qui évite de tels conflits à bas niveau. De plus, les atomes WebAssembly contiennent deux autres types d'instructions ("wait" et "notify") qui permettent à un thread de se mettre en veille ("wait") sur une adresse donnée dans une mémoire partagée jusqu'à ce qu'un autre thread le active via "notify".

Toutes les primitives de synchronisation de niveau supérieur, y compris les canaux, les mutex et les verrous en lecture-écriture, s'appuient sur ces instructions.

Utiliser des threads WebAssembly

Détection de fonctionnalités

WebAssembly Atoms et SharedArrayBuffer sont des fonctionnalités relativement récentes qui ne sont pas encore disponibles dans tous les navigateurs compatibles avec WebAssembly. Vous trouverez la liste des navigateurs compatibles avec les nouvelles fonctionnalités WebAssembly sur la feuille de route webassembly.org.

Pour vous assurer que tous les utilisateurs peuvent charger votre application, vous devez mettre en œuvre l'amélioration progressive en créant deux versions différentes de Wasm, l'une compatible avec le multithreading et l'autre sans. Chargez ensuite la version compatible en fonction des résultats de la détection de fonctionnalités. Pour détecter la compatibilité des threads WebAssembly au moment de l'exécution, utilisez la bibliothèque Wasm-feature-detect et chargez le module comme suit:

import { threads } from 'wasm-feature-detect';

const hasThreads = await threads();

const module = await (
  hasThreads
    ? import('./module-with-threads.js')
    : import('./module-without-threads.js')
);

// …now use `module` as you normally would

Voyons maintenant comment créer une version multithread du module WebAssembly.

C

Dans C, en particulier sur les systèmes de type Unix, la méthode courante d'utilisation des threads consiste à utiliser les threads POSIX fournis par la bibliothèque pthread. Emscripten fournit une mise en œuvre compatible avec l'API de la bibliothèque pthread créée sur des nœuds de calcul Web, la mémoire partagée et l'atome, afin que le même code puisse fonctionner sur le Web sans être modifié.

Examinons un exemple:

example.c:

#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

void *thread_callback(void *arg)
{
    sleep(1);
    printf("Inside the thread: %d\n", *(int *)arg);
    return NULL;
}

int main()
{
    puts("Before the thread");

    pthread_t thread_id;
    int arg = 42;
    pthread_create(&thread_id, NULL, thread_callback, &arg);

    pthread_join(thread_id, NULL);

    puts("After the thread");

    return 0;
}

Ici, les en-têtes de la bibliothèque pthread sont inclus via pthread.h. Vous pouvez également voir quelques fonctions essentielles pour gérer les threads.

pthread_create crée un thread d'arrière-plan. Il faut une destination pour stocker un handle de thread, certains attributs de création de thread (ici, il s'agit simplement de NULL), le rappel à exécuter dans le nouveau thread (ici thread_callback) et un pointeur d'argument facultatif à transmettre à ce rappel si vous souhaitez partager des données du thread principal. Dans cet exemple, nous partageons un pointeur vers une variable arg.

pthread_join peut être appelé ultérieurement à tout moment pour attendre la fin de l'exécution du thread et obtenir le résultat renvoyé par le rappel. Il accepte le handle de thread précédemment attribué, ainsi qu'un pointeur pour stocker le résultat. Dans ce cas, il n'y a aucun résultat. La fonction accepte donc NULL comme argument.

Pour compiler du code à l'aide de threads avec Emscripten, vous devez appeler emcc et transmettre un paramètre -pthread, comme lorsque vous compilez le même code avec Clang ou GCC sur d'autres plates-formes:

emcc -pthread example.c -o example.js

Toutefois, lorsque vous essayez de l'exécuter dans un navigateur ou en Node.js, un avertissement s'affiche, puis le programme se bloque:

Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]

Que s'est-il passé ? Le problème est que la plupart des API chronophages sur le Web sont asynchrones et reposent sur la boucle d'événements pour s'exécuter. Cette limitation constitue une distinction importante par rapport aux environnements traditionnels, dans lesquels les applications exécutent normalement des E/S de manière synchrone et bloquante. Pour en savoir plus, consultez l'article de blog sur l'utilisation d'API Web asynchrones à partir de WebAssembly.

Dans ce cas, le code appelle de manière synchrone pthread_create pour créer un thread d'arrière-plan, puis fait suite à un autre appel synchrone à pthread_join qui attend que l'exécution du thread d'arrière-plan se termine. Toutefois, les Web Workers, utilisés en arrière-plan lorsque ce code est compilé avec Emscripten, sont asynchrones. Par conséquent, pthread_create planifie uniquement la création d'un thread de nœud de calcul à la prochaine exécution de la boucle d'événements, puis pthread_join bloque immédiatement la boucle d'événements pour attendre ce worker et empêche ainsi sa création. Il s'agit d'un exemple classique d'interblocage.

Une façon de résoudre ce problème consiste à créer un pool de nœuds de calcul à l'avance, avant même le démarrage du programme. Lorsque pthread_create est appelé, il peut extraire du pool un nœud de calcul prêt à l'emploi, exécuter le rappel fourni sur son thread d'arrière-plan et renvoyer le nœud de calcul dans le pool. Tout cela peut être effectué de manière synchrone. Il n'y aura donc pas d'interblocages tant que le pool est suffisamment grand.

C'est exactement ce qu'Embscripten autorise avec l'option -s PTHREAD_POOL_SIZE=.... Elle permet de spécifier un nombre de threads, soit un nombre fixe, soit une expression JavaScript telle que navigator.hardwareConcurrency pour créer autant de threads qu'il y a de cœurs sur le processeur. Cette dernière option est utile lorsque votre code peut évoluer pour atteindre un nombre arbitraire de threads.

Dans l'exemple ci-dessus, un seul thread est créé. Par conséquent, au lieu de réserver tous les cœurs, il suffit d'utiliser -s PTHREAD_POOL_SIZE=1:

emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js

Cette fois, lorsque vous l'exécutez, tout fonctionne correctement:

Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.

Toutefois, un autre problème se pose: voyez que sleep(1) dans l'exemple de code ? Il s'exécute dans le rappel de thread, c'est-à-dire en dehors du thread principal. Cela devrait donc fonctionner, n'est-ce pas ? Eh bien, ce n'est pas le cas.

Lorsque pthread_join est appelé, il doit attendre la fin de l'exécution du thread. Cela signifie que si le thread créé effectue des tâches de longue durée (dans ce cas, une seconde en veille), le thread principal doit également être bloqué pendant le même temps jusqu'à ce que les résultats soient de retour. Lorsque ce code JS est exécuté dans le navigateur, il bloque le thread UI pendant une seconde jusqu'à ce que le rappel du thread soit renvoyé. Cela nuit à l'expérience utilisateur.

Plusieurs solutions s'offrent à vous:

  • pthread_detach
  • -s PROXY_TO_PTHREAD
  • Nœud de calcul personnalisé et Comlink

pthread_detach

Tout d'abord, si vous n'avez besoin d'exécuter que certaines tâches en dehors du thread principal, sans attendre les résultats, vous pouvez utiliser pthread_detach au lieu de pthread_join. Le rappel de thread restera ainsi en cours d'exécution en arrière-plan. Si vous utilisez cette option, vous pouvez désactiver l'avertissement avec -s PTHREAD_POOL_SIZE_STRICT=0.

PROXY_TO_PTHREAD

Ensuite, si vous compilez une application C plutôt qu'une bibliothèque, vous pouvez utiliser l'option -s PROXY_TO_PTHREAD, qui décharge le code principal de l'application dans un thread distinct en plus des threads imbriqués créés par l'application elle-même. De cette façon, le code principal peut être bloqué à tout moment et en toute sécurité, sans figer l'interface utilisateur. Par ailleurs, lorsque vous utilisez cette option, vous n'avez pas non plus à précréer le pool de threads. Emscripten peut exploiter le thread principal pour créer des workers sous-jacents, puis bloquer le thread d'aide dans pthread_join sans interblocage.

Troisièmement, si vous travaillez sur une bibliothèque et devez toujours bloquer, vous pouvez créer votre propre nœud de calcul, importer le code généré par Emscripten et l'exposer avec Comlink au thread principal. Le thread principal pourra appeler toutes les méthodes exportées en tant que fonctions asynchrones, ce qui évitera également de bloquer l'UI.

Dans une application simple comme l'exemple précédent, -s PROXY_TO_PTHREAD est la meilleure option:

emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js

C++

Les mêmes mises en garde et la même logique s'appliquent de la même manière à C++. La seule nouveauté que vous obtenez est l'accès à des API de niveau supérieur telles que std::thread et std::async, qui utilisent en arrière-plan la bibliothèque pthread mentionnée précédemment.

Ainsi, l'exemple ci-dessus peut être réécrit en C++ plus idiomatique comme ceci:

example.cpp:

#include <iostream>
#include <thread>
#include <chrono>

int main()
{
    puts("Before the thread");

    int arg = 42;
    std::thread thread([&]() {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Inside the thread: " << arg << std::endl;
    });

    thread.join();

    std::cout << "After the thread" << std::endl;

    return 0;
}

Lorsqu'elle est compilée et exécutée avec des paramètres similaires, elle se comporte de la même manière que l'exemple C:

emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js

Résultat :

Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.

Rust

Contrairement à Emscripten, Rust n'a pas de cible Web spécialisée de bout en bout, mais fournit une cible wasm32-unknown-unknown générique pour la sortie WebAssembly générique.

Si Wasm est destiné à être utilisé dans un environnement Web, toute interaction avec les API JavaScript est confiée à des bibliothèques et à des outils externes tels que wasm-bindgen et wasm-pack. Malheureusement, cela signifie que la bibliothèque standard n'est pas consciente des Web Workers, et les API standards telles que std::thread ne fonctionneront pas une fois compilées dans WebAssembly.

Heureusement, la majeure partie de l'écosystème dépend de bibliothèques de niveau supérieur pour gérer le multithreading. À ce niveau, il est beaucoup plus facile d'éliminer toutes les différences de plates-formes.

En particulier, Rayon est le choix le plus populaire pour le parallélisme des données dans Rust. Il vous permet d'utiliser des chaînes de méthodes sur des itérateurs standards et de les convertir, généralement en un seul changement de ligne, de sorte qu'elles s'exécutent en parallèle sur tous les threads disponibles plutôt que de manière séquentielle. Exemple :

pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .iter()
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Avec cette petite modification, le code divisera les données d'entrée, calculera x * x et les sommes partielles dans des threads parallèles, puis additionnera ces résultats partiels.

Pour s'adapter aux plates-formes sans std::thread fonctionnel, Rayon fournit des hooks qui permettent de définir une logique personnalisée pour la génération et la sortie des threads.

wasm-bindgen-rayon exploite ces hooks pour générer des threads WebAssembly en tant que Web Workers. Pour l'utiliser, vous devez l'ajouter en tant que dépendance et suivre les étapes de configuration décrites dans la docs. L'exemple ci-dessus finira par se présenter comme suit:

pub use wasm_bindgen_rayon::init_thread_pool;

#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
  numbers
  .par_iter()
  .map(|x| x * x)
  .sum()
}

Une fois l'opération terminée, le code JavaScript généré exporte une fonction initThreadPool supplémentaire. Cette fonction crée un pool de nœuds de calcul et les réutilise tout au long de la durée de vie du programme pour toutes les opérations multithread effectuées par Rayon.

Ce mécanisme de pool est semblable à l'option -s PTHREAD_POOL_SIZE=... d'Emmscripten expliquée précédemment, et doit également être initialisé avant le code principal pour éviter les interblocages:

import init, { initThreadPool, sum_of_squares } from './pkg/index.js';

// Regular wasm-bindgen initialization.
await init();

// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);

// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14

Notez que les mêmes avertissements concernant le blocage du thread principal s'appliquent également ici. Même l'exemple sum_of_squares doit toujours bloquer le thread principal pour attendre les résultats partiels d'autres threads.

L'attente peut être très courte ou longue, en fonction de la complexité des itérateurs et du nombre de threads disponibles, mais, par précaution, les moteurs de navigateur empêchent activement le blocage du thread principal. Ce code génère alors une erreur. À la place, vous devez créer un worker, y importer le code généré par wasm-bindgen et exposer son API avec une bibliothèque telle que Comlink au thread principal.

Consultez l'exemple de wasm-bindgen-rayon pour une démonstration de bout en bout:

Cas d'utilisation concrets

Nous utilisons activement les threads WebAssembly dans Squoosh.app pour la compression d'images côté client, en particulier pour les formats tels que AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) et WebP v2 (C++). Grâce au multithreading seul, nous avons constaté des accélérations de 1,5 à 3 fois avec WebAssembly et la possibilité de transmettre des nombres de 1,5 à 3 fois plus rapidement (aussi bien les chiffres que le code WebAssembly).

Google Earth est un autre service remarquable qui utilise les threads WebAssembly pour sa version Web.

FFMPEG.WASM est une version WebAssembly d'une chaîne d'outils multimédia FFmpeg populaire qui utilise des threads WebAssembly pour encoder efficacement des vidéos directement dans le navigateur.

Il existe de nombreux autres exemples intéressants utilisant les threads WebAssembly. N'oubliez pas de consulter les démonstrations et d'utiliser sur le Web vos propres applications et bibliothèques multithread.