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'étendant à autant de cœurs que l'utilisateur en 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 les paradigmes de multithreading traditionnels sur le Web.
Nœuds de calcul Web
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 de 10 ans maintenant, 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 accessible uniquement par un seul thread.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
Pour prendre en charge le multithreading, WebAssembly.Memory
a également créé 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 vues 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. Elle a été initialement lancée dans plusieurs navigateurs mi-2017, mais a dû être désactivée début 2018 en raison de la découverte de failles Spectre. Cela s'explique en particulier par le fait que l'extraction de données dans Spectre repose sur des attaques temporelles, c'est-à-dire la mesure de la durée 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 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 limiter considérablement les performances d'exécution.
Au lieu de cela, Chrome 68 (mi-2018) a réactivé SharedArrayBuffer
en exploitant l'isolation de sites, une fonctionnalité qui associe différents sites Web à différents processus et rend bien plus difficile l'utilisation d'attaques par canal auxiliaire comme Spectre. Toutefois, cette atténuation n'était toujours limitée qu'à 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 la configuration de votre serveur:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Une fois activé, vous avez accès à SharedArrayBuffer
(y compris WebAssembly.Memory
reposant sur un SharedArrayBuffer
), à des minuteurs précis, à des mesures de 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 obtient donc désormais 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à que les opérations atomiques entrent en jeu.
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 compatibilité des threads WebAssembly au moment de l'exécution, utilisez la bibliothèquewasm-feature-detect, puis 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
construite sur des Web Workers, une mémoire partagée et des 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 utilise une destination pour stocker un handle de thread, certains attributs de création de thread (ici, sans transmettre aucun, 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 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 dans 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 dépendent de 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. Ainsi, pthread_create
planifie la création d'un thread de nœud de calcul lors de la prochaine exécution de la boucle d'événements, mais pthread_join
bloque immédiatement la boucle d'événements pour attendre ce worker, ce qui empêche sa création. 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 d'interblocage tant que le pool est suffisamment grand.
C'est exactement ce qu'Emmscripten permet avec l'option -s
PTHREAD_POOL_SIZE=...
. Il 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 ê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 ? Eh bien, ce n'est pas le cas.
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 conduit à une mauvaise 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
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 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.
Comlink
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 entre les plates-formes.
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 finira par ressembler à ceci:
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 de bloquer complètement le thread principal, et un tel code génère 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 :
- Détection des fonctionnalités des threads
- Création de versions monothread et multithread de la même application Rust.
- Chargement du code JS+Wasm généré par wasm-bindgen dans un Worker.
- Utilisation de wasm-bindgen-rayon pour initialiser un pool de threads.
- Utilisation de Comlink pour exposer l'API du worker au thread principal.
Cas d'utilisation concrets
Nous utilisons activement des threads WebAssembly dans Squoosh.app pour la compression des images côté client, en particulier pour des formats tels que AVIF (C++), JPEG-XL (C++), OxiPNG (Rust) et WebP v2 (C++). Grâce au multithreading, nous avons constaté des nombres cohérents de 1,5x-3x pour le code push et de WebAssembl.
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é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 des threads WebAssembly. N'hésitez pas à regarder les démonstrations et à publier vos propres applications et bibliothèques multithread sur le Web.