Nederlands

Ontgrendel echte multithreading in JavaScript. Deze uitgebreide gids behandelt SharedArrayBuffer, Atomics, Web Workers en de beveiligingseisen voor high-performance webapplicaties.

JavaScript SharedArrayBuffer: Een Diepgaande Duik in Concurrente Programmering op het Web

Decennialang was de single-threaded aard van JavaScript zowel een bron van eenvoud als een aanzienlijke prestatieknelpunt. Het event loop-model werkt uitstekend voor de meeste UI-gestuurde taken, maar heeft het moeilijk met rekenintensieve operaties. Langlopende berekeningen kunnen de browser bevriezen, wat leidt tot een frustrerende gebruikerservaring. Hoewel Web Workers een gedeeltelijke oplossing boden door scripts op de achtergrond te laten draaien, hadden ze hun eigen grote beperking: inefficiënte datacommunicatie.

Maak kennis met SharedArrayBuffer (SAB), een krachtige functie die het spel fundamenteel verandert door echt, low-level geheugendelen tussen threads op het web te introduceren. In combinatie met het Atomics-object ontsluit SAB een nieuw tijdperk van high-performance, concurrente applicaties rechtstreeks in de browser. Maar met grote kracht komt grote verantwoordelijkheid—en complexiteit.

Deze gids neemt u mee op een diepgaande duik in de wereld van concurrente programmering in JavaScript. We zullen onderzoeken waarom we het nodig hebben, hoe SharedArrayBuffer en Atomics werken, de kritieke beveiligingsoverwegingen die u moet aanpakken, en praktische voorbeelden om u op weg te helpen.

De Oude Wereld: JavaScript's Single-Threaded Model en de Beperkingen ervan

Voordat we de oplossing kunnen waarderen, moeten we het probleem volledig begrijpen. JavaScript-uitvoering in een browser vindt traditioneel plaats op een enkele thread, vaak de "main thread" of "UI thread" genoemd.

De Event Loop

De main thread is verantwoordelijk voor alles: het uitvoeren van uw JavaScript-code, het renderen van de pagina, het reageren op gebruikersinteracties (zoals klikken en scrollen) en het uitvoeren van CSS-animaties. Het beheert deze taken met behulp van een event loop, die continu een wachtrij van berichten (taken) verwerkt. Als een taak lang duurt, blokkeert deze de hele wachtrij. Er kan niets anders gebeuren—de UI bevriest, animaties haperen en de pagina wordt onresponsief.

Web Workers: Een Stap in de Juiste Richting

Web Workers werden geïntroduceerd om dit probleem te verminderen. Een Web Worker is in wezen een script dat op een aparte achtergrondthread draait. U kunt zware berekeningen naar een worker verplaatsen, waardoor de main thread vrij blijft om de gebruikersinterface af te handelen.

Communicatie tussen de main thread en een worker gebeurt via de postMessage() API. Wanneer u gegevens verzendt, wordt dit afgehandeld door het structured clone algorithm. Dit betekent dat de gegevens worden geserialiseerd, gekopieerd en vervolgens gedeserialiseerd in de context van de worker. Hoewel effectief, heeft dit proces aanzienlijke nadelen voor grote datasets:

Stel je een video-editor in de browser voor. Het heen en weer sturen van een volledig videoframe (dat enkele megabytes groot kan zijn) naar een worker voor verwerking, 60 keer per seconde, zou onbetaalbaar duur zijn. Dit is precies het probleem waarvoor SharedArrayBuffer is ontworpen om op te lossen.

De Game-Changer: Introductie van SharedArrayBuffer

Een SharedArrayBuffer is een onbewerkte binaire databuffer met een vaste lengte, vergelijkbaar met een ArrayBuffer. Het cruciale verschil is dat een SharedArrayBuffer kan worden gedeeld tussen meerdere threads (bijv. de main thread en een of meer Web Workers). Wanneer u een SharedArrayBuffer 'verzendt' met postMessage(), verzendt u geen kopie; u verzendt een verwijzing naar hetzelfde geheugenblok.

