Le `SharedArrayBuffer` en TypeScript

Le `SharedArrayBuffer` en TypeScript

Cet article explique le SharedArrayBuffer en TypeScript.

Nous allons expliquer le SharedArrayBuffer en TypeScript avec des exemples pratiques.

YouTube Video

Le SharedArrayBuffer en TypeScript

SharedArrayBuffer est un mécanisme permettant de partager le même espace mémoire entre plusieurs threads, comme les Web Workers. En le combinant avec Atomics, vous pouvez gérer les conditions de concurrence (data races) et effectuer des opérations de mémoire partagée à faible latence.

Pré-requis et remarques

Lors de l’utilisation de SharedArrayBuffer dans le navigateur, les en-têtes COOP et COEP sont requis pour satisfaire aux exigences de sécurité appelées isolation multi-origine (cross-origin isolation). Dans Node.js, la mémoire partagée peut être gérée relativement facilement à l’aide de worker_threads.

Concepts de base

SharedArrayBuffer est un objet représentant une séquence d’octets de longueur fixe, et vous pouvez lire et écrire des nombres via des TypedArray et des vues similaires. Les opérations simples de lecture/écriture ne sont pas synchronisées, c’est pourquoi vous utilisez l’API Atomics pour garantir des opérations atomiques et les mécanismes wait et notify pour la coordination.

Compteur simple (version navigateur)

Dans cet exemple, le thread principal crée un SharedArrayBuffer et le transmet à un Web Worker, et tous deux incrémentent un compteur partagé. Cela montre le schéma minimal : addition atomique avec Atomics.add et lecture avec Atomics.load.

main.ts (côté navigateur)

Cet exemple montre comment le thread principal crée un SharedArrayBuffer et le partage avec un Worker pour un accès multi-thread.

 1// main.ts
 2// Create a 4-byte (Int32) shared buffer for one counter
 3const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 1);
 4const counter = new Int32Array(sab); // view over the buffer
 5
 6// Create worker and transfer the SharedArrayBuffer
 7const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
 8worker.postMessage({ sab });
 9
10// Increase counter in main thread every 200ms
11setInterval(() => {
12  const newVal = Atomics.add(counter, 0, 1) + 1; // Atomically increment
13  console.log('Main incremented ->', newVal);
14}, 200);
15
16// Listen for messages (optional)
17worker.onmessage = (e) => {
18  console.log('From worker:', e.data);
19};
  • Dans ce code, le thread principal utilise Atomics.add pour incrémenter la valeur de manière atomique. Du côté de worker.ts, le même SharedArrayBuffer peut être accédé et manipulé.

worker.ts (Web Worker navigateur)

Voici un exemple où le worker reçoit le même buffer partagé et le décrémente ou le manipule périodiquement.

 1// worker.ts
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const counter = new Int32Array(sab);
 5
 6  // Every 350ms, atomically add 2 (demonstration)
 7  setInterval(() => {
 8    const newVal = Atomics.add(counter, 0, 2) + 2;
 9    // Optionally notify main thread (postMessage)
10    self.postMessage(`Worker added 2 -> ${newVal}`);
11  }, 350);
12};
  • Le Worker manipule également la même mémoire via Int32Array, et les mises à jour sont réalisées sans conditions de concurrence grâce à Atomics.

Synchronisation avec wait/notify

En utilisant Atomics.wait et Atomics.notify, vous pouvez suspendre les threads jusqu’à ce que certaines conditions soient remplies, permettant ainsi une synchronisation pilotée par événements dans les threads Workers. Dans les navigateurs, l’approche la plus sûre consiste à utiliser Atomics.wait à l’intérieur d’un Worker.

producer.ts (Worker producteur navigateur)

Le producteur écrit les données et notifie le consommateur avec notify.

 1// producer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab); // state[0] will be 0=empty,1=filled
 5
 6  // produce every 500ms
 7  setInterval(() => {
 8    // produce: set state to 1 (filled)
 9    Atomics.store(state, 0, 1);
10    Atomics.notify(state, 0, 1); // wake one waiter
11    self.postMessage('produced');
12  }, 500);
13};

consumer.ts (Worker consommateur navigateur)

Le consommateur attend grâce à Atomics.wait et reprend le traitement après notification du producteur.

 1// consumer.ts (worker)
 2self.onmessage = (ev: MessageEvent) => {
 3  const { sab } = ev.data as { sab: SharedArrayBuffer };
 4  const state = new Int32Array(sab);
 5
 6  // consumer loop
 7  (async function consumeLoop() {
 8    while (true) {
 9      // if state is 0 => wait until notified (value changes)
10      while (Atomics.load(state, 0) === 0) {
11        // Blocks this worker until notified or timeout
12        Atomics.wait(state, 0, 0);
13      }
14      // consume (reset to 0)
15      // (processing simulated)
16      // read data from another shared buffer in real scenarios
17      Atomics.store(state, 0, 0);
18      // continue loop
19      self.postMessage('consumed');
20    }
21  })();
22};
  • Dans ce schéma, le producteur notifie le consommateur via Atomics.notify et le consommateur attend efficacement grâce à Atomics.wait. Atomics.wait ne peut pas être appelé sur le thread principal dans les navigateurs. Pour éviter de bloquer l’interface utilisateur, Atomics.wait ne peut être utilisé que dans des Workers.

