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:
- Prestatie-overhead: Het kopiëren van megabytes of zelfs gigabytes aan gegevens tussen threads is traag en CPU-intensief.
- Geheugenverbruik: Het creëert een duplicaat van de gegevens in het geheugen, wat een groot probleem kan zijn voor apparaten met beperkt geheugen.
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:
- Web Workers met
postMessage()
: Dit is alsof twee collega's aan een document werken door kopieën heen en weer te e-mailen. Elke wijziging vereist het verzenden van een volledig nieuwe kopie. - Web Workers met
SharedArrayBuffer
: Dit is alsof twee collega's aan hetzelfde document werken in een gedeelde online editor (zoals Google Docs). Wijzigingen zijn voor beiden in realtime zichtbaar.
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.
- Thread A leest de huidige waarde, die 5 is.
- Voordat Thread A de nieuwe waarde kan schrijven, pauzeert het besturingssysteem deze en schakelt over naar Thread B.
- Thread B leest de huidige waarde, die nog steeds 5 is.
- Thread B berekent de nieuwe waarde (6) en schrijft deze terug naar het geheugen.
- 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.
Atomics.load(typedArray, index)
: Leest atomair de waarde op een bepaalde index en retourneert deze. Dit zorgt ervoor dat u een volledige, niet-corrupte waarde leest.Atomics.store(typedArray, index, value)
: Slaat atomair een waarde op een bepaalde index op en retourneert die waarde. Dit zorgt ervoor dat de schrijfbewerking niet wordt onderbroken.Atomics.add(typedArray, index, value)
: Telt atomair een waarde op bij de waarde op de opgegeven index. Het retourneert de oorspronkelijke waarde op die positie. Dit is het atomaire equivalent vanx += value
.Atomics.sub(typedArray, index, value)
: Trekt atomair een waarde af van de waarde op de opgegeven index.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Dit is een krachtige voorwaardelijke schrijfbewerking. Het controleert of de waarde opindex
gelijk is aanexpectedValue
. Als dat zo is, wordt deze vervangen doorreplacementValue
en wordt de oorspronkelijkeexpectedValue
geretourneerd. Zo niet, dan doet het niets en retourneert het de huidige waarde. Dit is een fundamentele bouwsteen voor het implementeren van complexere synchronisatieprimitieven zoals locks.
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()
.
Atomics.wait(typedArray, index, value, timeout)
: Dit vertelt een thread om te gaan slapen. Het controleert of de waarde opindex
nog steedsvalue
is. Als dat zo is, slaapt de thread totdat deze wordt gewekt doorAtomics.notify()
of totdat de optioneletimeout
(in milliseconden) is bereikt. Als de waarde opindex
al is gewijzigd, keert het onmiddellijk terug. Dit is ongelooflijk efficiënt, omdat een slapende thread bijna geen CPU-bronnen verbruikt.Atomics.notify(typedArray, index, count)
: Dit wordt gebruikt om threads te wekken die slapen op een specifieke geheugenlocatie viaAtomics.wait()
. Het zal maximaalcount
wachtende threads wekken (of allemaal alscount
niet is opgegeven ofInfinity
is).
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
Cross-Origin-Opener-Policy: same-origin
(COOP): Isoleert de browsing context van uw document van andere documenten, waardoor wordt voorkomen dat ze rechtstreeks interactie hebben met uw window-object.Cross-Origin-Embedder-Policy: require-corp
(COEP): Vereist dat alle subbronnen (zoals afbeeldingen, scripts en iframes) die door uw pagina worden geladen, ofwel van dezelfde origin zijn of expliciet zijn gemarkeerd als cross-origin laadbaar met deCross-Origin-Resource-Policy
-header of CORS.
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:
- Index 0: Een statusvlag (0 = bezig met verwerken, 1 = voltooid).
- Index 1: Een teller voor hoeveel workers klaar zijn.
- Index 2: De uiteindelijke som.
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.
- WebAssembly (Wasm): Dit is de 'killer use case'. Talen als C++, Rust en Go hebben volwassen ondersteuning voor multithreading. Wasm stelt ontwikkelaars in staat om deze bestaande high-performance, multi-threaded applicaties (zoals game-engines, CAD-software en wetenschappelijke modellen) te compileren om in de browser te draaien, waarbij
SharedArrayBuffer
wordt gebruikt als het onderliggende mechanisme voor thread-communicatie. - In-Browser Dataverwerking: Grootschalige datavisualisatie, client-side machine learning model-inferentie en wetenschappelijke simulaties die enorme hoeveelheden data verwerken, kunnen aanzienlijk worden versneld.
- Mediabewerking: Het toepassen van filters op afbeeldingen met hoge resolutie of het uitvoeren van audiobewerking op een geluidsbestand kan worden opgedeeld in brokken en parallel worden verwerkt door meerdere workers, wat realtime feedback aan de gebruiker geeft.
- High-Performance Gaming: Moderne game-engines leunen zwaar op multithreading voor physics, AI en het laden van assets.
SharedArrayBuffer
maakt het mogelijk om games van console-kwaliteit te bouwen die volledig in de browser draaien.
Uitdagingen en Laatste Overwegingen
Hoewel SharedArrayBuffer
transformerend is, is het geen wondermiddel. Het is een low-level tool die zorgvuldige behandeling vereist.
- 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.
- 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.
- 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.
- 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 naarSharedArrayBuffer
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.