Dit betekent dat alle wijzigingen die door de ene thread in de gegevens van de buffer worden aangebracht, onmiddellijk zichtbaar zijn voor alle andere threads die er een verwijzing naar hebben. Dit elimineert de kostbare kopieer-en-serialiseer-stap, waardoor bijna onmiddellijke gegevensuitwisseling mogelijk wordt.

Zie het als volgt:

Het Gevaar van Gedeeld Geheugen: Race Conditions

Onmiddellijke geheugendeling is krachtig, maar introduceert ook een klassiek probleem uit de wereld van concurrente programmering: race conditions.

Een race condition treedt op wanneer meerdere threads tegelijkertijd proberen toegang te krijgen tot dezelfde gedeelde gegevens en deze te wijzigen, en de uiteindelijke uitkomst afhangt van de onvoorspelbare volgorde waarin ze worden uitgevoerd. Denk aan een eenvoudige teller die is opgeslagen in een SharedArrayBuffer. Zowel de main thread als een worker willen deze verhogen.

  1. Thread A leest de huidige waarde, die 5 is.
  2. Voordat Thread A de nieuwe waarde kan schrijven, pauzeert het besturingssysteem deze en schakelt over naar Thread B.
  3. Thread B leest de huidige waarde, die nog steeds 5 is.
  4. Thread B berekent de nieuwe waarde (6) en schrijft deze terug naar het geheugen.
  5. Het systeem schakelt terug naar Thread A. Deze weet niet dat Thread B iets heeft gedaan. Het gaat verder waar het gebleven was, berekent zijn nieuwe waarde (5 + 1 = 6) en schrijft 6 terug naar het geheugen.

Hoewel de teller twee keer is verhoogd, is de uiteindelijke waarde 6, niet 7. De operaties waren niet atomair—ze waren onderbreekbaar, wat leidde tot gegevensverlies. Dit is precies waarom u een SharedArrayBuffer niet kunt gebruiken zonder zijn cruciale partner: het Atomics-object.

De Bewaker van Gedeeld Geheugen: Het Atomics Object

Het Atomics-object biedt een set statische methoden voor het uitvoeren van atomaire operaties op SharedArrayBuffer-objecten. Een atomaire operatie wordt gegarandeerd in zijn geheel uitgevoerd zonder te worden onderbroken door een andere operatie. Het gebeurt volledig of helemaal niet.

Het gebruik van Atomics voorkomt race conditions door ervoor te zorgen dat lees-wijzig-schrijf-operaties op gedeeld geheugen veilig worden uitgevoerd.

Belangrijkste Atomics Methoden

Laten we enkele van de belangrijkste methoden van Atomics bekijken.

Synchronisatie: Meer dan Simpele Operaties

Soms heeft u meer nodig dan alleen veilig lezen en schrijven. U heeft threads nodig die coördineren en op elkaar wachten. Een veelvoorkomend anti-patroon is 'busy-waiting', waarbij een thread in een strakke lus zit en voortdurend een geheugenlocatie controleert op een wijziging. Dit verspilt CPU-cycli en verbruikt de batterij.

Atomics biedt een veel efficiëntere oplossing met wait() en notify().

Alles Samenvoegen: Een Praktische Gids

Nu we de theorie begrijpen, laten we de stappen doorlopen voor het implementeren van een oplossing met SharedArrayBuffer.

Stap 1: De Beveiligingsvereiste - Cross-Origin Isolatie

Dit is het meest voorkomende struikelblok voor ontwikkelaars. Om veiligheidsredenen is SharedArrayBuffer alleen beschikbaar op pagina's die zich in een cross-origin isolated staat bevinden. Dit is een beveiligingsmaatregel om speculatieve uitvoeringskwetsbaarheden zoals Spectre te beperken, die potentieel timers met hoge resolutie (mogelijk gemaakt door gedeeld geheugen) kunnen gebruiken om gegevens over verschillende origins te lekken.

