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

Découvrez comment migrer des applications multithreads écrites dans d'autres langages vers WebAssembly.

La prise en charge des threads WebAssembly est l'une des améliorations de performances les plus importantes de WebAssembly. Il vous permet d'exécuter des parties de votre code en parallèle sur des cœurs distincts ou le même code sur des parties indépendantes des données d'entrée, en l'adaptant à autant de cœurs que l'utilisateur possède et en réduisant considérablement le temps d'exécution global.

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

Fonctionnement des threads WebAssembly

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

Web Worker

Le premier composant est les Workers que vous connaissez et aimez 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 par JavaScript.

Les Web Workers existent depuis plus d'une décennie, sont largement acceptés 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'octet brut 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 reçu 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 de chaque côté.

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

Contrairement à postMessage, qui est 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 que la boucle d'événements envoie et reçoive des messages. Au lieu de cela, toutes les modifications sont visibles par tous les threads presque instantanément, ce qui en fait une cible de compilation bien meilleure pour les primitives de synchronisation traditionnelles.

SharedArrayBuffer a une histoire complexe. Il a été initialement déployé dans plusieurs navigateurs à la mi-2017, mais a dû être désactivé début 2018 en raison de la découverte de vulnérabilités Spectre. La raison en est que l'extraction de données dans Spectre repose sur des attaques par temporisation, qui mesurent le temps d'exécution d'un fragment de code spécifique. Pour rendre ce type d'attaque plus difficile, les navigateurs ont réduit la précision des API de synchronisation standards telles que Date.now et performance.now. Cependant, la mémoire partagée, combinée à une boucle de compteur simple exécutée dans un thread distinct, est également un moyen très fiable d'obtenir un chronométrage de haute précision, et il est beaucoup plus difficile de l'atténuer sans réduire considérablement les performances d'exécution.

À la place, Chrome 68 (mi-2018) a réactivé SharedArrayBuffer en exploitant l'isolation de sites, une fonctionnalité qui place différents sites Web dans différents processus et rend beaucoup plus difficile l'utilisation d'attaques par canal auxiliaire telles que Spectre. Toutefois, cette atténuation était encore limitée à Chrome pour ordinateur, car l'isolation de site est une fonctionnalité assez coûteuse et ne pouvait pas être activée par défaut pour tous les sites sur les appareils mobiles à faible mémoire. Elle n'était pas encore implémentée par d'autres fournisseurs.

En 2020, Chrome et Firefox ont tous deux implémenté l'isolation de sites, et les sites Web peuvent activer cette fonctionnalité de manière standard avec les en-têtes COOP et COEP. Un mécanisme d'activation permet d'utiliser l'isolation de site même sur les appareils à faible consommation d'énergie, où l'activation pour tous les sites Web serait trop coûteuse. Pour l'activer, ajoutez les en-têtes suivants au document principal de votre configuration de serveur:

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

Une fois l'activation effectuée, vous avez accès à SharedArrayBuffer (y compris WebAssembly.Memory avec SharedArrayBuffer en arrière-plan), aux minuteurs précis, à la mesure 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 Isoler votre site Web de manière inter-origine à l'aide de COOP et COEP.

Atomes WebAssembly

Bien que SharedArrayBuffer permette à chaque thread de lire et d'écrire dans la même mémoire, vous devez vous assurer qu'ils n'effectuent pas d'opérations en conflit en même temps pour une communication correcte. 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. Le premier thread obtiendra donc un résultat corrompu. Cette catégorie de bugs est appelée "condition de concurrence". Pour éviter les conditions de course, vous devez synchroniser ces accès d'une manière ou d'une autre. C'est là qu'interviennent les opérations atomiques.

Les opérations atomiques WebAssembly sont une extension du jeu d'instructions WebAssembly qui permet de lire et d'écrire de petites cellules de données (généralement des entiers 32 et 64 bits) "de manière atomique". C'est-à-dire d'une manière qui garantit qu'aucun thread ne lit ni n'écrit dans la même cellule en même temps, ce qui évite de tels conflits à un niveau bas. De plus, les atomes WebAssembly contiennent deux autres types d'instructions (wait et notify) qui permettent à un thread de passer en veille (wait) sur une adresse donnée dans une mémoire partagée jusqu'à ce qu'un autre thread le réveille 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

Les atomes WebAssembly et SharedArrayBuffer sont des fonctionnalités relativement récentes et ne sont pas encore disponibles dans tous les navigateurs compatibles avec WebAssembly. Vous pouvez identifier les navigateurs compatibles avec les nouvelles fonctionnalités WebAssembly dans la feuille de route webassembly.org.

Pour vous assurer que tous les utilisateurs peuvent charger votre application, vous devez implémenter l'amélioration progressive en créant deux versions différentes de Wasm : une avec prise en charge du multithreading et une sans. Chargez ensuite la version compatible en fonction des résultats de la détection des fonctionnalités. Pour détecter la prise en charge 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

En 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 implémentation compatible avec l'API de la bibliothèque pthread basée sur les Web Workers, la mémoire partagée et les atomes, afin que le même code puisse fonctionner sur le Web sans modification.

Prenons 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 en arrière-plan. Il nécessite une destination pour stocker un gestionnaire de thread, des attributs de création de thread (ici, aucun n'est transmis, il s'agit donc de NULL), le rappel à exécuter dans le nouveau thread (ici thread_callback) et un pointeur d'argument facultatif à transmettre à ce rappel au cas où vous souhaiteriez partager des données du thread principal. Dans cet exemple, nous partageons un pointeur vers une variable arg.

