Lås upp äkta flertrådskörning i JavaScript. Denna omfattande guide täcker SharedArrayBuffer, Atomics, Web Workers och säkerhetskraven för högpresterande webbapplikationer.
JavaScript SharedArrayBuffer: En djupdykning i samtidig programmering på webben
I årtionden har JavaScripts entrådiga natur varit både en källa till dess enkelhet och en betydande prestandaflaskhals. Händelseloopen fungerar utmärkt för de flesta UI-drivna uppgifter, men den kämpar när den ställs inför beräkningsintensiva operationer. Långvariga beräkningar kan frysa webbläsaren, vilket skapar en frustrerande användarupplevelse. Även om Web Workers erbjöd en dellösning genom att tillåta skript att köras i bakgrunden, kom de med sin egen stora begränsning: ineffektiv datakommunikation.
Här kommer SharedArrayBuffer
(SAB), en kraftfull funktion som i grunden förändrar spelplanen genom att introducera äkta, lågnivå-minnesdelning mellan trådar på webben. Tillsammans med Atomics
-objektet låser SAB upp en ny era av högpresterande, samtidiga applikationer direkt i webbläsaren. Men med stor makt kommer stort ansvar – och komplexitet.
Denna guide tar dig med på en djupdykning i världen av samtidig programmering i JavaScript. Vi kommer att utforska varför vi behöver det, hur SharedArrayBuffer
och Atomics
fungerar, de kritiska säkerhetsaspekterna du måste hantera och praktiska exempel för att komma igång.
Den gamla världen: JavaScripts entrådiga modell och dess begränsningar
Innan vi kan uppskatta lösningen måste vi helt förstå problemet. JavaScript-exekvering i en webbläsare sker traditionellt på en enda tråd, ofta kallad "huvudtråden" eller "UI-tråden".
Händelseloopen
Huvudtråden ansvarar för allt: att exekvera din JavaScript-kod, rendera sidan, svara på användarinteraktioner (som klick och scrollningar) och köra CSS-animationer. Den hanterar dessa uppgifter med hjälp av en händelseloop, som kontinuerligt bearbetar en kö av meddelanden (uppgifter). Om en uppgift tar lång tid att slutföra blockerar den hela kön. Inget annat kan hända – användargränssnittet fryser, animationer hackar och sidan blir oresponsiv.
Web Workers: Ett steg i rätt riktning
Web Workers introducerades för att mildra detta problem. En Web Worker är i grunden ett skript som körs på en separat bakgrundstråd. Du kan avlasta tunga beräkningar till en worker, vilket håller huvudtråden fri att hantera användargränssnittet.
Kommunikation mellan huvudtråden och en worker sker via postMessage()
-API:et. När du skickar data hanteras det av den strukturerade kloningsalgoritmen. Detta innebär att datan serialiseras, kopieras och sedan deserialiseras i workerns kontext. Även om det är effektivt har denna process betydande nackdelar för stora datamängder:
- Prestanda-overhead: Att kopiera megabyte eller till och med gigabyte data mellan trådar är långsamt och CPU-intensivt.
- Minnesförbrukning: Det skapar en dubblett av datan i minnet, vilket kan vara ett stort problem för enheter med begränsat minne.
Föreställ dig en videoredigerare i webbläsaren. Att skicka en hel videobildruta (som kan vara flera megabyte) fram och tillbaka till en worker för bearbetning 60 gånger per sekund skulle vara oöverkomligt dyrt. Detta är exakt det problem som SharedArrayBuffer
utformades för att lösa.
Spelförändraren: Introduktion av SharedArrayBuffer
En SharedArrayBuffer
är en rå binär databuffert med fast längd, liknande en ArrayBuffer
. Den kritiska skillnaden är att en SharedArrayBuffer
kan delas mellan flera trådar (t.ex. huvudtråden och en eller flera Web Workers). När du "skickar" en SharedArrayBuffer
med postMessage()
, skickar du inte en kopia; du skickar en referens till samma minnesblock.
Detta innebär att alla ändringar som görs i buffertens data av en tråd är omedelbart synliga för alla andra trådar som har en referens till den. Detta eliminerar det kostsamma kopiera-och-serialisera-steget, vilket möjliggör nästan omedelbar datadelning.
Tänk på det så här:
- Web Workers med
postMessage()
: Det är som två kollegor som arbetar på ett dokument genom att mejla kopior fram och tillbaka. Varje ändring kräver att en helt ny kopia skickas. - Web Workers med
SharedArrayBuffer
: Det är som två kollegor som arbetar på samma dokument i en delad onlineredigerare (som Google Docs). Ändringar är synliga för båda i realtid.
Faran med delat minne: Race Conditions
Omedelbar minnesdelning är kraftfullt, men det introducerar också ett klassiskt problem från världen av samtidig programmering: race conditions.
En race condition uppstår när flera trådar försöker komma åt och ändra samma delade data samtidigt, och det slutliga resultatet beror på den oförutsägbara ordningen i vilken de exekveras. Tänk dig en enkel räknare lagrad i en SharedArrayBuffer
. Både huvudtråden och en worker vill öka den.
- Tråd A läser det aktuella värdet, som är 5.
- Innan Tråd A kan skriva det nya värdet, pausar operativsystemet den och byter till Tråd B.
- Tråd B läser det aktuella värdet, som fortfarande är 5.
- Tråd B beräknar det nya värdet (6) och skriver tillbaka det till minnet.
- Systemet byter tillbaka till Tråd A. Den vet inte att Tråd B har gjort något. Den fortsätter där den slutade, beräknar sitt nya värde (5 + 1 = 6) och skriver 6 tillbaka till minnet.
Även om räknaren ökades två gånger är slutvärdet 6, inte 7. Operationerna var inte atomära – de var avbrytbara, vilket ledde till förlorad data. Detta är exakt anledningen till att du inte kan använda en SharedArrayBuffer
utan dess avgörande partner: Atomics
-objektet.
Väktaren av delat minne: Atomics
-objektet
Atomics
-objektet tillhandahåller en uppsättning statiska metoder för att utföra atomära operationer på SharedArrayBuffer
-objekt. En atomär operation garanteras att utföras i sin helhet utan att avbrytas av någon annan operation. Den sker antingen helt och hållet eller inte alls.
Att använda Atomics
förhindrar race conditions genom att säkerställa att läsa-modifiera-skriva-operationer på delat minne utförs säkert.
Viktiga Atomics
-metoder
Låt oss titta på några av de viktigaste metoderna som Atomics
tillhandahåller.
Atomics.load(typedArray, index)
: Läser atomärt värdet vid ett givet index och returnerar det. Detta säkerställer att du läser ett komplett, icke-korrupt värde.Atomics.store(typedArray, index, value)
: Lagrar atomärt ett värde vid ett givet index och returnerar det värdet. Detta säkerställer att skrivoperationen inte avbryts.Atomics.add(typedArray, index, value)
: Adderar atomärt ett värde till värdet vid det givna indexet. Det returnerar det ursprungliga värdet på den positionen. Detta är den atomära motsvarigheten tillx += value
.Atomics.sub(typedArray, index, value)
: Subtraherar atomärt ett värde från värdet vid det givna indexet.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Detta är en kraftfull villkorlig skrivning. Den kontrollerar om värdet vidindex
är lika medexpectedValue
. Om det är det, ersätter den det medreplacementValue
och returnerar det ursprungligaexpectedValue
. Om inte, gör den ingenting och returnerar det nuvarande värdet. Detta är en fundamental byggsten för att implementera mer komplexa synkroniseringsprimitiver som lås.
Synkronisering: Utöver enkla operationer
Ibland behöver du mer än bara säker läsning och skrivning. Du behöver att trådar koordinerar och väntar på varandra. Ett vanligt anti-mönster är "busy-waiting", där en tråd sitter i en tät loop och ständigt kontrollerar en minnesplats för en förändring. Detta slösar CPU-cykler och tömmer batteriet.
Atomics
erbjuder en mycket effektivare lösning med wait()
och notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Detta säger åt en tråd att gå i viloläge. Den kontrollerar om värdet vidindex
fortfarande ärvalue
. Om så är fallet, sover tråden tills den väcks avAtomics.notify()
eller tills den valfriatimeout
(i millisekunder) uppnås. Om värdet vidindex
redan har ändrats, returnerar den omedelbart. Detta är otroligt effektivt eftersom en sovande tråd förbrukar nästan inga CPU-resurser.Atomics.notify(typedArray, index, count)
: Detta används för att väcka trådar som sover på en specifik minnesplats viaAtomics.wait()
. Det kommer att väcka högstcount
väntande trådar (eller alla omcount
inte anges eller ärInfinity
).
Att sätta ihop allt: En praktisk guide
Nu när vi förstår teorin, låt oss gå igenom stegen för att implementera en lösning med SharedArrayBuffer
.
Steg 1: Säkerhetskravet - Cross-Origin Isolation
Detta är den vanligaste stötestenen för utvecklare. Av säkerhetsskäl är SharedArrayBuffer
endast tillgänglig på sidor som är i ett cross-origin-isolerat tillstånd. Detta är en säkerhetsåtgärd för att mildra sårbarheter relaterade till spekulativ exekvering som Spectre, som potentiellt skulle kunna använda högupplösta timers (möjliggjorda av delat minne) för att läcka data över origins.
För att aktivera cross-origin-isolering måste du konfigurera din webbserver att skicka två specifika HTTP-headers för ditt huvuddokument:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Isolerar ditt dokuments webbläsarkontext från andra dokument, vilket hindrar dem från att direkt interagera med ditt window-objekt.Cross-Origin-Embedder-Policy: require-corp
(COEP): Kräver att alla underresurser (som bilder, skript och iframes) som laddas av din sida antingen måste komma från samma origin eller vara explicit markerade som laddningsbara cross-origin medCross-Origin-Resource-Policy
-headern eller CORS.
Detta kan vara utmanande att sätta upp, särskilt om du förlitar dig på tredjepartsskript eller resurser som inte tillhandahåller de nödvändiga headers. Efter att ha konfigurerat din server kan du verifiera om din sida är isolerad genom att kontrollera egenskapen self.crossOriginIsolated
i webbläsarens konsol. Den måste vara true
.
Steg 2: Skapa och dela bufferten
I ditt huvudskript skapar du SharedArrayBuffer
och en "vy" över den med hjälp av en TypedArray
som Int32Array
.
main.js:
// Kontrollera för cross-origin-isolering först!
if (!self.crossOriginIsolated) {
console.error("Sidan är inte cross-origin-isolerad. SharedArrayBuffer kommer inte att vara tillgänglig.");
} else {
// Skapa en delad buffert för ett 32-bitars heltal.
const buffer = new SharedArrayBuffer(4);
// Skapa en vy över bufferten. Alla atomära operationer sker på vyn.
const int32Array = new Int32Array(buffer);
// Initiera värdet vid index 0.
int32Array[0] = 0;
// Skapa en ny worker.
const worker = new Worker('worker.js');
// Skicka den DELADE bufferten till workern. Detta är en referensöverföring, inte en kopia.
worker.postMessage({ buffer });
// Lyssna på meddelanden från workern.
worker.onmessage = (event) => {
console.log(`Workern rapporterade slutförande. Slutligt värde: ${Atomics.load(int32Array, 0)}`);
};
}
Steg 3: Utföra atomära operationer i workern
Workern tar emot bufferten och kan nu utföra atomära operationer på den.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker mottog den delade bufferten.");
// Låt oss utföra några atomära operationer.
for (let i = 0; i < 1000000; i++) {
// Öka det delade värdet på ett säkert sätt.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker har slutfört ökningen.");
// Signalera tillbaka till huvudtråden att vi är klara.
self.postMessage({ done: true });
};
Steg 4: Ett mer avancerat exempel - Parallell summering med synkronisering
Låt oss ta itu med ett mer realistiskt problem: att summera en mycket stor array av tal med hjälp av flera workers. Vi kommer att använda Atomics.wait()
och Atomics.notify()
för effektiv synkronisering.
Vår delade buffert kommer att ha tre delar:
- Index 0: En statusflagga (0 = bearbetar, 1 = klar).
- Index 1: En räknare för hur många workers som har slutförts.
- Index 2: Den slutliga summan.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// Vi använder två 32-bitars heltal för resultatet för att undvika overflow för stora summor.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 heltal
const sharedArray = new Int32Array(sharedBuffer);
// Generera lite slumpmässig data att bearbeta
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);
// Skapa en icke-delad vy för workerns datablock
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Detta kopieras
});
}
console.log('Huvudtråden väntar nu på att workers ska bli klara...');
// Vänta på att statusflaggan vid index 0 ska bli 1
// Detta är mycket bättre än en while-loop!
Atomics.wait(sharedArray, 0, 0); // Vänta om sharedArray[0] är 0
console.log('Huvudtråden har väckts!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Den slutliga parallella summan är: ${finalSum}`);
} else {
console.error('Sidan är inte cross-origin-isolerad.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Beräkna summan för denna workers datablock
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Addera atomärt den lokala summan till den delade totalsumman
Atomics.add(sharedArray, 2, localSum);
// Öka atomärt räknaren för 'workers klara'
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Om detta är den sista workern som blir klar...
const NUM_WORKERS = 4; // Bör skickas in i en verklig app
if (finishedCount === NUM_WORKERS) {
console.log('Sista workern är klar. Meddelar huvudtråden.');
// 1. Sätt statusflaggan till 1 (klar)
Atomics.store(sharedArray, 0, 1);
// 2. Meddela huvudtråden, som väntar på index 0
Atomics.notify(sharedArray, 0, 1);
}
};
Verkliga användningsfall och tillämpningar
Var gör denna kraftfulla men komplexa teknologi egentligen skillnad? Den utmärker sig i applikationer som kräver tunga, parallelliserbara beräkningar på stora datamängder.
- WebAssembly (Wasm): Detta är det främsta användningsfallet. Språk som C++, Rust och Go har moget stöd för flertrådskörning. Wasm tillåter utvecklare att kompilera dessa befintliga högpresterande, flertrådiga applikationer (som spelmotorer, CAD-programvara och vetenskapliga modeller) för att köras i webbläsaren, med
SharedArrayBuffer
som den underliggande mekanismen för trådkommunikation. - Databehandling i webbläsaren: Storskalig datavisualisering, maskininlärningsinferens på klientsidan och vetenskapliga simuleringar som bearbetar massiva mängder data kan accelereras avsevärt.
- Medieredigering: Att applicera filter på högupplösta bilder eller utföra ljudbehandling på en ljudfil kan delas upp i bitar och bearbetas parallellt av flera workers, vilket ger realtidsfeedback till användaren.
- Högpresterande spel: Moderna spelmotorer förlitar sig starkt på flertrådskörning för fysik, AI och laddning av tillgångar.
SharedArrayBuffer
gör det möjligt att bygga spel av konsolkvalitet som körs helt i webbläsaren.
Utmaningar och avslutande överväganden
Även om SharedArrayBuffer
är omvälvande, är det ingen universallösning. Det är ett lågnivåverktyg som kräver noggrann hantering.
- Komplexitet: Samtidig programmering är notoriskt svårt. Att felsöka race conditions och deadlocks kan vara otroligt utmanande. Du måste tänka annorlunda på hur din applikations tillstånd hanteras.
- Deadlocks: En deadlock uppstår när två eller flera trådar blockeras för evigt, där var och en väntar på att den andra ska frigöra en resurs. Detta kan hända om du implementerar komplexa låsmekanismer felaktigt.
- Säkerhets-overhead: Kravet på cross-origin-isolering är ett betydande hinder. Det kan bryta integrationer med tredjepartstjänster, annonser och betalningsgateways om de inte stöder de nödvändiga CORS/CORP-headers.
- Inte för alla problem: För enkla bakgrundsuppgifter eller I/O-operationer är den traditionella Web Worker-modellen med
postMessage()
ofta enklare och tillräcklig. Använd endastSharedArrayBuffer
när du har en tydlig, CPU-bunden flaskhals som involverar stora mängder data.
Slutsats
SharedArrayBuffer
, i kombination med Atomics
och Web Workers, representerar ett paradigmskifte för webbutveckling. Det krossar gränserna för den entrådiga modellen och bjuder in en ny klass av kraftfulla, prestandastarka och komplexa applikationer till webbläsaren. Det placerar webbplattformen på en mer jämlik nivå med native applikationsutveckling för beräkningsintensiva uppgifter.
Resan in i samtidig JavaScript är utmanande och kräver ett rigoröst tillvägagångssätt för tillståndshantering, synkronisering och säkerhet. Men för utvecklare som vill tänja på gränserna för vad som är möjligt på webben – från ljudsyntes i realtid till komplex 3D-rendering och vetenskaplig databehandling – är att bemästra SharedArrayBuffer
inte längre bara ett alternativ; det är en nödvändig färdighet för att bygga nästa generations webbapplikationer.