Débloquez le véritable multithreading en JavaScript. Ce guide complet aborde SharedArrayBuffer, Atomics, les Web Workers et les exigences de sécurité pour les applications web à haute performance.
JavaScript SharedArrayBuffer : Guide Approfondi de la Programmation Concurrente sur le Web
Pendant des décennies, la nature monothread de JavaScript a été à la fois une source de sa simplicité et un important goulot d'étranglement en termes de performances. Le modèle de la boucle d'événements fonctionne à merveille pour la plupart des tâches orientées interface utilisateur, mais il éprouve des difficultés face à des opérations de calcul intensif. Des calculs de longue durée peuvent figer le navigateur, créant une expérience utilisateur frustrante. Bien que les Web Workers aient offert une solution partielle en permettant l'exécution de scripts en arrière-plan, ils présentaient leur propre limitation majeure : une communication de données inefficace.
C'est là qu'intervient SharedArrayBuffer
(SAB), une fonctionnalité puissante qui change fondamentalement la donne en introduisant un véritable partage de mémoire de bas niveau entre les threads sur le web. Associé à l'objet Atomics
, le SAB ouvre une nouvelle ère d'applications concurrentes à haute performance directement dans le navigateur. Cependant, un grand pouvoir implique de grandes responsabilités — et une grande complexité.
Ce guide vous plongera dans le monde de la programmation concurrente en JavaScript. Nous explorerons pourquoi nous en avons besoin, comment SharedArrayBuffer
et Atomics
fonctionnent, les considérations de sécurité critiques que vous devez aborder, et des exemples pratiques pour vous lancer.
L'Ancien Monde : Le Modèle Monothread de JavaScript et ses Limites
Avant de pouvoir apprécier la solution, nous devons bien comprendre le problème. L'exécution de JavaScript dans un navigateur se fait traditionnellement sur un seul thread, souvent appelé le "thread principal" ou "thread UI".
La Boucle d'Événements
Le thread principal est responsable de tout : exécuter votre code JavaScript, effectuer le rendu de la page, répondre aux interactions de l'utilisateur (comme les clics et les défilements) et exécuter les animations CSS. Il gère ces tâches à l'aide d'une boucle d'événements, qui traite en continu une file d'attente de messages (tâches). Si une tâche prend beaucoup de temps à s'exécuter, elle bloque toute la file d'attente. Rien d'autre ne peut se passer : l'interface utilisateur se fige, les animations saccadent et la page ne répond plus.
Les Web Workers : Un Pas dans la Bonne Direction
Les Web Workers ont été introduits pour pallier ce problème. Un Web Worker est essentiellement un script s'exécutant sur un thread d'arrière-plan distinct. Vous pouvez déléguer des calculs lourds à un worker, laissant le thread principal libre de gérer l'interface utilisateur.
La communication entre le thread principal et un worker se fait via l'API postMessage()
. Lorsque vous envoyez des données, elles sont traitées par l'algorithme de clonage structuré. Cela signifie que les données sont sérialisées, copiées, puis désérialisées dans le contexte du worker. Bien qu'efficace, ce processus présente des inconvénients importants pour les grands ensembles de données :
- Surcharge de Performance : Copier des mégaoctets ou même des gigaoctets de données entre les threads est lent et gourmand en CPU.
- Consommation Mémoire : Cela crée un duplicata des données en mémoire, ce qui peut être un problème majeur pour les appareils à mémoire limitée.
Imaginez un éditeur vidéo dans le navigateur. Envoyer une trame vidéo entière (qui peut peser plusieurs mégaoctets) à un worker pour traitement, 60 fois par seconde, serait prohibitif en termes de coût. C'est précisément le problème que SharedArrayBuffer
a été conçu pour résoudre.
Le Tournant Décisif : Introduction de SharedArrayBuffer
Un SharedArrayBuffer
est un tampon de données binaires brutes de longueur fixe, similaire à un ArrayBuffer
. La différence cruciale est qu'un SharedArrayBuffer
peut être partagé entre plusieurs threads (par exemple, le thread principal et un ou plusieurs Web Workers). Lorsque vous "envoyez" un SharedArrayBuffer
avec postMessage()
, vous n'envoyez pas une copie ; vous envoyez une référence au même bloc de mémoire.
Cela signifie que toute modification apportée aux données du tampon par un thread est instantanément visible par tous les autres threads qui y ont une référence. Cela élimine l'étape coûteuse de copie et de sérialisation, permettant un partage de données quasi instantané.
Imaginez la chose ainsi :
- Web Workers avec
postMessage()
: C'est comme deux collègues travaillant sur un document en s'envoyant des copies par e-mail. Chaque modification nécessite l'envoi d'une nouvelle copie entière. - Web Workers avec
SharedArrayBuffer
: C'est comme deux collègues travaillant sur le même document dans un éditeur en ligne partagé (comme Google Docs). Les modifications sont visibles par les deux en temps réel.
Le Danger de la Mémoire Partagée : Les Conditions de Concurrence
Le partage de mémoire instantané est puissant, mais il introduit également un problème classique du monde de la programmation concurrente : les conditions de concurrence (race conditions).
Une condition de concurrence se produit lorsque plusieurs threads tentent d'accéder et de modifier les mêmes données partagées simultanément, et le résultat final dépend de l'ordre imprévisible dans lequel ils s'exécutent. Prenons un simple compteur stocké dans un SharedArrayBuffer
. Le thread principal et un worker veulent tous deux l'incrémenter.
- Le Thread A lit la valeur actuelle, qui est 5.
- Avant que le Thread A ne puisse écrire la nouvelle valeur, le système d'exploitation le met en pause et passe au Thread B.
- Le Thread B lit la valeur actuelle, qui est toujours 5.
- Le Thread B calcule la nouvelle valeur (6) et l'écrit en mémoire.
- Le système revient au Thread A. Il ne sait pas que le Thread B a fait quoi que ce soit. Il reprend là où il s'était arrêté, calcule sa nouvelle valeur (5 + 1 = 6) et écrit 6 en mémoire.
Même si le compteur a été incrémenté deux fois, la valeur finale est 6, et non 7. Les opérations n'étaient pas atomiques — elles étaient interruptibles, ce qui a entraîné une perte de données. C'est précisément pourquoi vous ne pouvez pas utiliser un SharedArrayBuffer
sans son partenaire crucial : l'objet Atomics
.
Le Gardien de la Mémoire Partagée : L'Objet Atomics
L'objet Atomics
fournit un ensemble de méthodes statiques pour effectuer des opérations atomiques sur les objets SharedArrayBuffer
. Une opération atomique est garantie de s'exécuter dans son intégralité sans être interrompue par aucune autre opération. Soit elle se produit complètement, soit pas du tout.
L'utilisation d'Atomics
prévient les conditions de concurrence en garantissant que les opérations de lecture-modification-écriture sur la mémoire partagée sont effectuées en toute sécurité.
Méthodes Clés d'Atomics
Examinons quelques-unes des méthodes les plus importantes fournies par Atomics
.
Atomics.load(typedArray, index)
: Lit atomiquement la valeur à un index donné et la renvoie. Cela garantit que vous lisez une valeur complète et non corrompue.Atomics.store(typedArray, index, value)
: Stocke atomiquement une valeur à un index donné et renvoie cette valeur. Cela garantit que l'opération d'écriture n'est pas interrompue.Atomics.add(typedArray, index, value)
: Ajoute atomiquement une valeur à la valeur à l'index donné. Elle renvoie la valeur originale à cette position. C'est l'équivalent atomique dex += value
.Atomics.sub(typedArray, index, value)
: Soustrait atomiquement une valeur de la valeur à l'index donné.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Il s'agit d'une écriture conditionnelle puissante. Elle vérifie si la valeur àindex
est égale àexpectedValue
. Si c'est le cas, elle la remplace parreplacementValue
et renvoie laexpectedValue
originale. Sinon, elle ne fait rien et renvoie la valeur actuelle. C'est un élément de base fondamental pour implémenter des primitives de synchronisation plus complexes comme les verrous.
Synchronisation : Au-delà des Opérations Simples
Parfois, vous avez besoin de plus que de simples lectures et écritures sécurisées. Vous avez besoin que les threads se coordonnent et s'attendent les uns les autres. Un anti-pattern courant est "l'attente active" (busy-waiting), où un thread reste dans une boucle serrée, vérifiant constamment un emplacement mémoire pour un changement. Cela gaspille des cycles CPU et épuise la batterie.
Atomics
fournit une solution beaucoup plus efficace avec wait()
et notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Ceci indique à un thread de se mettre en veille. Il vérifie si la valeur àindex
est toujoursvalue
. Si c'est le cas, le thread se met en veille jusqu'à ce qu'il soit réveillé parAtomics.notify()
ou jusqu'à ce que letimeout
optionnel (en millisecondes) soit atteint. Si la valeur àindex
a déjà changé, il retourne immédiatement. C'est incroyablement efficace car un thread en veille ne consomme presque aucune ressource CPU.Atomics.notify(typedArray, index, count)
: Ceci est utilisé pour réveiller les threads qui sont en veille sur un emplacement mémoire spécifique viaAtomics.wait()
. Il réveillera au maximumcount
threads en attente (ou tous sicount
n'est pas fourni ou estInfinity
).
Mettre Tout en Œuvre : Un Guide Pratique
Maintenant que nous comprenons la théorie, passons en revue les étapes de la mise en œuvre d'une solution utilisant SharedArrayBuffer
.
Étape 1 : Le Prérequis de Sécurité - L'Isolation Inter-Origine
C'est l'écueil le plus courant pour les développeurs. Pour des raisons de sécurité, SharedArrayBuffer
n'est disponible que dans les pages qui sont dans un état isolé inter-origine. C'est une mesure de sécurité pour atténuer les vulnérabilités d'exécution spéculative comme Spectre, qui pourraient potentiellement utiliser des temporisateurs haute résolution (rendus possibles par la mémoire partagée) pour fuiter des données entre les origines.
Pour activer l'isolation inter-origine, vous devez configurer votre serveur web pour qu'il envoie deux en-têtes HTTP spécifiques pour votre document principal :
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP) : Isole le contexte de navigation de votre document des autres documents, les empêchant d'interagir directement avec votre objet window.Cross-Origin-Embedder-Policy: require-corp
(COEP) : Exige que toutes les sous-ressources (comme les images, les scripts et les iframes) chargées par votre page proviennent soit de la même origine, soit soient explicitement marquées comme chargeables en inter-origine avec l'en-têteCross-Origin-Resource-Policy
ou CORS.
Cela peut être difficile à mettre en place, surtout si vous dépendez de scripts ou de ressources tiers qui ne fournissent pas les en-têtes nécessaires. Après avoir configuré votre serveur, vous pouvez vérifier si votre page est isolée en consultant la propriété self.crossOriginIsolated
dans la console du navigateur. Elle doit être à true
.
Étape 2 : Créer et Partager le Tampon
Dans votre script principal, vous créez le SharedArrayBuffer
et une "vue" sur celui-ci à l'aide d'un TypedArray
comme Int32Array
.
main.js :
// Vérifiez d'abord l'isolation inter-origine !
if (!self.crossOriginIsolated) {
console.error("Cette page n'est pas isolée inter-origine. SharedArrayBuffer ne sera pas disponible.");
} else {
// Crée un tampon partagé pour un entier de 32 bits.
const buffer = new SharedArrayBuffer(4);
// Crée une vue sur le tampon. Toutes les opérations atomiques se font sur la vue.
const int32Array = new Int32Array(buffer);
// Initialise la valeur à l'index 0.
int32Array[0] = 0;
// Crée un nouveau worker.
const worker = new Worker('worker.js');
// Envoie le tampon PARTAGÉ au worker. C'est un transfert de référence, pas une copie.
worker.postMessage({ buffer });
// Écoute les messages du worker.
worker.onmessage = (event) => {
console.log(`Le worker a signalé la fin. Valeur finale : ${Atomics.load(int32Array, 0)}`);
};
}
Étape 3 : Effectuer des Opérations Atomiques dans le Worker
Le worker reçoit le tampon et peut maintenant effectuer des opérations atomiques dessus.
worker.js :
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Le worker a reçu le tampon partagé.");
// Effectuons quelques opérations atomiques.
for (let i = 0; i < 1000000; i++) {
// Incrémente la valeur partagée en toute sécurité.
Atomics.add(int32Array, 0, 1);
}
console.log("Le worker a fini d'incrémenter.");
// Signale au thread principal que nous avons terminé.
self.postMessage({ done: true });
};
Étape 4 : Un Exemple Plus Avancé - Sommation Parallèle avec Synchronisation
Abordons un problème plus réaliste : sommer un très grand tableau de nombres en utilisant plusieurs workers. Nous utiliserons Atomics.wait()
et Atomics.notify()
pour une synchronisation efficace.
Notre tampon partagé aura trois parties :
- Index 0 : Un drapeau de statut (0 = en cours de traitement, 1 = terminé).
- Index 1 : Un compteur pour le nombre de workers ayant terminé.
- Index 2 : La somme finale.
main.js :
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [statut, workers_termines, resultat_bas, resultat_haut]
// Nous utilisons deux entiers de 32 bits pour le résultat afin d'éviter le débordement pour les grandes sommes.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 entiers
const sharedArray = new Int32Array(sharedBuffer);
// Génère des données aléatoires à traiter
const data = new Uint8Array(DATA_SIZE);
for (let i = 0; i < DATA_SIZE; i++) {
data[i] = Math.floor(Math.random() * 10);
}
const chunkSize = Math.ceil(DATA_SIZE / NUM_WORKERS);
for (let i = 0; i < NUM_WORKERS; i++) {
const worker = new Worker('sum_worker.js');
const start = i * chunkSize;
const end = Math.min(start + chunkSize, DATA_SIZE);
// Crée une vue non partagée pour le segment de données du worker
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Ceci est copié
});
}
console.log('Le thread principal attend maintenant que les workers terminent...');
// Attend que le drapeau de statut à l'index 0 devienne 1
// C'est bien mieux qu'une boucle while !
Atomics.wait(sharedArray, 0, 0); // Attend si sharedArray[0] est 0
console.log('Le thread principal a été réveillé !');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`La somme parallèle finale est : ${finalSum}`);
} else {
console.error('La page n\'est pas isolée inter-origine.');
}
sum_worker.js :
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Calcule la somme pour le segment de ce worker
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Ajoute atomiquement la somme locale au total partagé
Atomics.add(sharedArray, 2, localSum);
// Incrémente atomiquement le compteur 'workers terminés'
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Si c'est le dernier worker à finir...
const NUM_WORKERS = 4; // Devrait être passé en paramètre dans une vraie app
if (finishedCount === NUM_WORKERS) {
console.log('Le dernier worker a terminé. Notification au thread principal.');
// 1. Met le drapeau de statut à 1 (terminé)
Atomics.store(sharedArray, 0, 1);
// 2. Notifie le thread principal, qui attend sur l'index 0
Atomics.notify(sharedArray, 0, 1);
}
};
Cas d'Utilisation et Applications Concrètes
Où cette technologie puissante mais complexe fait-elle réellement la différence ? Elle excelle dans les applications qui nécessitent des calculs lourds et parallélisables sur de grands ensembles de données.
- WebAssembly (Wasm) : C'est le cas d'utilisation phare. Des langages comme C++, Rust et Go ont un support mature pour le multithreading. Wasm permet aux développeurs de compiler ces applications existantes à haute performance et multithread (comme les moteurs de jeu, les logiciels de CAO et les modèles scientifiques) pour qu'elles s'exécutent dans le navigateur, en utilisant
SharedArrayBuffer
comme mécanisme sous-jacent pour la communication entre threads. - Traitement de Données dans le Navigateur : La visualisation de données à grande échelle, l'inférence de modèles d'apprentissage automatique côté client et les simulations scientifiques qui traitent d'énormes quantités de données peuvent être considérablement accélérées.
- Édition de Médias : L'application de filtres à des images haute résolution ou le traitement audio sur un fichier son peuvent être décomposés en segments et traités en parallèle par plusieurs workers, fournissant un retour en temps réel à l'utilisateur.
- Jeux à Haute Performance : Les moteurs de jeu modernes dépendent fortement du multithreading pour la physique, l'IA et le chargement des ressources.
SharedArrayBuffer
permet de créer des jeux de qualité console qui s'exécutent entièrement dans le navigateur.
Défis et Considérations Finales
Bien que SharedArrayBuffer
soit transformateur, ce n'est pas une solution miracle. C'est un outil de bas niveau qui nécessite une manipulation prudente.
- Complexité : La programmation concurrente est notoirement difficile. Le débogage des conditions de concurrence et des interblocages (deadlocks) peut être incroyablement ardu. Vous devez penser différemment à la manière dont l'état de votre application est géré.
- Interblocages : Un interblocage se produit lorsque deux threads ou plus sont bloqués indéfiniment, chacun attendant que l'autre libère une ressource. Cela peut arriver si vous implémentez incorrectement des mécanismes de verrouillage complexes.
- Surcharge de Sécurité : L'exigence d'isolation inter-origine est un obstacle important. Elle peut casser les intégrations avec des services tiers, des publicités et des passerelles de paiement s'ils ne prennent pas en charge les en-têtes CORS/CORP nécessaires.
- Pas pour Tous les Problèmes : Pour de simples tâches d'arrière-plan ou des opérations d'E/S, le modèle traditionnel de Web Worker avec
postMessage()
est souvent plus simple et suffisant. Ne recourez àSharedArrayBuffer
que lorsque vous avez un goulot d'étranglement clair, lié au CPU et impliquant de grandes quantités de données.
Conclusion
SharedArrayBuffer
, en conjonction avec Atomics
et les Web Workers, représente un changement de paradigme pour le développement web. Il brise les frontières du modèle monothread, invitant une nouvelle classe d'applications puissantes, performantes et complexes dans le navigateur. Il place la plateforme web sur un pied d'égalité avec le développement d'applications natives pour les tâches de calcul intensif.
Le voyage dans le JavaScript concurrent est exigeant, requérant une approche rigoureuse de la gestion d'état, de la synchronisation et de la sécurité. Mais pour les développeurs cherchant à repousser les limites de ce qui est possible sur le web — de la synthèse audio en temps réel au rendu 3D complexe et au calcul scientifique — maîtriser SharedArrayBuffer
n'est plus seulement une option ; c'est une compétence essentielle pour construire la prochaine génération d'applications web.