Om cross-origin isolatie in te schakelen, moet u uw webserver configureren om twee specifieke HTTP-headers voor uw hoofddocument te verzenden:


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

Dit kan een uitdaging zijn om op te zetten, vooral als u afhankelijk bent van scripts of bronnen van derden die niet de benodigde headers leveren. Na het configureren van uw server, kunt u verifiëren of uw pagina geïsoleerd is door de eigenschap self.crossOriginIsolated in de console van de browser te controleren. Deze moet true zijn.

Stap 2: De Buffer Creëren en Delen

In uw hoofdscript maakt u de SharedArrayBuffer en een 'view' erop met behulp van een TypedArray zoals Int32Array.

main.js:


// Controleer eerst op cross-origin isolatie!
if (!self.crossOriginIsolated) {
  console.error("Deze pagina is niet cross-origin geïsoleerd. SharedArrayBuffer zal niet beschikbaar zijn.");
} else {
  // Maak een gedeelde buffer voor één 32-bit integer.
  const buffer = new SharedArrayBuffer(4);

  // Maak een view op de buffer. Alle atomaire operaties vinden plaats op de view.
  const int32Array = new Int32Array(buffer);

  // Initialiseer de waarde op index 0.
  int32Array[0] = 0;

  // Maak een nieuwe worker.
  const worker = new Worker('worker.js');

  // Verzend de GEDEELDE buffer naar de worker. Dit is een referentieoverdracht, geen kopie.
  worker.postMessage({ buffer });

  // Luister naar berichten van de worker.
  worker.onmessage = (event) => {
    console.log(`Worker meldt voltooiing. Eindwaarde: ${Atomics.load(int32Array, 0)}`);
  };
}

Stap 3: Atomaire Operaties Uitvoeren in de Worker

De worker ontvangt de buffer en kan er nu atomaire operaties op uitvoeren.

worker.js:


self.onmessage = (event) => {
  const { buffer } = event.data;
  const int32Array = new Int32Array(buffer);

  console.log("Worker heeft de gedeelde buffer ontvangen.");

  // Laten we enkele atomaire operaties uitvoeren.
  for (let i = 0; i < 1000000; i++) {
    // Verhoog de gedeelde waarde veilig.
    Atomics.add(int32Array, 0, 1);
  }

  console.log("Worker is klaar met verhogen.");

  // Geef een signaal terug naar de main thread dat we klaar zijn.
  self.postMessage({ done: true });
};

Stap 4: Een Geavanceerder Voorbeeld - Parallelle Sommatie met Synchronisatie

Laten we een realistischer probleem aanpakken: het sommeren van een zeer grote array van getallen met behulp van meerdere workers. We gebruiken Atomics.wait() en Atomics.notify() voor efficiënte synchronisatie.

Onze gedeelde buffer zal drie delen hebben:

main.js:


if (self.crossOriginIsolated) {
  const NUM_WORKERS = 4;
  const DATA_SIZE = 10_000_000;

  // [status, workers_klaar, resultaat_laag, resultaat_hoog]
  // We gebruiken twee 32-bit integers voor het resultaat om overflow bij grote sommen te voorkomen.
  const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 integers
  const sharedArray = new Int32Array(sharedBuffer);

  // Genereer wat willekeurige data om te verwerken
  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);
    
    // Maak een niet-gedeelde view voor het datablok van de worker
    const dataChunk = data.subarray(start, end);

    worker.postMessage({ 
      sharedBuffer,
      dataChunk // Dit wordt gekopieerd
    });
  }

  console.log('Main thread wacht nu tot de workers klaar zijn...');

  // Wacht tot de statusvlag op index 0 de waarde 1 krijgt
  // Dit is veel beter dan een while-lus!
  Atomics.wait(sharedArray, 0, 0); // Wacht als sharedArray[0] gelijk is aan 0

  console.log('Main thread is gewekt!');
  const finalSum = Atomics.load(sharedArray, 2);
  console.log(`De uiteindelijke parallelle som is: ${finalSum}`);

} else {
  console.error('Pagina is niet cross-origin geïsoleerd.');
}

