Lås op for ægte multithreading i JavaScript. Denne omfattende guide dækker SharedArrayBuffer, Atomics, Web Workers og sikkerhedskravene for højtydende webapplikationer.
JavaScript SharedArrayBuffer: En Dybdegående Gennemgang af Samtidig Programmering på Weben
I årtier har JavaScripts enkelttrådede natur både været kilden til dens enkelhed og en betydelig ydelsesmæssig flaskehals. Event loop-modellen fungerer smukt for de fleste UI-drevne opgaver, men den kæmper, når den står over for beregningstunge operationer. Langvarige beregninger kan fryse browseren, hvilket skaber en frustrerende brugeroplevelse. Mens Web Workers tilbød en delvis løsning ved at lade scripts køre i baggrunden, kom de med deres egen store begrænsning: ineffektiv datakommunikation.
Her kommer SharedArrayBuffer
(SAB) ind i billedet, en kraftfuld funktion, der fundamentalt ændrer spillet ved at introducere ægte, lav-niveau hukommelsesdeling mellem tråde på weben. Sammen med Atomics
-objektet åbner SAB op for en ny æra af højtydende, samtidige applikationer direkte i browseren. Men med stor magt følger stort ansvar – og kompleksitet.
Denne guide vil tage dig med på en dybdegående rejse ind i verdenen af samtidig programmering i JavaScript. Vi vil udforske, hvorfor vi har brug for det, hvordan SharedArrayBuffer
og Atomics
virker, de kritiske sikkerhedsovervejelser, du skal håndtere, og praktiske eksempler for at komme i gang.
Den Gamle Verden: JavaScripts Enkelttrådede Model og dens Begrænsninger
Før vi kan værdsætte løsningen, må vi fuldt ud forstå problemet. JavaScript-eksekvering i en browser sker traditionelt på en enkelt tråd, ofte kaldet "hovedtråden" eller "UI-tråden".
Event Loop
Hovedtråden er ansvarlig for alt: at eksekvere din JavaScript-kode, gengive siden, reagere på brugerinteraktioner (som klik og scrolls) og køre CSS-animationer. Den håndterer disse opgaver ved hjælp af en event loop, som kontinuerligt behandler en kø af meddelelser (opgaver). Hvis en opgave tager lang tid at fuldføre, blokerer den hele køen. Intet andet kan ske – UI'et fryser, animationer hakker, og siden bliver ikke-reagerende.
Web Workers: Et Skridt i den Rigtige Retning
Web Workers blev introduceret for at afhjælpe dette problem. En Web Worker er i bund og grund et script, der kører på en separat baggrundstråd. Du kan overføre tunge beregninger til en worker, hvilket holder hovedtråden fri til at håndtere brugergrænsefladen.
Kommunikation mellem hovedtråden og en worker sker via postMessage()
API'et. Når du sender data, håndteres det af structured clone-algoritmen. Dette betyder, at dataene serialiseres, kopieres og derefter deserialiseres i workerens kontekst. Selvom det er effektivt, har denne proces betydelige ulemper for store datasæt:
- Ydelsesmæssig Overhead: At kopiere megabytes eller endda gigabytes af data mellem tråde er langsomt og CPU-intensivt.
- Hukommelsesforbrug: Det skaber en kopi af dataene i hukommelsen, hvilket kan være et stort problem for enheder med begrænset hukommelse.
Forestil dig en videoeditor i browseren. At sende en hel videoramme (som kan være flere megabytes) frem og tilbage til en worker for behandling 60 gange i sekundet ville være uoverkommeligt dyrt. Dette er præcis det problem, SharedArrayBuffer
blev designet til at løse.
Game-Changeren: Introduktion af SharedArrayBuffer
En SharedArrayBuffer
er en rå binær databuffer med fast længde, ligesom en ArrayBuffer
. Den kritiske forskel er, at en SharedArrayBuffer
kan deles på tværs af flere tråde (f.eks. hovedtråden og en eller flere Web Workers). Når du "sender" en SharedArrayBuffer
ved hjælp af postMessage()
, sender du ikke en kopi; du sender en reference til den samme hukommelsesblok.
Dette betyder, at alle ændringer, der foretages i bufferens data af én tråd, er øjeblikkeligt synlige for alle andre tråde, der har en reference til den. Dette eliminerer det dyre kopier-og-serialiser-trin, hvilket muliggør næsten øjeblikkelig datadeling.
Tænk på det på denne måde:
- Web Workers med
postMessage()
: Dette er som to kolleger, der arbejder på et dokument ved at sende kopier frem og tilbage via e-mail. Hver ændring kræver, at en helt ny kopi sendes. - Web Workers med
SharedArrayBuffer
: Dette er som to kolleger, der arbejder på det samme dokument i en delt online-editor (som Google Docs). Ændringer er synlige for begge i realtid.
Faren ved Delt Hukommelse: Race Conditions
Øjeblikkelig hukommelsesdeling er kraftfuld, men den introducerer også et klassisk problem fra verdenen af samtidig programmering: race conditions (kapløbstilstande).
En race condition opstår, når flere tråde forsøger at tilgå og ændre de samme delte data samtidigt, og det endelige resultat afhænger af den uforudsigelige rækkefølge, hvori de eksekverer. Overvej en simpel tæller gemt i en SharedArrayBuffer
. Både hovedtråden og en worker ønsker at forøge den.
- Tråd A læser den nuværende værdi, som er 5.
- Før Tråd A kan skrive den nye værdi, pauser operativsystemet den og skifter til Tråd B.
- Tråd B læser den nuværende værdi, som stadig er 5.
- Tråd B beregner den nye værdi (6) og skriver den tilbage til hukommelsen.
- Systemet skifter tilbage til Tråd A. Den ved ikke, at Tråd B har gjort noget. Den fortsætter, hvor den slap, beregner sin nye værdi (5 + 1 = 6) og skriver 6 tilbage til hukommelsen.
Selvom tælleren blev forøget to gange, er den endelige værdi 6, ikke 7. Operationerne var ikke atomare – de kunne afbrydes, hvilket førte til tabte data. Dette er præcis grunden til, at du ikke kan bruge en SharedArrayBuffer
uden dens afgørende partner: Atomics
-objektet.
Vogteren af Delt Hukommelse: Atomics
-objektet
Atomics
-objektet leverer et sæt statiske metoder til at udføre atomare operationer på SharedArrayBuffer
-objekter. En atomar operation er garanteret at blive udført i sin helhed uden at blive afbrudt af nogen anden operation. Den sker enten fuldstændigt eller slet ikke.
Brug af Atomics
forhindrer race conditions ved at sikre, at læse-modificere-skrive-operationer på delt hukommelse udføres sikkert.
Vigtige Atomics
-metoder
Lad os se på nogle af de vigtigste metoder, som Atomics
stiller til rådighed.
Atomics.load(typedArray, index)
: Læser atomart værdien på et givent indeks og returnerer den. Dette sikrer, at du læser en komplet, ikke-korrupt værdi.Atomics.store(typedArray, index, value)
: Gemmer atomart en værdi på et givent indeks og returnerer den værdi. Dette sikrer, at skriveoperationen ikke bliver afbrudt.Atomics.add(typedArray, index, value)
: Tilføjer atomart en værdi til værdien på det givne indeks. Den returnerer den oprindelige værdi på den position. Dette er den atomare ækvivalent tilx += value
.Atomics.sub(typedArray, index, value)
: Fratrækker atomart en værdi fra værdien på det givne indeks.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Dette er en kraftfuld betinget skrivning. Den tjekker, om værdien påindex
er lig medexpectedValue
. Hvis den er det, erstatter den den medreplacementValue
og returnerer den oprindeligeexpectedValue
. Hvis ikke, gør den ingenting og returnerer den nuværende værdi. Dette er en fundamental byggeklods for at implementere mere komplekse synkroniseringsprimitiver som låse.
Synkronisering: Ud over Simple Operationer
Nogle gange har du brug for mere end bare sikker læsning og skrivning. Du har brug for, at tråde koordinerer og venter på hinanden. Et almindeligt anti-mønster er "busy-waiting", hvor en tråd sidder i en tæt løkke og konstant tjekker en hukommelsesplacering for en ændring. Dette spilder CPU-cyklusser og dræner batterilevetiden.
Atomics
giver en meget mere effektiv løsning med wait()
og notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Dette beder en tråd om at gå i dvale. Den tjekker, om værdien påindex
stadig ervalue
. Hvis ja, sover tråden, indtil den bliver vækket afAtomics.notify()
, eller indtil den valgfrietimeout
(i millisekunder) er nået. Hvis værdien påindex
allerede er ændret, returnerer den med det samme. Dette er utroligt effektivt, da en sovende tråd næsten ikke bruger nogen CPU-ressourcer.Atomics.notify(typedArray, index, count)
: Dette bruges til at vække tråde, der sover på en specifik hukommelsesplacering viaAtomics.wait()
. Det vil vække højstcount
ventende tråde (eller alle, hviscount
ikke er angivet eller erInfinity
).
Samling af Trådene: En Praktisk Guide
Nu hvor vi forstår teorien, lad os gennemgå trinene for at implementere en løsning ved hjælp af SharedArrayBuffer
.
Trin 1: Sikkerhedsforudsætningen - Cross-Origin Isolation
Dette er den mest almindelige anstødssten for udviklere. Af sikkerhedsmæssige årsager er SharedArrayBuffer
kun tilgængelig på sider, der er i en cross-origin isolated tilstand. Dette er en sikkerhedsforanstaltning for at afbøde spekulative eksekveringssårbarheder som Spectre, som potentielt kunne bruge højopløsningstimere (muliggjort af delt hukommelse) til at lække data på tværs af oprindelser.
For at aktivere cross-origin isolation skal du konfigurere din webserver til at sende to specifikke HTTP-headere for dit hoveddokument:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Isolerer dit dokuments browsing-kontekst fra andre dokumenter, hvilket forhindrer dem i at interagere direkte med dit window-objekt.Cross-Origin-Embedder-Policy: require-corp
(COEP): Kræver, at alle underressourcer (som billeder, scripts og iframes), der indlæses af din side, enten skal være fra samme oprindelse eller eksplicit markeret som indlæselige på tværs af oprindelser medCross-Origin-Resource-Policy
-headeren eller CORS.
Dette kan være udfordrende at sætte op, især hvis du er afhængig af tredjeparts-scripts eller ressourcer, der ikke leverer de nødvendige headere. Efter at have konfigureret din server kan du verificere, om din side er isoleret, ved at tjekke self.crossOriginIsolated
-egenskaben i browserens konsol. Den skal være true
.
Trin 2: Oprettelse og Deling af Bufferen
I dit hovedscript opretter du SharedArrayBuffer
og et "view" på den ved hjælp af en TypedArray
som Int32Array
.
main.js:
// Tjek for cross-origin isolation først!
if (!self.crossOriginIsolated) {
console.error("Denne side er ikke cross-origin isolated. SharedArrayBuffer vil ikke være tilgængelig.");
} else {
// Opret en delt buffer til ét 32-bit heltal.
const buffer = new SharedArrayBuffer(4);
// Opret et view på bufferen. Alle atomare operationer sker på view'et.
const int32Array = new Int32Array(buffer);
// Initialiser værdien ved indeks 0.
int32Array[0] = 0;
// Opret en ny worker.
const worker = new Worker('worker.js');
// Send den DELTE buffer til workeren. Dette er en referenceoverførsel, ikke en kopi.
worker.postMessage({ buffer });
// Lyt efter beskeder fra workeren.
worker.onmessage = (event) => {
console.log(`Worker rapporterede fuldførelse. Endelig værdi: ${Atomics.load(int32Array, 0)}`);
};
}
Trin 3: Udførelse af Atomare Operationer i Workeren
Workeren modtager bufferen og kan nu udføre atomare operationer på den.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker modtog den delte buffer.");
// Lad os udføre nogle atomare operationer.
for (let i = 0; i < 1000000; i++) {
// Forøg sikkert den delte værdi.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker er færdig med at forøge.");
// Signalér tilbage til hovedtråden, at vi er færdige.
self.postMessage({ done: true });
};
Trin 4: Et Mere Avanceret Eksempel - Parallel Summering med Synkronisering
Lad os tackle et mere realistisk problem: at summere et meget stort array af tal ved hjælp af flere workers. Vi vil bruge Atomics.wait()
og Atomics.notify()
til effektiv synkronisering.
Vores delte buffer vil have tre dele:
- Indeks 0: Et statusflag (0 = behandler, 1 = fuldført).
- Indeks 1: En tæller for, hvor mange workers der er færdige.
- Indeks 2: Den endelige sum.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// Vi bruger to 32-bit heltal til resultatet for at undgå overflow ved store summer.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 heltal
const sharedArray = new Int32Array(sharedBuffer);
// Generer nogle tilfældige data, der skal behandles
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);
// Opret et ikke-delt view for workerens del af dataene
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Dette kopieres
});
}
console.log('Hovedtråden venter nu på, at workers bliver færdige...');
// Vent på, at statusflaget ved indeks 0 bliver 1
// Dette er meget bedre end en while-løkke!
Atomics.wait(sharedArray, 0, 0); // Vent hvis sharedArray[0] er 0
console.log('Hovedtråden er blevet vækket!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Den endelige parallelle sum er: ${finalSum}`);
} else {
console.error('Siden er ikke cross-origin isolated.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Beregn summen for denne workers del
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Tilføj atomart den lokale sum til den delte total
Atomics.add(sharedArray, 2, localSum);
// Forøg atomart 'workers færdige'-tælleren
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Hvis dette er den sidste worker, der bliver færdig...
const NUM_WORKERS = 4; // Bør overføres i en rigtig app
if (finishedCount === NUM_WORKERS) {
console.log('Sidste worker er færdig. Giver hovedtråden besked.');
// 1. Sæt statusflaget til 1 (fuldført)
Atomics.store(sharedArray, 0, 1);
// 2. Giv hovedtråden besked, som venter på indeks 0
Atomics.notify(sharedArray, 0, 1);
}
};
Anvendelsestilfælde og Applikationer i den Virkelige Verden
Hvor gør denne kraftfulde, men komplekse teknologi egentlig en forskel? Den excellerer i applikationer, der kræver tunge, paralleliserbare beregninger på store datasæt.
- WebAssembly (Wasm): Dette er det helt store anvendelsestilfælde. Sprog som C++, Rust og Go har moden understøttelse for multithreading. Wasm giver udviklere mulighed for at kompilere disse eksisterende højtydende, flertrådede applikationer (som spilmotorer, CAD-software og videnskabelige modeller) til at køre i browseren, ved hjælp af
SharedArrayBuffer
som den underliggende mekanisme for trådkommunikation. - Databehandling i Browseren: Storskala datavisualisering, klient-side machine learning model-inferens og videnskabelige simuleringer, der behandler massive mængder data, kan accelereres betydeligt.
- Medieredigering: Anvendelse af filtre på højopløselige billeder eller udførelse af lydbehandling på en lydfil kan opdeles i bidder og behandles parallelt af flere workers, hvilket giver brugeren realtidsfeedback.
- Højtydende Gaming: Moderne spilmotorer er stærkt afhængige af multithreading for fysik, AI og indlæsning af aktiver.
SharedArrayBuffer
gør det muligt at bygge spil i konsolkvalitet, der kører udelukkende i browseren.
Udfordringer og Afsluttende Overvejelser
Selvom SharedArrayBuffer
er transformerende, er det ikke en mirakelkur. Det er et lav-niveau værktøj, der kræver omhyggelig håndtering.
- Kompleksitet: Samtidig programmering er notorisk svært. Fejlfinding af race conditions og deadlocks kan være utroligt udfordrende. Du skal tænke anderledes over, hvordan din applikations tilstand håndteres.
- Deadlocks: Et deadlock opstår, når to eller flere tråde er blokeret for evigt, hvor hver venter på, at den anden frigiver en ressource. Dette kan ske, hvis du implementerer komplekse låsemekanismer forkert.
- Sikkerhedsmæssig Overhead: Kravet om cross-origin isolation er en betydelig forhindring. Det kan ødelægge integrationer med tredjeparts-tjenester, annoncer og betalingsgateways, hvis de ikke understøtter de nødvendige CORS/CORP-headere.
- Ikke til Ethvert Problem: For simple baggrundsopgaver eller I/O-operationer er den traditionelle Web Worker-model med
postMessage()
ofte enklere og tilstrækkelig. Grib kun tilSharedArrayBuffer
, når du har en klar, CPU-bundet flaskehals, der involverer store mængder data.
Konklusion
SharedArrayBuffer
, i kombination med Atomics
og Web Workers, repræsenterer et paradigmeskift for webudvikling. Det sprænger grænserne for den enkelttrådede model og inviterer en ny klasse af kraftfulde, højtydende og komplekse applikationer ind i browseren. Det placerer webplatformen på mere lige fod med udvikling af native applikationer for beregningstunge opgaver.
Rejsen ind i samtidig JavaScript er udfordrende og kræver en stringent tilgang til tilstandsstyring, synkronisering og sikkerhed. Men for udviklere, der ønsker at skubbe grænserne for, hvad der er muligt på weben – fra realtids lydsyntese til kompleks 3D-rendering og videnskabelig databehandling – er beherskelse af SharedArrayBuffer
ikke længere bare en mulighed; det er en essentiel færdighed for at bygge den næste generation af webapplikationer.