Odemkněte skutečný multithreading v JavaScriptu. Průvodce SharedArrayBuffer, Atomics a Web Workers pro vysoce výkonné webové aplikace.
JavaScript SharedArrayBuffer: Hloubkový pohled na souběžné programování na webu
Jednovláknová povaha JavaScriptu byla po desetiletí zdrojem jeho jednoduchosti, ale zároveň i významným výkonnostním omezením. Model smyčky událostí (event loop) funguje skvěle pro většinu úloh řízených uživatelským rozhraním, ale selhává při výpočetně náročných operacích. Dlouhotrvající výpočty mohou zamrazit prohlížeč a způsobit frustrující uživatelský zážitek. Ačkoli Web Workers nabídly částečné řešení tím, že umožnily spouštět skripty na pozadí, přinesly s sebou vlastní velké omezení: neefektivní datovou komunikaci.
Přichází SharedArrayBuffer
(SAB), mocná funkce, která zásadně mění pravidla hry tím, že zavádí skutečné, nízkoúrovňové sdílení paměti mezi vlákny na webu. Ve spojení s objektem Atomics
odemyká SAB novou éru vysoce výkonných, souběžných aplikací přímo v prohlížeči. S velkou mocí však přichází velká zodpovědnost – a složitost.
Tento průvodce vás zavede do hlubin světa souběžného programování v JavaScriptu. Prozkoumáme, proč ho potřebujeme, jak SharedArrayBuffer
a Atomics
fungují, klíčové bezpečnostní aspekty, kterým musíte věnovat pozornost, a praktické příklady, které vám pomohou začít.
Starý svět: Jednovláknový model JavaScriptu a jeho omezení
Než dokážeme ocenit řešení, musíme plně porozumět problému. Spouštění JavaScriptu v prohlížeči tradičně probíhá v jediném vlákně, často nazývaném „hlavní vlákno“ nebo „UI vlákno“.
Smyčka událostí (Event Loop)
Hlavní vlákno je zodpovědné za vše: spouštění vašeho JavaScriptového kódu, vykreslování stránky, reakce na interakce uživatele (jako jsou kliknutí a posouvání) a spouštění CSS animací. Tyto úkoly spravuje pomocí smyčky událostí, která neustále zpracovává frontu zpráv (úkolů). Pokud nějaký úkol trvá dlouho, zablokuje celou frontu. Nic jiného se nemůže dít – uživatelské rozhraní zamrzne, animace se zasekávají a stránka přestane reagovat.
Web Workers: Krok správným směrem
Web Workers byly zavedeny, aby tento problém zmírnily. Web Worker je v podstatě skript běžící v samostatném vlákně na pozadí. Můžete na něj přesunout náročné výpočty a udržet tak hlavní vlákno volné pro obsluhu uživatelského rozhraní.
Komunikace mezi hlavním vláknem a workerem probíhá prostřednictvím API postMessage()
. Když posíláte data, jsou zpracována pomocí algoritmu strukturovaného klonování (structured clone algorithm). To znamená, že data jsou serializována, zkopírována a poté deserializována v kontextu workera. I když je tento proces efektivní, má pro velké datové sady významné nevýhody:
- Výkonnostní režie: Kopírování megabajtů nebo dokonce gigabajtů dat mezi vlákny je pomalé a náročné na CPU.
- Spotřeba paměti: Vytváří se duplikát dat v paměti, což může být velký problém pro zařízení s omezenou pamětí.
Představte si video editor v prohlížeči. Posílání celého video snímku (který může mít několik megabajtů) tam a zpět do workera ke zpracování 60krát za sekundu by bylo neúnosně nákladné. Přesně tento problém byl SharedArrayBuffer
navržen k řešení.
Změna pravidel hry: Představení SharedArrayBuffer
SharedArrayBuffer
je binární datový buffer s pevnou délkou, podobný ArrayBuffer
. Klíčový rozdíl je v tom, že SharedArrayBuffer
může být sdílen napříč několika vlákny (např. hlavním vláknem a jedním nebo více Web Workery). Když „posíláte“ SharedArrayBuffer
pomocí postMessage()
, neposíláte kopii; posíláte odkaz na tentýž blok paměti.
To znamená, že jakékoli změny provedené v datech bufferu jedním vláknem jsou okamžitě viditelné pro všechna ostatní vlákna, která na něj mají odkaz. Tím se eliminuje nákladný krok kopírování a serializace, což umožňuje téměř okamžité sdílení dat.
Představte si to takto:
- Web Workers s
postMessage()
: Je to jako když dva kolegové pracují na dokumentu tak, že si posílají kopie e-mailem tam a zpět. Každá změna vyžaduje odeslání celé nové kopie. - Web Workers se
SharedArrayBuffer
: Je to jako když dva kolegové pracují na stejném dokumentu ve sdíleném online editoru (jako jsou Google Docs). Změny jsou pro oba viditelné v reálném čase.
Nebezpečí sdílené paměti: Souběhy (Race Conditions)
Okamžité sdílení paměti je mocné, ale také přináší klasický problém ze světa souběžného programování: souběhy (race conditions).
K souběhu dochází, když se více vláken pokouší přistupovat a upravovat stejná sdílená data současně a konečný výsledek závisí na nepředvídatelném pořadí, v jakém se provedou. Představte si jednoduchý čítač uložený v SharedArrayBuffer
. Jak hlavní vlákno, tak worker ho chtějí inkrementovat.
- Vlákno A přečte aktuální hodnotu, která je 5.
- Než Vlákno A stihne zapsat novou hodnotu, operační systém ho pozastaví a přepne na Vlákno B.
- Vlákno B přečte aktuální hodnotu, která je stále 5.
- Vlákno B vypočítá novou hodnotu (6) a zapíše ji zpět do paměti.
- Systém se přepne zpět na Vlákno A. To neví, že Vlákno B něco udělalo. Pokračuje tam, kde skončilo, vypočítá svou novou hodnotu (5 + 1 = 6) a zapíše 6 zpět do paměti.
I když byl čítač inkrementován dvakrát, konečná hodnota je 6, ne 7. Operace nebyly atomické – byly přerušitelné, což vedlo ke ztrátě dat. Přesně proto nemůžete používat SharedArrayBuffer
bez jeho klíčového partnera: objektu Atomics
.
Strážce sdílené paměti: Objekt Atomics
Objekt Atomics
poskytuje sadu statických metod pro provádění atomických operací na objektech SharedArrayBuffer
. Atomická operace je zaručeně provedena vcelku, aniž by byla přerušena jakoukoli jinou operací. Buď se provede kompletně, nebo vůbec.
Použití Atomics
zabraňuje souběhům tím, že zajišťuje bezpečné provedení operací typu čtení-úprava-zápis ve sdílené paměti.
Klíčové metody Atomics
Podívejme se na některé z nejdůležitějších metod, které Atomics
poskytuje.
Atomics.load(typedArray, index)
: Atomicky přečte hodnotu na daném indexu a vrátí ji. Tím je zajištěno, že čtete kompletní, nepoškozenou hodnotu.Atomics.store(typedArray, index, value)
: Atomicky uloží hodnotu na daný index a vrátí tuto hodnotu. Tím je zajištěno, že operace zápisu není přerušena.Atomics.add(typedArray, index, value)
: Atomicky přičte hodnotu k hodnotě na daném indexu. Vrací původní hodnotu na této pozici. Jedná se o atomický ekvivalentx += value
.Atomics.sub(typedArray, index, value)
: Atomicky odečte hodnotu od hodnoty na daném indexu.Atomics.compareExchange(typedArray, index, expectedValue, replacementValue)
: Toto je mocný podmíněný zápis. Zkontroluje, zda se hodnota naindex
rovnáexpectedValue
. Pokud ano, nahradí jireplacementValue
a vrátí původníexpectedValue
. Pokud ne, neudělá nic a vrátí aktuální hodnotu. Jedná se o základní stavební kámen pro implementaci složitějších synchronizačních primitiv, jako jsou zámky.
Synchronizace: Více než jen jednoduché operace
Někdy potřebujete víc než jen bezpečné čtení a zápis. Potřebujete, aby se vlákna koordinovala a čekala na sebe. Běžným anti-patternem je „aktivní čekání“ (busy-waiting), kdy vlákno sedí v těsné smyčce a neustále kontroluje paměťové místo, zda nedošlo ke změně. To plýtvá cykly CPU a vybíjí baterii.
Atomics
poskytuje mnohem efektivnější řešení s metodami wait()
a notify()
.
Atomics.wait(typedArray, index, value, timeout)
: Tato metoda uspí vlákno. Zkontroluje, zda hodnota naindex
je stálevalue
. Pokud ano, vlákno spí, dokud není probuzeno pomocíAtomics.notify()
nebo dokud nevyprší volitelnýtimeout
(v milisekundách). Pokud se hodnota naindex
již změnila, metoda se okamžitě vrátí. Je to neuvěřitelně efektivní, protože spící vlákno nespotřebovává téměř žádné prostředky CPU.Atomics.notify(typedArray, index, count)
: Tato metoda se používá k probuzení vláken, která spí na konkrétním paměťovém místě prostřednictvímAtomics.wait()
. Probudí maximálněcount
čekajících vláken (nebo všechna, pokudcount
není zadán nebo jeInfinity
).
Vše dohromady: Praktický průvodce
Nyní, když rozumíme teorii, pojďme si projít kroky implementace řešení pomocí SharedArrayBuffer
.
Krok 1: Bezpečnostní předpoklad – Cross-Origin Isolation
Toto je nejčastější překážka pro vývojáře. Z bezpečnostních důvodů je SharedArrayBuffer
k dispozici pouze na stránkách, které jsou ve stavu cross-origin isolated. Jedná se o bezpečnostní opatření ke zmírnění zranitelností spekulativního spouštění, jako je Spectre, které by mohly potenciálně využít časovače s vysokým rozlišením (umožněné sdílenou pamětí) k úniku dat mezi různými původy (origins).
Pro povolení cross-origin izolace musíte nakonfigurovat váš webový server tak, aby pro váš hlavní dokument posílal dvě specifické HTTP hlavičky:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
(COOP): Izoluje kontext procházení vašeho dokumentu od ostatních dokumentů, čímž jim brání v přímé interakci s vaším objektem window.Cross-Origin-Embedder-Policy: require-corp
(COEP): Vyžaduje, aby všechny podřízené zdroje (jako obrázky, skripty a iframy) načtené vaší stránkou byly buď ze stejného původu (same origin), nebo explicitně označeny jako načitatelné z jiného původu pomocí hlavičkyCross-Origin-Resource-Policy
nebo CORS.
Nastavení může být náročné, zvláště pokud se spoléháte na skripty nebo zdroje třetích stran, které neposkytují potřebné hlavičky. Po nakonfigurování serveru můžete ověřit, zda je vaše stránka izolovaná, kontrolou vlastnosti self.crossOriginIsolated
v konzoli prohlížeče. Musí být true
.
Krok 2: Vytvoření a sdílení bufferu
Ve svém hlavním skriptu vytvoříte SharedArrayBuffer
a „pohled“ na něj pomocí TypedArray
, jako je Int32Array
.
main.js:
// Nejprve zkontrolujte cross-origin izolaci!
if (!self.crossOriginIsolated) {
console.error("Tato stránka není cross-origin isolated. SharedArrayBuffer nebude k dispozici.");
} else {
// Vytvořte sdílený buffer pro jedno 32bitové celé číslo.
const buffer = new SharedArrayBuffer(4);
// Vytvořte pohled na buffer. Všechny atomické operace se provádějí na tomto pohledu.
const int32Array = new Int32Array(buffer);
// Inicializujte hodnotu na indexu 0.
int32Array[0] = 0;
// Vytvořte nového workera.
const worker = new Worker('worker.js');
// Odešlete SDÍLENÝ buffer workerovi. Jedná se o předání reference, nikoli o kopii.
worker.postMessage({ buffer });
// Naslouchejte zprávám od workera.
worker.onmessage = (event) => {
console.log(`Worker ohlásil dokončení. Konečná hodnota: ${Atomics.load(int32Array, 0)}`);
};
}
Krok 3: Provádění atomických operací ve workeru
Worker obdrží buffer a nyní na něm může provádět atomické operace.
worker.js:
self.onmessage = (event) => {
const { buffer } = event.data;
const int32Array = new Int32Array(buffer);
console.log("Worker obdržel sdílený buffer.");
// Proveďme několik atomických operací.
for (let i = 0; i < 1000000; i++) {
// Bezpečně inkrementujte sdílenou hodnotu.
Atomics.add(int32Array, 0, 1);
}
console.log("Worker dokončil inkrementaci.");
// Dejte signál hlavnímu vláknu, že jsme hotovi.
self.postMessage({ done: true });
};
Krok 4: Pokročilejší příklad – Paralelní sčítání se synchronizací
Pojďme se podívat na realističtější problém: sčítání velmi velkého pole čísel pomocí více workerů. Pro efektivní synchronizaci použijeme Atomics.wait()
a Atomics.notify()
.
Náš sdílený buffer bude mít tři části:
- Index 0: Příznak stavu (0 = zpracovává se, 1 = dokončeno).
- Index 1: Čítač, kolik workerů dokončilo práci.
- Index 2: Konečný součet.
main.js:
if (self.crossOriginIsolated) {
const NUM_WORKERS = 4;
const DATA_SIZE = 10_000_000;
// [stav, workeri_dokonceno, vysledek_nizsi, vysledek_vyssi]
// Používáme dvě 32bitová celá čísla pro výsledek, abychom se vyhnuli přetečení u velkých součtů.
const sharedBuffer = new SharedArrayBuffer(4 * 4); // 4 celá čísla
const sharedArray = new Int32Array(sharedBuffer);
// Vygenerujte náhodná data ke zpracování
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);
// Vytvořte nesdílený pohled pro část dat daného workera
const dataChunk = data.subarray(start, end);
worker.postMessage({
sharedBuffer,
dataChunk // Toto se kopíruje
});
}
console.log('Hlavní vlákno nyní čeká na dokončení workerů...');
// Počkejte, dokud se příznak stavu na indexu 0 nezmění na 1
// Je to mnohem lepší než while smyčka!
Atomics.wait(sharedArray, 0, 0); // Čekej, pokud je sharedArray[0] rovno 0
console.log('Hlavní vlákno bylo probuzeno!');
const finalSum = Atomics.load(sharedArray, 2);
console.log(`Konečný paralelní součet je: ${finalSum}`);
} else {
console.error('Stránka není cross-origin isolated.');
}
sum_worker.js:
self.onmessage = ({ data }) => {
const { sharedBuffer, dataChunk } = data;
const sharedArray = new Int32Array(sharedBuffer);
// Vypočítejte součet pro část dat tohoto workera
let localSum = 0;
for (let i = 0; i < dataChunk.length; i++) {
localSum += dataChunk[i];
}
// Atomicky přičtěte lokální součet ke sdílenému celkovému součtu
Atomics.add(sharedArray, 2, localSum);
// Atomicky inkrementujte čítač 'dokončených workerů'
const finishedCount = Atomics.add(sharedArray, 1, 1) + 1;
// Pokud je toto poslední worker, který dokončil práci...
const NUM_WORKERS = 4; // V reálné aplikaci by se mělo předávat jako parametr
if (finishedCount === NUM_WORKERS) {
console.log('Poslední worker dokončil. Upozorňuji hlavní vlákno.');
// 1. Nastavte příznak stavu na 1 (dokončeno)
Atomics.store(sharedArray, 0, 1);
// 2. Upozorněte hlavní vlákno, které čeká na indexu 0
Atomics.notify(sharedArray, 0, 1);
}
};
Případy použití a aplikace v reálném světě
Kde tato mocná, ale složitá technologie skutečně přináší změnu? Vyniká v aplikacích, které vyžadují náročné, paralelizovatelné výpočty nad velkými datovými sadami.
- WebAssembly (Wasm): Toto je klíčový případ použití. Jazyky jako C++, Rust a Go mají vyspělou podporu pro multithreading. Wasm umožňuje vývojářům kompilovat tyto existující vysoce výkonné, vícevláknové aplikace (jako jsou herní enginy, CAD software a vědecké modely) pro běh v prohlížeči, přičemž
SharedArrayBuffer
slouží jako základní mechanismus pro komunikaci mezi vlákny. - Zpracování dat v prohlížeči: Rozsáhlé vizualizace dat, inference modelů strojového učení na straně klienta a vědecké simulace zpracovávající obrovské množství dat mohou být výrazně zrychleny.
- Úprava médií: Aplikace filtrů na obrázky s vysokým rozlišením nebo zpracování zvuku v audio souboru lze rozdělit na části a zpracovávat paralelně pomocí více workerů, což uživateli poskytuje zpětnou vazbu v reálném čase.
- Vysoce výkonné hry: Moderní herní enginy se silně spoléhají na multithreading pro fyziku, umělou inteligenci a načítání aktiv.
SharedArrayBuffer
umožňuje vytvářet hry v kvalitě konzolových her, které běží kompletně v prohlížeči.
Výzvy a závěrečná zvážení
Ačkoli je SharedArrayBuffer
transformační, není to všelék. Je to nízkoúrovňový nástroj, který vyžaduje opatrné zacházení.
- Složitost: Souběžné programování je notoricky obtížné. Ladění souběhů a deadlocků může být neuvěřitelně náročné. Musíte přemýšlet jinak o tom, jak je spravován stav vaší aplikace.
- Deadlocky (zablokování): K deadlocku dochází, když jsou dvě nebo více vláken navždy zablokována, přičemž každé čeká, až to druhé uvolní zdroj. To se může stát, pokud nesprávně implementujete složité zamykací mechanismy.
- Bezpečnostní režie: Požadavek na cross-origin izolaci je významnou překážkou. Může narušit integrace se službami třetích stran, reklamami a platebními bránami, pokud nepodporují potřebné hlavičky CORS/CORP.
- Není pro každý problém: Pro jednoduché úlohy na pozadí nebo I/O operace je tradiční model Web Worker s
postMessage()
často jednodušší a dostačující. PoSharedArrayBuffer
sáhněte pouze tehdy, když máte jasné, CPU-vázané úzké hrdlo zahrnující velké množství dat.
Závěr
SharedArrayBuffer
, ve spojení s Atomics
a Web Workers, představuje změnu paradigmatu pro webový vývoj. Boří hranice jednovláknového modelu a zve do prohlížeče novou třídu výkonných, efektivních a komplexních aplikací. Staví webovou platformu na roveň s nativním vývojem aplikací pro výpočetně náročné úkoly.
Cesta do souběžného JavaScriptu je náročná a vyžaduje pečlivý přístup ke správě stavu, synchronizaci a bezpečnosti. Ale pro vývojáře, kteří chtějí posouvat hranice toho, co je na webu možné – od syntézy zvuku v reálném čase po komplexní 3D vykreslování a vědecké výpočty – zvládnutí SharedArrayBuffer
již není jen možností; je to nezbytná dovednost pro budování webových aplikací nové generace.