sum_worker.js:


self.onmessage = ({ data }) => {
  const { sharedBuffer, dataChunk } = data;
  const sharedArray = new Int32Array(sharedBuffer);

  // Bereken de som voor het datablok van deze worker
  let localSum = 0;
  for (let i = 0; i < dataChunk.length; i++) {
    localSum += dataChunk[i];
  }

  // Voeg de lokale som atomair toe aan het gedeelde totaal
  Atomics.add(sharedArray, 2, localSum);

  // Verhoog atomair de 'workers klaar'-teller
  const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;

  // Als dit de laatste worker is die klaar is...
  const NUM_WORKERS = 4; // Zou in een echte app moeten worden doorgegeven
  if (finishedCount === NUM_WORKERS) {
    console.log('Laatste worker is klaar. Main thread wordt op de hoogte gebracht.');

    // 1. Zet de statusvlag op 1 (voltooid)
    Atomics.store(sharedArray, 0, 1);

    // 2. Breng de main thread op de hoogte, die wacht op index 0
    Atomics.notify(sharedArray, 0, 1);
  }
};

Praktijkvoorbeelden en Toepassingen

Waar maakt deze krachtige maar complexe technologie nu echt een verschil? Het blinkt uit in toepassingen die zware, paralleliseerbare berekeningen op grote datasets vereisen.

Uitdagingen en Laatste Overwegingen

Hoewel SharedArrayBuffer transformerend is, is het geen wondermiddel. Het is een low-level tool die zorgvuldige behandeling vereist.

  1. Complexiteit: Concurrente programmering is notoir moeilijk. Het debuggen van race conditions en deadlocks kan ongelooflijk uitdagend zijn. U moet anders nadenken over hoe de status van uw applicatie wordt beheerd.
  2. Deadlocks: Een deadlock treedt op wanneer twee of meer threads voor altijd geblokkeerd zijn, elk wachtend tot de ander een resource vrijgeeft. Dit kan gebeuren als u complexe vergrendelingsmechanismen onjuist implementeert.
  3. Beveiligingsoverhead: De vereiste van cross-origin isolatie is een aanzienlijke hindernis. Het kan integraties met diensten van derden, advertenties en betalingsgateways verbreken als deze niet de benodigde CORS/CORP-headers ondersteunen.
  4. Niet voor Elk Probleem: Voor eenvoudige achtergrondtaken of I/O-operaties is het traditionele Web Worker-model met postMessage() vaak eenvoudiger en voldoende. Grijp alleen naar SharedArrayBuffer als u een duidelijk, CPU-gebonden knelpunt heeft met grote hoeveelheden gegevens.

Conclusie

SharedArrayBuffer, in combinatie met Atomics en Web Workers, vertegenwoordigt een paradigmaverschuiving voor webontwikkeling. Het doorbreekt de grenzen van het single-threaded model en nodigt een nieuwe klasse van krachtige, performante en complexe applicaties uit in de browser. Het plaatst het webplatform op een meer gelijkwaardige basis met native applicatieontwikkeling voor rekenintensieve taken.

De reis naar concurrente JavaScript is uitdagend en vereist een rigoureuze aanpak van statusbeheer, synchronisatie en beveiliging. Maar voor ontwikkelaars die de grenzen willen verleggen van wat mogelijk is op het web—van realtime audiosynthese tot complexe 3D-rendering en wetenschappelijk rekenen—is het beheersen van SharedArrayBuffer niet langer slechts een optie; het is een essentiële vaardigheid voor het bouwen van de volgende generatie webapplicaties.

JavaScript SharedArrayBuffer: Een Diepgaande Duik in Concurrente Programmering op het Web | MLOG