Mahdollista aito monisäikeisyys JavaScriptissä. Tämä kattava opas käsittelee SharedArrayBufferia, Atomicsia, Web Workereita ja korkean suorituskyvyn verkkosovellusten tietoturvavaatimuksia.
JavaScript SharedArrayBuffer: Syväsukellus verkon rinnakkaisohjelmointiin
Vuosikymmenten ajan JavaScriptin yksisäikeinen luonne on ollut sekä sen yksinkertaisuuden lähde että merkittävä suorituskyvyn pullonkaula. Tapahtumasilmukkamalli toimii kauniisti useimmissa käyttöliittymäkeskeisissä tehtävissä, mutta se kohtaa haasteita laskennallisesti raskaiden operaatioiden kanssa. Pitkäkestoiset laskutoimitukset voivat jäädyttää selaimen, mikä luo turhauttavan käyttökokemuksen. Vaikka Web Workerit tarjosivat osittaisen ratkaisun sallimalla skriptien suorittamisen taustalla, niillä oli oma merkittävä rajoituksensa: tehoton datanvälitys.
Tässä astuu kuvaan SharedArrayBuffer
(SAB), tehokas ominaisuus, joka muuttaa pelin perusteellisesti tuomalla aidon, matalan tason muistin jakamisen verkon säikeiden välille. Yhdessä Atomics
-olion kanssa SAB avaa uuden aikakauden korkean suorituskyvyn rinnakkaissovelluksille suoraan selaimessa. Suuren voiman myötä tulee kuitenkin suuri vastuu – ja monimutkaisuus.
Tämä opas vie sinut syväsukellukselle JavaScriptin rinnakkaisohjelmoinnin maailmaan. Tutkimme, miksi tarvitsemme sitä, miten SharedArrayBuffer
ja Atomics
toimivat, kriittiset tietoturvanäkökohdat, jotka sinun on otettava huomioon, sekä käytännön esimerkkejä, joiden avulla pääset alkuun.
Vanha maailma: JavaScriptin yksisäikeinen malli ja sen rajoitukset
Ennen kuin voimme arvostaa ratkaisua, meidän on ymmärrettävä ongelma täysin. JavaScriptin suoritus selaimessa tapahtuu perinteisesti yhdellä säikeellä, jota kutsutaan usein "pääsäikeeksi" tai "käyttöliittymäsäikeeksi".
Tapahtumasilmukka
Pääsäie on vastuussa kaikesta: JavaScript-koodisi suorittamisesta, sivun renderöinnistä, käyttäjän vuorovaikutuksiin (kuten klikkauksiin ja vierityksiin) vastaamisesta sekä CSS-animaatioiden ajamisesta. Se hallitsee näitä tehtäviä tapahtumasilmukan avulla, joka käsittelee jatkuvasti viestijonoa (tehtäviä). Jos tehtävän suorittaminen kestää kauan, se estää koko jonon toiminnan. Mitään muuta ei voi tapahtua – käyttöliittymä jäätyy, animaatiot pätkivät ja sivu muuttuu reagoimattomaksi.
Web Workerit: Askel oikeaan suuntaan
Web Workerit otettiin käyttöön tämän ongelman lievittämiseksi. Web Worker on käytännössä skripti, joka suoritetaan erillisessä taustasäikeessä. Voit siirtää raskaita laskutoimituksia workerille, jolloin pääsäie pysyy vapaana käsittelemään käyttöliittymää.
Viestintä pääsäikeen ja workerin välillä tapahtuu postMessage()
-API:n kautta. Kun lähetät dataa, se käsitellään strukturoidun kloonauksen algoritmilla. Tämä tarkoittaa, että data sarjallistetaan, kopioidaan ja sitten deserialisoidaan workerin kontekstissa. Vaikka tämä on tehokasta, prosessilla on merkittäviä haittoja suurten datajoukkojen kohdalla:
- Suorituskyvyn kuormitus: Megatavujen tai jopa gigatavujen datan kopioiminen säikeiden välillä on hidasta ja CPU-intensiivistä.
- Muistin kulutus: Se luo datasta kopion muistiin, mikä voi olla suuri ongelma laitteille, joissa on vähän muistia.
Kuvittele videoeditori selaimessa. Koko videokehyksen (joka voi olla useita megatavuja) lähettäminen edestakaisin workerille käsiteltäväksi 60 kertaa sekunnissa olisi kohtuuttoman kallista. Tämä on juuri se ongelma, jonka SharedArrayBuffer
suunniteltiin ratkaisemaan.
Pelin muuttaja: SharedArrayBufferin
esittely
SharedArrayBuffer
on kiinteän pituinen raaka binääridatapuskuri, samankaltainen kuin ArrayBuffer
. Kriittinen ero on, että SharedArrayBuffer
voidaan jakaa useiden säikeiden (esim. pääsäikeen ja yhden tai useamman Web Workerin) kesken. Kun "lähetät" SharedArrayBufferin
käyttämällä postMessage()
-metodia, et lähetä kopiota; lähetät viittauksen samaan muistilohkoon.
Tämä tarkoittaa, että kaikki yhden säikeen puskurin dataan tekemät muutokset ovat välittömästi näkyvissä kaikille muille säikeille, joilla on viittaus siihen. Tämä eliminoi kalliin kopiointi- ja sarjallistamisvaiheen, mahdollistaen lähes välittömän datan jakamisen.
Ajattele sitä näin:
- Web Workerit
postMessage()
:lla: Tämä on kuin kaksi kollegaa työskentelisi dokumentin parissa lähettämällä kopioita sähköpostitse edestakaisin. Jokainen muutos vaatii kokonaan uuden kopion lähettämisen. - Web Workerit
SharedArrayBuffer
:lla: Tämä on kuin kaksi kollegaa työskentelisi saman dokumentin parissa jaetussa verkkopalvelussa (kuten Google Docs). Muutokset ovat molempien nähtävissä reaaliajassa.
Jaetun muistin vaara: Kilpailutilanteet
Välitön muistin jakaminen on tehokasta, mutta se tuo mukanaan myös klassisen ongelman rinnakkaisohjelmoinnin maailmasta: kilpailutilanteet (race conditions).
Kilpailutilanne syntyy, kun useat säikeet yrittävät käyttää ja muokata samaa jaettua dataa samanaikaisesti, ja lopputulos riippuu niiden arvaamattomasta suoritusjärjestyksestä. Kuvitellaan yksinkertainen laskuri, joka on tallennettu SharedArrayBufferiin
. Sekä pääsäie että workeri haluavat kasvattaa sitä.
- Säie A lukee nykyisen arvon, joka on 5.
- Ennen kuin säie A ehtii kirjoittaa uutta arvoa, käyttöjärjestelmä keskeyttää sen ja vaihtaa säikeeseen B.
- Säie B lukee nykyisen arvon, joka on edelleen 5.
- Säie B laskee uuden arvon (6) ja kirjoittaa sen takaisin muistiin.
- Järjestelmä vaihtaa takaisin säikeeseen A. Se ei tiedä, että säie B teki mitään. Se jatkaa siitä, mihin se jäi, laskien oman uuden arvonsa (5 + 1 = 6) ja kirjoittaen arvon 6 takaisin muistiin.
Vaikka laskuria kasvatettiin kahdesti, lopullinen arvo on 6, ei 7. Operaatiot eivät olleet atomisia – ne olivat keskeytettävissä, mikä johti datan katoamiseen. Juuri tästä syystä et voi käyttää SharedArrayBufferia
ilman sen elintärkeää kumppania: Atomics
-oliota.
Jaetun muistin vartija: Atomics
-olio
Atomics
-olio tarjoaa joukon staattisia metodeja atomisten operaatioiden suorittamiseen SharedArrayBuffer
-olioilla. Atominen operaatio suoritetaan taatusti kokonaisuudessaan ilman, että mikään muu operaatio keskeyttää sitä. Se joko tapahtuu kokonaan tai ei lainkaan.
Atomicsin
käyttö estää kilpailutilanteet varmistamalla, että luku-muokkaus-kirjoitus-operaatiot jaetussa muistissa suoritetaan turvallisesti.
Tärkeimmät Atomics
-metodit
Katsotaanpa joitakin tärkeimpiä Atomicsin
tarjoamia metodeja.
Atomics.load(typedArray, index)
: Lukee atomisesti arvon annetusta indeksistä ja palauttaa sen. Tämä varmistaa, että luet täydellisen, vioittumattoman arvon.Atomics.store(typedArray, index, value)
: Tallentaa atomisesti arvon annettuun indeksiin ja palauttaa kyseisen arvon. Tämä varmistaa, että kirjoitusoperaatiota ei keskeytetä.Atomics.add(typedArray, index, value)
: Lisää atomisesti arvon annetussa indeksissä olevaan arvoon. Se palauttaa alkuperäisen arvon kyseisessä paikassa. Tämä on atominen vastine operaatiollex += value
.Atomics.sub(typedArray, index, value)
: Vähentää atomisesti arvon annetussa indeksissä olevasta arvosta.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Tämä on tehokas ehdollinen kirjoitus. Se tarkistaa, onko arvo indeksissäindex
sama kuinexpectedValue
. Jos on, se korvaa senreplacementValue
:lla ja palauttaa alkuperäisenexpectedValue
:n. Jos ei, se ei tee mitään ja palauttaa nykyisen arvon. Tämä on perustavanlaatuinen rakennuspalikka monimutkaisempien synkronointiprimitiivien, kuten lukkojen, toteuttamiseen.
Synkronointi: Yksinkertaisia operaatioita pidemmälle
Joskus tarvitset enemmän kuin vain turvallista lukemista ja kirjoittamista. Säikeiden on koordinoitava ja odotettava toisiaan. Yleinen anti-pattern on "aktiivinen odotus" (busy-waiting), jossa säie istuu tiukassa silmukassa tarkistaen jatkuvasti muistipaikkaa muutoksen varalta. Tämä tuhlaa CPU-syklejä ja kuluttaa akkua.
Atomics
tarjoaa paljon tehokkaamman ratkaisun metodeilla wait()
ja notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Tämä käskee säiettä menemään nukkumaan. Se tarkistaa, onko arvo indeksissäindex
edelleenvalue
. Jos on, säie nukkuu, kunnes se herätetäänAtomics.notify()
:lla tai kunnes valinnainentimeout
(millisekunneissa) saavutetaan. Jos arvo indeksissäindex
on jo muuttunut, se palaa välittömästi. Tämä on uskomattoman tehokasta, koska nukkuva säie kuluttaa tuskin lainkaan CPU-resursseja.Atomics.notify(typedArray, index, count)
: Tätä käytetään herättämään säikeitä, jotka nukkuvat tietyssä muistipaikassaAtomics.wait()
:n avulla. Se herättää enintääncount
määrän odottavia säikeitä (tai kaikki, joscount
-arvoa ei ole annettu tai se onInfinity
).
Kaiken yhdistäminen: Käytännön opas
Nyt kun ymmärrämme teorian, käydään läpi vaiheet ratkaisun toteuttamiseksi käyttämällä SharedArrayBufferia
.
Vaihe 1: Tietoturvan ennakkoehto - Cross-Origin-eristys
Tämä on yleisin kompastuskivi kehittäjille. Tietoturvasyistä SharedArrayBuffer
on saatavilla vain sivuilla, jotka ovat cross-origin-eristetyssä tilassa. Tämä on turvatoimenpide spekulatiivisen suorituksen haavoittuvuuksien, kuten Spectren, lieventämiseksi, jotka voisivat mahdollisesti käyttää korkean resoluution ajastimia (jaetun muistin mahdollistamana) vuotaakseen dataa alkuperien välillä.
Ottaksesi cross-origin-eristyksen käyttöön, sinun on määritettävä verkkopalvelimesi lähettämään kaksi tiettyä HTTP-otsaketta päädokumentillesi:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Eristää dokumenttisi selauskontekstin muista dokumenteista, estäen niitä suoraan vuorovaikuttamasta window-oliosi kanssa.Cross-Origin-Embedder-Policy: require-corp
(COEP): Vaatii, että kaikki sivusi lataamat aliresurssit (kuten kuvat, skriptit ja iframe-kehykset) ovat joko samasta alkuperästä tai ne on nimenomaisesti merkitty cross-origin-ladattaviksiCross-Origin-Resource-Policy
-otsakkeella tai CORS:lla.
Tämän määrittäminen voi olla haastavaa, erityisesti jos käytät kolmannen osapuolen skriptejä tai resursseja, jotka eivät tarjoa tarvittavia otsakkeita. Palvelimen konfiguroinnin jälkeen voit varmistaa, onko sivusi eristetty, tarkistamalla self.crossOriginIsolated
-ominaisuuden selaimen konsolista. Sen on oltava true
.
Vaihe 2: Puskurin luominen ja jakaminen
Pääskriptissäsi luot SharedArrayBufferin
ja sille "näkymän" käyttämällä TypedArray
:ta, kuten Int32Array
.
main.js:
// Check for cross-origin isolation first!
if (!self.crossOriginIsolated) {
console.error("This page is not cross-origin isolated. SharedArrayBuffer will not be available.");
} else {
// Create a shared buffer for one 32-bit integer.
const buffer = new SharedArrayBuffer(4);
// Create a view on the buffer. All atomic operations happen on the view.
const int32Array = new Int32Array(buffer);
// Initialize the value at index 0.
int32Array[0] = 0;
// Create a new worker.
const worker = new Worker('worker.js');
// Send the SHARED buffer to the worker. This is a reference transfer, not a copy.
worker.postMessage({ buffer });
// Listen for messages from the worker.
worker.onmessage = (event) => {
console.log(`Worker reported completion. Final value: ${Atomics.load(int32Array, 0)}`);
};
}
Vaihe 3: Atomisten operaatioiden suorittaminen workerissa
Workeri vastaanottaa puskurin ja voi nyt suorittaa sille atomisia operaatioita.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker received the shared buffer.");
// Let's perform some atomic operations.
for (let i = 0; i < 1000000; i++) {
// Safely increment the shared value.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker finished incrementing.");
// Signal back to the main thread that we are done.
self.postMessage({ done: true });
};
Vaihe 4: Edistyneempi esimerkki - Rinnakkainen summaus synkronoinnilla
Käsitelläänpä realistisempaa ongelmaa: erittäin suuren numerotaulukon summaamista käyttämällä useita workereita. Käytämme Atomics.wait()
ja Atomics.notify()
tehokkaaseen synkronointiin.
Jaetussa puskurissamme on kolme osaa:
- Indeksi 0: Tilalippu (0 = käsittely kesken, 1 = valmis).
- Indeksi 1: Laskuri sille, kuinka moni workeri on valmistunut.
- Indeksi 2: Lopullinen summa.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [status, workers_finished, result_low, result_high]
// We use two 32-bit integers for the result to avoid overflow for large sums.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 integers
const sharedArray = new Int32Array(sharedBuffer);
// Generate some random data to process
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);
// Create a non-shared view for the worker's chunk of data
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // This is copied
});
}
console.log('Main thread is now waiting for workers to finish...');
// Wait for the status flag at index 0 to become 1
// This is much better than a while loop!
Atomics.wait(sharedArray, 0, 0); // Wait if sharedArray[0] is 0
console.log('Main thread woken up!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`The final parallel sum is: ${finalSum}`);
} else {
console.error('Page is not cross-origin isolated.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Calculate the sum for this worker's chunk
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Atomically add the local sum to the shared total
Atomics.add(sharedArray, 2, localSum);
// Atomically increment the 'workers finished' counter
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// If this is the last worker to finish...
const NUM_WORKERS = 4; // Should be passed in a real app
if (finishedCount === NUM_WORKERS) {
console.log('Last worker finished. Notifying main thread.');
// 1. Set the status flag to 1 (complete)
Atomics.store(sharedArray, 0, 1);
// 2. Notify the main thread, which is waiting on index 0
Atomics.notify(sharedArray, 0, 1);
}
};
Tosimaailman käyttötapaukset ja sovellukset
Missä tämä tehokas mutta monimutkainen teknologia todella tekee eron? Se loistaa sovelluksissa, jotka vaativat raskasta, rinnakkaistettavaa laskentaa suurilla datajoukoilla.
- WebAssembly (Wasm): Tämä on sen ykköskäyttökohde. Kielillä, kuten C++, Rust ja Go, on kypsä tuki monisäikeistykselle. Wasm antaa kehittäjille mahdollisuuden kääntää nämä olemassa olevat korkean suorituskyvyn monisäikeiset sovellukset (kuten pelimoottorit, CAD-ohjelmistot ja tieteelliset mallit) toimimaan selaimessa käyttäen
SharedArrayBufferia
säikeiden välisen viestinnän taustamekanismina. - Selaimessa tapahtuva datankäsittely: Laajamittainen datan visualisointi, asiakaspuolen koneoppimismallien päättely ja tieteelliset simulaatiot, jotka käsittelevät valtavia datamääriä, voidaan merkittävästi nopeuttaa.
- Median muokkaus: Suodattimien soveltaminen korkearesoluutioisiin kuviin tai audioprosessointi äänitiedostolle voidaan jakaa osiin ja käsitellä rinnakkain useilla workereilla, tarjoten reaaliaikaista palautetta käyttäjälle.
- Korkean suorituskyvyn pelaaminen: Nykyaikaiset pelimoottorit tukeutuvat vahvasti monisäikeisyyteen fysiikan, tekoälyn ja resurssien lataamisen osalta.
SharedArrayBuffer
mahdollistaa konsolitasoisten pelien rakentamisen, jotka toimivat kokonaan selaimessa.
Haasteet ja loppuhuomiot
Vaikka SharedArrayBuffer
on mullistava, se ei ole ihmelääke. Se on matalan tason työkalu, joka vaatii huolellista käsittelyä.
- Monimutkaisuus: Rinnakkaisohjelmointi on tunnetusti vaikeaa. Kilpailutilanteiden ja lukkiutumien (deadlocks) debuggaus voi olla uskomattoman haastavaa. Sinun on ajateltava eri tavalla sovelluksesi tilan hallinnasta.
- Lukkiutumat: Lukkiutuma tapahtuu, kun kaksi tai useampi säie on pysyvästi estettynä, odottaen toisiaan vapauttamaan resurssin. Tämä voi tapahtua, jos toteutat monimutkaisia lukitusmekanismeja virheellisesti.
- Tietoturvan lisävaatimukset: Cross-origin-eristysvaatimus on merkittävä este. Se voi rikkoa integraatioita kolmannen osapuolen palveluihin, mainoksiin ja maksuyhdyskäytäviin, jos ne eivät tue tarvittavia CORS/CORP-otsakkeita.
- Ei jokaiseen ongelmaan: Yksinkertaisiin taustatehtäviin tai I/O-operaatioihin perinteinen Web Worker -malli
postMessage()
:lla on usein yksinkertaisempi ja riittävä. OtaSharedArrayBuffer
käyttöön vain, kun sinulla on selkeä, CPU-sidonnainen pullonkaula, joka liittyy suuriin datamääriin.
Yhteenveto
SharedArrayBuffer
yhdessä Atomics
in ja Web Workereiden kanssa edustaa paradigman muutosta web-kehityksessä. Se murtaa yksisäikeisen mallin rajat, kutsuen uuden luokan tehokkaita, suorituskykyisiä ja monimutkaisia sovelluksia selaimeen. Se asettaa verkkoympäristön tasavertaisempaan asemaan natiivisovelluskehityksen kanssa laskennallisesti raskaissa tehtävissä.
Matka rinnakkaiseen JavaScriptiin on haastava, vaatien kurinalaista lähestymistapaa tilanhallintaan, synkronointiin ja tietoturvaan. Mutta kehittäjille, jotka haluavat ylittää verkon mahdollisuuksien rajoja – reaaliaikaisesta äänisynteesistä monimutkaiseen 3D-renderöintiin ja tieteelliseen laskentaan – SharedArrayBufferin
hallitseminen ei ole enää vain vaihtoehto; se on olennainen taito seuraavan sukupolven verkkosovellusten rakentamisessa.