Asynchronní JavaScript pod pokličkou aneb Eventloop v praxi

Co znamená asynchronní JavaScript a jak funguje pod pokličkou? K čemu slouží event loop, API a event queue? Jak zařídit, aby váš kód zbytečně neblokoval prohlížeč, tzv. non-blocking kód?
Co je to JavaScript
Javascript je mnoho věcí (high-level, dynamic, weakly typed…). Jedna z definic tvrdí „a single threaded non-blocking asynchronous langugage“. V češtině by to znamenalo něco jako „jednovláknový neblokující asynchronní jazyk“. V tomto článku si projdeme každý aspekt této definice. Nejdřívě si však zopakujeme, co je to synchronní a asynchronní kód.
Synchronní vs. asynchronní kód
U synchronního kódu se čeká na jeho dokončení, než se provede další akce. U asynchronního kódu se na jeho dokončení nečeká a pokračuje se dál ve výpočtu.
Příklad synchronního kódu
const result = sum(4, 5);
console.log(result);
Čekáme na výsledek první operace (sum), než se provede další operace (console.log).
Příklad asynchronního kódu
console.log(1);
setTimeout(() => console.log(2), 100); // po zavolání setTimeout se hned provede další kód
console.log(3);
Jako první se provede console.log(1). Poté se zavolá funkce setTimeout(…) a ihned se provede další operace console.log(3). Po 100 milisekundách se provede anonymní funkce, kterou jsme předali jako první parametr funkce setTimeout.
Single thread aneb jedno vlákno
Snad každý javascriptový vývojář ví, že JavaScript je „single threaded“ (jednovláknový), ale co to vlastně znamená?
Single thread zpracovává pouze jeden příkaz v daný okamžik a je náchylný k zamrznutí. Javascriptový engine tedy zpracovává kód „postupně za sebou“. Má jeden zásobník (call stack), kam ukládá volané funkce s jejich argumenty (lokální proměnné) a haldu (memory heap), kam ukládá objekty. Jelikož engine používá jen jeden zásobník, tak nezvládne vykonávat více operací zároveň. Zásobník umožňuje dvě operace – vložení funkce na vrchol zásobníku (push) a odebrání funkce z vrcholu zásobníku (pop).
Praktický příklad call stacku
Definujme funkci baz a funkci foo.
function baz() {
console.log('javascript');
}
function foo() {
return baz();
}
foo();
Volaná funkce a její argumenty se vloží na vrchol zásobníku a po návratu z funkce se volaná funkce odebere z vrcholu zásobníku.
Na začátku je prázdný zásobník. Zavolá se funkce foo()
, která se vloží na vrchol zásobníku. Ta v těle volá funkci baz(), která se vloží na vrchol zásobníku. Funkce baz v těle volá funkci console.log('javascript')
, která se vloží na vrchol zásobníku. Do konzole se vypíše ‚javascript‘. Následně se odebere ze zásobníku funkce console.log('javascript')
. Funkce baz je na konci, jelikož nemá v těle return provede se „implicitně“ a odebere se funkce baz()
z vrcholu zásobníku. Poté se odebere z vrcholu zásobníku funkce foo()
. Zásobník je prázdný.
Celý tento proces najdete přehledně simulovaný v této ukázce (pozn.: nejprve musíte zavřít modální okno a pak kliknout na save + run).
Asynchronnost
Jak tedy fungují asynchronní operace, když je JavaScript jednovláknový a má jen jeden zásobník? Přece víme, že můžeme provést několik requestů na backend a dělat něco jiného, dokud nám nedojde odpověď ze serveru, kterou pak zpracujeme.
Odpovědí je trio API, Event Queue a Event Loop.
API
Javascriptový engine jako takový opravdu umí zpracovávat jen sekvenčně (popořadě). Asynchronní operace jako setTimeout
a AJAXové dotazy zpracovává API (Web nebo Node) podle toho, jestli kód vykonáváme v prohlížečí a nebo na serveru. To, jakým způsobem se tam řeší souběžnost (concurency), už javascriptový engine nezajímá.
Event Queue
Jakmile API dokončilo asynchronní operaci (třeba AJAX request), tak callback, který jsme předali asynchronní funkci jako její argument, se vloží do fronty (event queue) na konec.
Event Loop
Poté přichází na řadu Event Loop. Nejznámější pojem v javascriptovém světě a přitom nejmenší část v celém asynchronním výpočetním cyklu. Event loop nedělá nic jiného, než že si hlídá zásobník (call stack) a frontu (event queue). V případě, že je zásobník prázdný, vezme první callback z fronty (event queue) a vloží ho na vrchol zásobníku (call stacku) ke zpracování.
Jak to celé funguje v praxi
doSomethingSync();
setTimeout(function() {
console.log('async!');
}, 3000);
doSomethingSync();
Na začátku je prázdný zásobník. Zavolá se funkce doSomethingSync()
a vloží se na vrchol zásobníku. Provede se tělo funkce a odebere se z vrcholu zásobníku. Pokračujeme dál. Zavolá se funkce setTimeout(cb, 3000)
. Vloží se na vrchol zásobníku a jelikož je to funkce, kterou nám poskytuje API (Web, Node), tak nic dalšího neřešíme a tím je pro nás volání funkce setTimeout
hotové. Odebereme funkci setTimeout
z vrcholu zásobníku. Nakonec se zavolá funkce doSomethingSync()
a vloží se na vrchol zásobníku. Provede se tělo funkce a odebere se z vrcholu zásobníku.
Zásobník je prázdný. Nesmíme zapomenout, že se nám někde operace setTimeout
ještě provádí v API prostředí. Za cca 3 sekundy se operace dokončí a vloží se callback do fronty (event queue). Event loop ověří, jestli je prázdný zásobník (ano, je), vezme první callback z fronty a vloží ho na vrchol zásobníku ke zpracování.
Celý tento proces je z animovaný v tomto příkladu.
Blocking kód
Určitě se vám nekdy stalo. že jste „zasekali“ prohlížeč. Například když jste nevědomky vytvořili opravdu dlouhý cyklus. Prohlížeč nereagoval, nefungovala tlačítka a nešlo označit ani text. Problém je v tom, že prohlížeč se snaží provést překreslení (re-paint) každých 16 ms, a to jen v případě, kdy je prázdný zásobník (call stack). Jelikož se vám podařilo „odpálit stack“, prohlížeč nemá příležitost provést překreslení, protože je zásobník pořád plný. Kódu, který blokuje po dlouhou dobu zásobník, se říká blocking.
Příklad blocking kódu
function blockingSyncLoop(array) {
array.forEach(function(item) {
doSomeTimeConsumingStuff();
});
}
blockingSyncLoop([1, 2, 3, ..., 99999]);
V případě kdy by bylo pole opravdu velké a funkce doSomeTimeConsumingStuff
by prováděla časově náročnou synchronní operaci, tak bychom zablokovali zásobník na dostatečně dlouho dobu a narazili bychom na „performance“ problémy v prohlížeči.
Non-blocking kód
Non-blocking kód „neblokuje zásobník“. Dává prostor prohlížeči pro překreslení.
Příklad non-blocking kódu
function nonBlockingAsyncLoop(array, cb) {
array.forEach(function(item) {
setTimeout(function() {
cb(item);
}, 0);
})
}
nonBlockingAsyncLoop([1, 2, 3], function(item) {
doSomeTimeConsumingStuff();
})
Tělo forEach
cyklu je samozřejmě pořád synchronní (blocking). Takže se zásobník pořád „zablokuje“ na určitou dobu v závislosti na velikosti pole. Trik je v tom, že využijeme funkci setTimeout
, kterou nám poskytuje API. Výpočet probíhá následovně:
- Provede se tělo cyklu
forEach
(3x se zavolásetTimeout
). - Do fronty (event queue) se dostanou všechny 3 anonymní funkce, které jsme předali jako argument funkce
setTimeout
. - Zásobník se vyprázdní (cyklus
forEach
skončil). - Provede se překreslení (re-paint).
- Event loop vezme první callback z fronty a vloží ho ke zpracování na vrchol zásobníku (call stacku).
- Zpracuje se synchronní kód na zásobníku a zásobník se vyprázdní.
- Pokračujeme rekurzivně krokem 4 (překreslení), dokud nebude fronta prázdná.
Překreslování má větší prioritu než event loop, proto prohlížeč nejdříve provede překreslení (re-paint) a až poté nastupuje event loop. Jelikož po každé „iteraci“ dáváme šanci prohlížeči, aby se překreslil, než event loop vezme další callback ke zpracování, můžeme tak vyřešit některé blocking operace, které byly „pain in the ass“.
Hezkou ukázku, jak to funguje pod pokličkou, najdete v tomto příkladu. Abyste viděli simulaci překreslení v prohlížeči, musíte si zapnout „Simulate rendering“, který zapnete po kliknutím na ikonu „kladiva“ nahoře vlevo. Upozorňuji, že nástroj občas dělá neplechu.
Pár řádků na konec
Tento článek by vám měl dát aspoň nějakou představu o tom, jak „JavaScript“ vlastně funguje. Pokud by vám některé pojmy nebyly z textu jasné, doporučuji si pustit animace, které najdete v odkazech u jednotlivých příkladů. Jelikož je to poměrně rozsáhlé téma, omluvte prosím případná chybějící fakta. Snažil jsem se sem uvést co nejvíce relevantních informací tak, aby se vešly do rozsahu tohoto článku.
To není úplně dobrá formulace. Funkce setTimeout je synchronní a kód čeká na výsledek, konkrétně až vrátí timeoutID. Asynchronně se provede pouze funkce nebo kód, který ji předáme jako parametr.
Díky za vhodnou připomínku.
Upravíme
Nefunguje vám odkaz v této větě:
Vrací to 404
Problém je pravděpodobně v tom, že jsfiddle maže kód, který se vykonává podezřele dlouho.
Díky za info, upravíme.
Jak je to s await. Když zavolám
await NecoAsynchronniho();
bude to v tomto případě blokovat?
Await je jenom „syntax sugar“ nad promisema. Takze to blokovat nebude. Viz odkaz
Diky za clanek, tohle me vzdycky zajimalo a konec to neni zas takova magie :-)
*nakonec
Diky!
Díky moc za fajn článek, stručně, jasně, s přehlednou grafikou, paráda ;)
Díky za feedback
Občas mám pocit, že zde vycházejí články, jejichž cílem je ukázat, že autor je fakt mistr světa v dané problematice. U tohoto článku mám pocit, že je cílem danou problematiku vysvětlit, což se myslím podařilo.
Díky, více takových!
Tak to jsem moc rád, díky moc!
No ve skutečnosti se o ten synchronní kód stará sám procesor včetně zásobníku. Event loop do zásobníku počas běhu synchronního kódu nekouká, on prostě neběží, jen z druhé strany lze do něj vložit event. Je to jako poštovní schránka do které lze vložit příkaz (vykonání nějaké funkce) a když aktuální js vlákno dokončí co právě dělá (zpracovává jinou funkci) vrátí se ke schránce a vyzvedne si další příkaz. Pokud tam žádný není, tak tam aktivně čeká až tam nějaký padne
Tomuto mechanismu se jinde říká dispatching, funkci která to obstarává dispatcher a operaci vložení do fronty dispatch. Ve Windows od prvních verzi to dělá oblíbená sekvence GetMessage TranslateMessage a DispatchMessage. Do fronty se příkazy vkládají přes PostMessage a SendMessage.
Dispatching v jiných jazycích není, ale dá se ho dodělat. Jen se špatně píši knihovny které s ním počítají. Například já mám též v C++ knihovnu která definuje funkci dispatch() a pak funkci runDispatcher která po vzoru JS dělá onen zmíněný eventloop.