pthread_join peut être appelé plus tard à tout moment pour attendre que le thread termine l'exécution et obtenir le résultat renvoyé par le rappel. Il accepte le gestionnaire de thread attribué précédemment, ainsi qu'un pointeur pour stocker le résultat. Dans ce cas, il n'y a pas de résultats. La fonction prend donc un 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 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 s'appuient sur la boucle d'événements pour s'exécuter. Cette limitation constitue une distinction importante par rapport aux environnements traditionnels, où les applications exécutent normalement les 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 en arrière-plan, puis effectue un autre appel synchrone à pthread_join qui attend la fin de l'exécution du thread en arrière-plan. Toutefois, les Web Workers, qui sont utilisés en coulisses lorsque ce code est compilé avec Emscripten, sont asynchrones. pthread_create ne planifie donc qu'un nouveau thread de travail à créer lors de l'exécution de la boucle d'événements suivante, mais pthread_join bloque immédiatement la boucle d'événements pour attendre ce thread de travail, ce qui l'empêche d'être créé. Il s'agit d'un exemple classique de boucle de blocage.

Pour résoudre ce problème, vous pouvez 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 récupérer un nœud de calcul prêt à l'emploi dans le pool, 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 de blocages tant que le pool est suffisamment important.

C'est exactement ce qu'Emscripten permet avec l'option -s PTHREAD_POOL_SIZE=.... Il permet de spécifier un nombre de threads (un nombre fixe ou 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 être mis à l'échelle à un nombre arbitraire de threads.

Dans l'exemple ci-dessus, un seul thread est créé. 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.

Il y a cependant un autre problème: voyez-vous ce sleep(1) dans l'exemple de code ? Il s'exécute dans le rappel de thread, ce qui signifie qu'il n'est pas sur le thread principal. Tout devrait donc bien se passer, n'est-ce pas ? Non.

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

Il existe plusieurs solutions à ce problème:

  • 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, mais que vous n'avez pas besoin d'attendre les résultats, vous pouvez utiliser pthread_detach au lieu de pthread_join. Le rappel de thread continuera de s'exécuter 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

Deuxièmement, si vous compilez une application C plutôt qu'une bibliothèque, vous pouvez utiliser l'option -s PROXY_TO_PTHREAD, qui transfère le code d'application principal vers un thread distinct en plus de tous les threads imbriqués créés par l'application elle-même. De cette manière, le code principal peut bloquer de manière sécurisée à tout moment sans figer l'UI. Par ailleurs, lorsque vous utilisez cette option, vous n'avez pas non plus à précréer le pool de threads. Au lieu de cela, Emscripten peut exploiter le thread principal pour créer de nouveaux travailleurs sous-jacents, puis bloquer le thread d'assistance dans pthread_join sans blocage.

Troisièmement, si vous travaillez sur une bibliothèque et que vous devez toujours bloquer, vous pouvez créer votre propre Worker, importer le code généré par Emscripten et l'exposer au thread principal avec Comlink. 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++

Toutes 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 gagnez est l'accès à des API de niveau supérieur telles que std::thread et std::async, qui utilisent la bibliothèque pthread précédemment décrite.

L'exemple ci-dessus peut donc être réécrit en C++ plus idiomatique comme suit:

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'il est compilé et exécuté avec des paramètres similaires, il 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

Sortie :

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 ne dispose pas d'une cible Web de bout en bout spécialisée, mais fournit plutôt 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 laissée aux bibliothèques et outils externes tels que wasm-bindgen et wasm-pack. Malheureusement, cela signifie que la bibliothèque standard n'est pas consciente des nœuds de calcul Web et que les API standards telles que std::thread ne fonctionneront pas lorsqu'elles seront compilées en WebAssembly.

Heureusement, la majorité 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 plate-forme.

En particulier, Rayon est le choix le plus populaire pour le parallélisme de données en Rust. Il vous permet de prendre des chaînes de méthodes sur des itérateurs réguliers et, généralement avec une seule ligne de modification, de les convertir de manière à ce qu'elles s'exécutent en parallèle sur tous les threads disponibles au lieu de s'exécuter 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 divise les données d'entrée, calcule x * x et les sommes partielles dans des threads parallèles, puis additionne ces résultats partiels.

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

wasm-bindgen-rayon exploite ces hooks pour générer des threads WebAssembly en tant que nœuds de calcul Web. Pour l'utiliser, vous devez l'ajouter en tant que dépendance et suivre les étapes de configuration décrites dans la documentation. L'exemple ci-dessus se présente 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 cette opération effectuée, le code JavaScript généré exportera une fonction initThreadPool supplémentaire. Cette fonction crée un pool de workers et les réutilise tout au long de la durée de vie du programme pour toutes les opérations multithreads effectuées par Rayon.

Ce mécanisme de pool est semblable à l'option -s PTHREAD_POOL_SIZE=... d'Emscripten expliquée précédemment. Il 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 mises en garde 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 très longue, en fonction de la complexité des itérateurs et du nombre de threads disponibles. Toutefois, pour plus de sécurité, les moteurs de navigateur empêchent activement le blocage du thread principal. Un tel code génère alors une erreur. Vous devez plutôt 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 wasm-bindgen-rayon pour une démonstration de bout en bout montrant:

Cas d'utilisation concrets

Nous utilisons activement des 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 constantes de 1,5 à 3 fois (le ratio exact diffère selon le codec), et nous avons pu pousser ces chiffres encore plus loin en combinant des threads WebAssembly avec WebAssembly SIMD.

Google Earth est un autre service notable qui utilise des threads WebAssembly pour sa version Web.

FFMPEG.WASM est une version WebAssembly d'une chaîne d'outils multimédias 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 des threads WebAssembly. N'hésitez pas à consulter les démonstrations et à publier vos propres applications et bibliothèques multithread sur le Web.