Exemple pratique avec Node.js (worker_threads)

Dans un environnement Node.js, vous pouvez gérer les fonctionnalités de SharedArrayBuffer grâce à worker_threads. Voici un exemple TypeScript typé pour Node.js.

main-node.ts

Le thread principal crée le buffer et le transmet au Worker.

 1// main-node.ts
 2import { Worker } from 'worker_threads';
 3import path from 'path';
 4
 5// Create SharedArrayBuffer for one 32-bit integer
 6const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
 7const counter = new Int32Array(sab);
 8
 9// Spawn worker and pass sab via workerData
10const worker = new Worker(path.resolve(__dirname, 'worker-node.js'), {
11  workerData: { sab }
12});
13
14// Log messages from worker
15worker.on('message', (msg) => console.log('Worker:', msg));
16
17// Increment in main thread periodically
18setInterval(() => {
19  const val = Atomics.add(counter, 0, 1) + 1;
20  console.log('Main increment ->', val);
21}, 300);

worker-node.ts

Cet exemple utilise parentPort et workerData issus de worker_threads côté Worker.

 1// worker-node.ts
 2import { parentPort, workerData } from 'worker_threads';
 3
 4const sab = workerData.sab as SharedArrayBuffer;
 5const counter = new Int32Array(sab);
 6
 7// Periodically add 5
 8setInterval(() => {
 9  const val = Atomics.add(counter, 0, 5) + 5;
10  parentPort?.postMessage(`Added 5 -> ${val}`);
11}, 700);
  • Dans Node.js, il n’y a pas de restrictions COOP ou COEP comme dans les navigateurs, ainsi la mémoire partagée peut être simplement gérée via worker_threads. Avec TypeScript, faites attention à savoir si vous compilez avec une configuration CommonJS ou ESM.

Points de typage TypeScript

SharedArrayBuffer et Atomics sont inclus dans les définitions de type standard du DOM et de la bibliothèque, vous pouvez donc les utiliser directement en TypeScript. Lors de l’échange de messages avec les Workers, il est plus sûr de définir des interfaces et de spécifier clairement les types.

1// Example: typed message
2type WorkerMessage = { type: 'init'; sab: SharedArrayBuffer } | { type: 'ping' };
  • La définition explicite des types rend le traitement de postMessage et onmessage plus sûr et permet la vérification de type.

Cas d’utilisation pratiques

SharedArrayBuffer n'est pas toujours nécessaire, mais il est très efficace dans les situations où les avantages de vitesse de la mémoire partagée sont requis. Comprendre les situations dans lesquelles il est efficace permet de faire des choix technologiques appropriés.

  • C’est adapté au traitement à faible latence nécessitant des buffers partagés performants et peut être utilisé pour le traitement audio/vidéo temps réel ou la physique de jeu.
  • Pour des échanges de données simples ou le transfert de gros volumes de données, Transferable ArrayBuffer ou postMessage peuvent être plus simples à utiliser que SharedArrayBuffer.

Contraintes et sécurité

Pour utiliser SharedArrayBuffer dans le navigateur, l’isolation multi-origine est requise : définissez COOP sur same-origin-allow-popups et COEP sur require-corp. SharedArrayBuffer sera désactivé si ces exigences ne sont pas respectées.

Conseils de performance et de débogage

Les opérations atomiques (Atomics) sont rapides, mais attendre fréquemment et sur-synchroniser peuvent augmenter la latence.

Les points suivants doivent être vérifiés pour gérer la mémoire partagée de manière sûre et efficace.

  • Les vues telles que Int32Array doivent être manipulées avec un alignement d’octets approprié.
  • Soyez clair sur quels indices du même buffer partagé sont utilisés par quels processus, et maintenez des conventions cohérentes dans votre code.
  • Si vous traitez un SharedArrayBuffer comme un ArrayBuffer normal, des conditions de concurrence apparaîtront. Écrire sur le même indice à partir de plusieurs threads sans utiliser Atomics est dangereux.

Résumé

En utilisant correctement SharedArrayBuffer et Atomics, vous pouvez réaliser des opérations de mémoire partagée sûres et rapides, même en TypeScript. Commencer par des schémas de synchronisation simples comme des compteurs ou signaux est plus facile à comprendre, et en gérant rigoureusement la synchronisation et les indices, vous pouvez être efficace même dans des scénarios à faible latence.

Vous pouvez suivre l'article ci-dessus avec Visual Studio Code sur notre chaîne YouTube. Veuillez également consulter la chaîne YouTube.

YouTube Video