Práce softwarového vývojáře vyžaduje, abychom se učili novým věcem a sledovali trendy v oboru. Ať už z důvodu zlepšení si pozice na pracovním trhu nebo jen pro zvýšení svých znalostí a produktivity. V posledních dvou letech se k tomu, co musíme sledovat, nejdříve objevily první náznaky umělé inteligence v podobě různých AI asistentů, co-pilotů a integrací ChatGPT, které umí nahradit běžné a rutinní procesy, ale i generovat řešení složitějších problémů. Dnes, v době psaní tohoto textu, dokážou přední jazykové modely na základě jednoho promptu v Claude Code Fable nebo ChatGPT Astra vygenerovat celý projekt, který lze deploynout, otestovat a spustit. Od softwaru, medicíny až po copywriting a překládání, nyní čelí v těchto oborech profesionálové a profesionálky vlnám propouštění. O této tzv. technologické nezaměstnanosti se už nemluví pouze na akademické půdě. Začalo být tématem a každodenní realitou bílolímečkových oborů viz zde nebo zde. Není to poprvé, ani naposled, kdy pokroky v technologiích mnoho zaměstnání a oborů dělá irelevantní, a naopak vytváří nové. V takových případech dochází k oddělování zrna od plev a je legitimní se ptát, kdo zůstane v této AI-powered tržní ekonomice pro korporace užitečný?
Juniorní pozice se zdají být prvním a přirozeným terčem. Povrchní znalosti, nerozvinutá schopnost vidět technické problémy v širším kontextu technických, ale i byznysových požadavů. Tím AI přestává trpět, kdežto juniorní pozice jsou tímto známé.
Podívám-li se na svůj obor front-endu a back-endových serverových runtimů node/deno/bun, hlubší znalost Event Loopu mi přijde, že je jedna z několika tzv. „hard skills“, které v prostředí Javascriptu oddělují juniorního kodéra od seniorního softwarového inženýra. Pochopením Event Loopu dokáže člověk odpovědět na několik důležitých otázek, například: jak je možné, že Javascript je single-threaded programovací jazyk, přesto v něm děláme několik věcí naráz pomocí asynchronních operací? O Event Loopu lze psát leccos. Mé ambice jsou skromné. Podívám se na Event Loop takovým způsobem, abych dokázal vysvětlit jednu z nejběžnějších otázek na pracovním pohovoru na pozici front-end vývojářem: v jakém pořadí se provedou následující operace a proč:
setTimeout(() => {
console.log('hey, from set timeout');
}, 0);
new Promise((resolve) => resolve('ahoy from promise')).then((v) =>
console.log(v)
);
console.log('hello from console log');
Technický úvod do problému
Javascript je single-threaded. Pokud nepočítáme Web Workers API, znamená to, že Javascript umí zpracovávat v jeden okamžik pouze jedinou operaci. Javascript je navržen tak, že zpracovává operace sekvenčně metodou LIFO (Last-In, First Out), a dokud operace není dokončena, blokuje všechny následující operace. Za normálních okolností by například po kliknutí na tlačítko blokoval jeden pomalý GET request na vzdálené API veškerou uživatelskou interakci; celé uživatelské rozhraní prohlížeče by zamrzlo, dokud by se síťový požadavek (network request) nevyřídil. Když v devadesátých letech navrhl Brendan Eich Javascript, bylo tehdy v Javascriptu zpracování operací synchronní, s omezenými možnostmi zpracování událostí, které vyvolala uživatelská interakce s prohlížečem. Stálo by za historickou sondu, proč Eich takto Javascript navrhl. V současné době ale víme, že na Javascriptu založené webové stránky (tzn. celý web) se takto nechovají. Uživatel může interagovat s více částmi webové stránky najednou. Jak toho může single-threaded programovací jazyk dosáhnout?
Enter Javascript runtime
Když se řekne Javascript, musíme si uvědomit, že se jedná o kolekci knihoven a komponent. Teprve jejich propojením získáme funkční celek – runtime, tedy prostředí, ve kterém běží naše aplikace. V současné době máme na výběr ze dvou hlavních typů runtime. Buď se jedná o runtime webového prohlížeče, nebo o runtime jedné z několika serverových implementací, například Node.js, Deno, či nejnovější Bun. Přestože runtime v prohlížečích a na serverech má společné prvky, najdeme v nich i důležité rozdíly. Ty vyplývají ze zaměření daného typu runtime. Webové prohlížeče se orientují na práci s DOM (window, document), uživatelské interakce (addEventListener), síťové operace pomocí fetch nebo XMLHttpRequest, lokální úložiště v prohlížeči (localStorage, sessionStorage) aj. Zatímco runtime Node.js nebo Bun se orientuje na prostředí serverových aplikací, práci se souborovým systémem, databázemi, nebo low-level operace nad operačním systémem. Oba typy runtime ale implementují Event Loop. Pro zjednodušení se dále budu zajímat pouze o runtime webových prohlížečů.
Runtime webových prohlížečů
Javascriptový runtime webových prohlížečů se skládá z následujících částí:
- Javascript Engine
- Web APIs
- Event Loop
- Task queue a Microtask queue
Event Loop je pouze jedna z komponent a přichází do hry pouze v případě zpracování asynchronního kódu. Pokud v našem Javascriptovém programu používáme pouze synchronní operace, Event Loop pak nebude nikdy využitý. Přesto Event Loop patří mezi hlavní charakteristiky Javascriptu a díky němu jsou současné moderní webové aplikace plné asynchronních funkcionalit. Pro pochopení Event Loopu a zpracování asynchronního kódu v Javascriptu vůbec, je dobré si připomenout, jak se chová Javascript při plně synchronních operacích. Na to je dobré pak pochopit fungování Javascript enginu.
Javascript engine
Pomyslným srdcem Javascriptu je engine. Hlavní funkce JavaScript enginu jsou parsování, interpretace a exekuce kódu. Parsingem kódu myslíme převod zdrojového kódu JavaScriptu na tzv. abstraktní syntaktický strom (AST), což je struktura, kterou engine dokáže zpracovat. Interpretací myslíme to, že většina moderních enginů nejdříve kód interpretuje a poté kompiluje do strojového kódu (just-in-time compilation – JIT). Exekucí kódu pak myslíme, že po překladu do strojového kódu začne engine provádět instrukce a zajistí běh aplikace.
Příklady populárních JavaScriptových enginů:
- V8: Používá jej Google Chrome a Node.js. Je známý svou rychlostí a efektivitou.
- SpiderMonkey: První JavaScriptový engine, vyvinutý společností Netscape, dnes používán ve Firefoxu.
- JavaScriptCore (aka Nitro): Používá jej Safari a další produkty od Apple.
- Chakra: Používal jej Microsoft Edge (legacy), nyní je nahrazen enginem V8 v novém Edge (Chromium).
JavaScript engine dále poskytuje dvě paměťové struktury: heap a call stack. Jejich rozdíly a fungování by si opět zasloužily samostatný článek na téma Memory managementu v Javascriptu. Základní definici si přečtětě na Wikipedii nebo vypromptujte s Chat GPT.
Co je pro pochopení Event Loopu důležité je, že Javascript Engine jako takový neimplementuje zpracování asynchronního kódu. Veškerou asynchronicitu Javascriptu je nutné implementovat jinde. Kde jinde? To má právě na starosti Javascript Runtime, který k Javascript enginu přidává Web APIs (jsme v browseru), Event loop a čekací fronty Task queue a Microtask queue. Fun fact: pokud by Javascript byl čistě synchronní programovací jazyk, vystačil by si pouze s heap a call stackem a nepotřeboval by Event Loop. Ale to předbíhám.
Heap
Heap co se týče pochopení Event Loopu není až tak důležitý. Heap je paměťová struktura, kde Javascript Engine ukládá komplexní datové typy (Object, Array, Function, Map, Set, WeakMap, WeakSet). Velikost heapu je dána velikostí paměti RAM. Narozdíl od call stacku není heap organizován sekvenčně, je to nelineární paměť. Do heapu se ukládají data, jejichž typ a velikost neznáme během kompilace a mohou se měnit během runtime (přiřazení jiných hodnot properties objektů, vznik nových properties, nových objektů apod.), takže heap je dynamická paměť. Heap je technicky vzato implementace běžného návrhového vzoru dynamické alokace paměti (dynamic memory allocation pattern), což znamená, že vyžaduje Garbage Collector, který čistí alokovanou paměť během runtime podle toho, zda nějaký kus programu vyžaduje (má reference v call stacku) na objekty alokované v heap. Pokud ne, Garbage Collector za normálních okolností pročistí heap a uvolní paměť. Garbage Collector ale není 100 % přesný, tudíž i přes jeho existenci stále může dojít k únikům paměti (memory leaks).
Call Stack
Na druhé straně je call stack jednodušší statická paměť a mnohem zajímavější pro vysvětlení Event Loopu, protože má co dočinění s pořadím toho, jak se v Javascriptu vykonává kód. Javascript engine pracuje s call stackem pouze synchronním způsobem krok za krokem. Po technické stránce je call stack implementací klasického návrhového vzoru Zásobníku (Stacku), kdy poslední přidaný element do paměti je zpracováván jako první (Last In, First Out, LIFO). A co přesně je v call stacku uloženo a určuje pořadí čeho přesně? Pro zjednodušení je možné si představit Javascriptový program jako kolekce funkcí a proměnných a call stack zajišťuje, aby naše funkce byly volány a proměnné byly deklarovány ve správném pořadí a ve správný čas.
Zatímco komplexní datové typy (objekty, pole atd.) jsou uložené v heapu, v call stacku se ukládají pouze reference/pointery do heapu. Co je naopak v call stacku reálně uložené, jsou hodnoty primitivních datových typů (string, integer, boolean, atd.), ale celý exekuční kontext funkce, čímž se myslí vše,, „co je potřeba k volání funkce“. Opět by bylo vhodné se hlouběji ponořit do toho, co všechno exekuční kontext funkce znamená, ale pro zjednodušení exekuční kontext zahrnuje:
- Variable Environment (Prostředí proměnných): Záznam o proměnných a deklaracích funkcí specifických pro danou funkci.
- Lexical Environment (Lexikální prostředí): Odkazy na nadřazený scope (lexikální prostředí) a proměnné definované pomocí let a const.
- Vazba this: Hodnota this v aktuální funkci.
- Specifické detaily volání: Argumenty předané funkci a mechanismus jejího návratu (tzn. místo v programu, kam se má program vrátit po dokončení funkce).
Když nechám Claude vygenerovat náhodný příklad pro ukázání, jak funguje call stack, mějme třeba následující kód:
function multiply(x, y) {
const result = x * y;
return result;
}
function calculate() {
const a = 10;
const b = 20;
const product = multiply(a, b);
console.log(product);
}
calculate();
Co se v takto jednoduchém příkladu přesně děje? Situace je následující: do call stacku se nejdříve uloží funkce calculate(), respektive její celý exekuční kontext. Poté se do call stacku přidá exekuční context funkce multiply(). Jakmile funkce multiply() je zavolána a její výsledek přiřazen do proměnné product, funkce multiply() a jeji exekuční kontext jsou odebrány z call stacku. Pak se do call stacku uloží exekuční kontext console.log(). Po vypsání hodnoty „200“ do konzole prohlížeče se exekuční kontext console.log() z call stacku odebere. Nakonec se odebere i exekuční kontext funkce calculate().
Všimněme si, že v příkladu nejsou globální proměnné a že volání funkce calculate() samo o sobě není zdánlivě v žádné funkce, jejíž exekuční kontext by mohlo být přidán do call stacku. Jak se tedy globální proměnné a volání funkcí v globálním scope dostane do call stacku?
Global Execution Context (EGC)
Vše, co je v globálním scope, lze chápat tak, že se volá uvnitř jedné hlavní funkce, jejíž exekuční kontext má speciální název i chování: global execution context. Upravme výše použitý příklad a přidáme do něj globální proměnné.
var globalA = 'testGlobalA';
const globalB = 'testGlobalB';
function multiply(x, y) {
const result = x * y;
return result;
}
function calculate() {
const a = 10;
const b = 20;
const product = multiply(a, b);
console.log(product);
}
calculate();
Budeme-li brát, že globální kontext našich skriptů se bere jako jakási „main“ funkce, náš skript se bude chovat následovně: do call stacku se přidá globální exekuční kontext, do prostředí proměnných se přidá proměnná „globalA“, do lexikální prostředí se přidá proměnná „globalB“, vytvoří se vazba this, která bude v prostředí prohlížeče odkazovat na globální objekt window. Co se týče objektu window, je dobré připomenout, že zatímco var global A se přidá jako property do objektu window, proměnné definované pomocí const nebo let se přidají do globálního scope/globálního lexikálního prostředí, ale nikoli do globálního objektu window.
Jakmile všechny funkce definované „uvnitř“ globálního exekučního kontextu jsou přidané do call stacku, zpracované, a odebraného z call stacku, zůstává v call stacku pouze globální exekuční kontext coby poslední funkce, a ta je nakonec také z call stacku odebrána, a call stack je prázdný.
Takto to aspoň funguje v principu, pokud veškerý kód, který Javascript engine zpracovává, je synchronní. Pokud v naší aplikaci (čti uvnitř globálního exekučního kontextu) máme nějaké funkce, které jsou zpracovávány asynchronně, situace je značně odlišná.
Řekli jsme, že global execution context (EGC) je jakousi prvotní „main“ funkcí a vstupním bodem aplikace. Zdá se, že musí běžet celou dobu, dokud není celý kód zpracován. Jak je to ale s asynchronními částmi kódu? Ty přece mohou spustit další funkce, které vracejí nové hodnoty a vytváří nové exekuční kontexty, takže globální exekuční kontext musí být v call stacku „nějak“ přítomen. A jak a kdy se volá asynchronní kód, když je call stack zaplněný částečně, nebo když je zcela prázdný? A pokud je call stack prázdný, jak je možné, aby se v Javascriptu něco spustilo, například, když uživatel klikne na tlačítko a spustí se callback event listeneru registrovaného pomocí addEventListener()? A co monitoruje asynchronní callbacky addEventListeneru, když Javascript engine je zcela synchronní, ba ani neobsahuje žádnou addEventListener funkci?
Asynchronie v Javascriptu aneb Web APIs, Task queues, Event loop
Engine programovacího jazyka Javascript neobsahuje ani objekt console ani její metodu console.log(). Což může být překvapení. Faktem je, že pro programátory běžné funkcionality typu setTimeout, addEventListener, operace pro manipulaci DOM, asynchronní síťové requesty fetch nebo XMLHttpRequest jsou součástí nikoli Javascript enginu, ale další komponenty Javascript runtime: Web API.
Web API
Jak přesně Javascript engine dokáže spolupracovat s Web API tak, aby Engine měl přístup k funkcím console.log() nebo setTimeout(), pokud nejsou přímo součástí engine? Javascript Engine (např. V8) má interní registr nativních funkcí, které jsou definovány v ECMAScript specifikaci (např. Math.random(), Array.prototype.map(), Date.now()). Podle toho Engine rozpozná, že setTimeout() není nativní funkcí, a proto si sáhne do globálního scope, respektive objektu window, a zkusí zavolat metodu window.setTimeout(). Javascript runtime totiž ještě před inicializací programu zajistí, že globální exekuční kontext (a tedy lexikální prostředí každé funkce, viz výše) obsahuje objekty, které jsou součástí Web API, např. document, navigator, performance, Intl, crypto, console.
Když jsem se ptal ChatGPT, aby mi vysvětlilo proces spuštění metody console.log(), napsalo mi chybně, že console.log() je součástí samotného Javascript enginu. Když jsem upozornil na to, že tomu tak není, ChatGPT se omluvilo a nabídlo mi přesnější odpověď. Zajímalo mě, proč udělalo chybu. Svedlo to na to, že údajně mezi vývojáři (a tedy i blogery) je rozšířený mentální model fungování Javascriptu, kdy console.log() je synchronní operace, tudíž je zpracována přímo na call stacku (což je pravda), a z toho vyplývá, že pokud se něco zpracovává přímo na call stacku (a nepotřebuje callbacky, které se zpracovávají asynchronně mimo hlavní vlákno Javascriptu, a tedy ani Event loop), musí to být součástí Javascript enginu.
To je chyba. Metoda console.log()neexistuje v Javascript enginu, ten o ní nic neví, kromě toho, že není nativní funkcí. Její konkrétní zpracování předává do Javascript runtime, kde prohlížeč (nebo nodejs) pak provede funkci pomocí nízkoúrovňového kódu v jazyce C++ pomocí C++ bindingů, což jsou vztahy mezi Javascript funkcí a C++ funkcí, která reálně vykonává to, co chceme po Javascript funkci.
Zhruba již víme, jak to funguje, když Javascript engine potřebuje volat ne-nativní funkcí z Web API. Jak je to ale s tou asynchronií a voláním setTimeout(), addEventListener() či fetch() ?
Callbacks, Task Queues obecně
Jako single-threaded jazyk potřebuje Javascript engine delegovat vykonání asychronních operací mimo sebe. Javascript runtime obsahuje vedle Web API další komponenty, které asynchronní operace umožňují: Task queues a Event loop. Všechny asynchronní operace v Javascriptu jsou řešeny pomocí callbacků (a to i await/async, které jsou sugar code využívající Promise-based asynchronicitu). Když Engine narazí na asynchronní operaci, předá její zpracování kompletně na Web API. Pokud se jedná o časovač jako setTimeout(), Web API čeká příslušný počet milisekund; pokud se jedná o network request jako fetch, opět je to na Web API, aby počkalo na příslušnou odpověď tohoto network requestu. Jakmile Web API dokončí zpracování samotné asynchronní operace, vezme programátorem definovaný callback a tuto callback funkci předá do speciální komponenty Javascript runtime, fronty nazvané Task queue anebo Microtask queue.
Callbacky zůstávají ve frontách, dokud není call stack zcela prázdný, respektive, pokud v něm nezůstává pouze globální exekuční kontext (GEK). Jakmile je v call stacku pouze GEK, příchází na řadu další komponenta: Event Loop.
Task queue
Task queue je starší z obou front a v jistém smyslu je stejně stará jako Javascript sám. Už první verze Javascriptu v Netscape Navigatoru uměly setTimeout() a reagovat na události vyvolané uživatelem, a obojí potřebovalo místo, kde callback počká, dokud engine nedokončí to, co právě dělá. Formálně ale Task queue (a Event loop vůbec) popsala až specifikace HTML5 organizace WHATWG, která sjednotila, jak se v tomto ohledu mají chovat všechny prohlížeče.
Důvod vzniku Task queue je ten, se kterým jsme začali: single-threaded engine nesmí na nic čekat. Web API proto řeší čekání mimo engine a do Task queue pak předá už jen hotový callback, který se zpracuje, až na něj přijde řada. Do Task queue (specifikace HTML jim říká tasks, v blozích se často setkáme i s označením macrotasks) patří:
- callbacky časovačů
setTimeout()asetInterval(), - callbacky událostí registrovaných pomocí
addEventListener()(kliknutí, stisk klávesy, scroll…), - síťové události starších API, například
onloaduXMLHttpRequestnebo zprávy zWebSocket, - zprávy mezi okny a workery posílané pomocí
postMessage(), - a vůbec samotné spuštění skriptu z tagu
<script>– i náš globální exekuční kontext tedy začíná svůj život jako task.
Za zmínku stojí dvě věci. Zaprvé, „Task queue“ v jednotném čísle je zjednodušení. Specifikace dovoluje prohlížeči mít front několik (např. zvlášť pro uživatelské interakce a zvlášť pro časovače) a vybírat mezi nimi, takže prohlížeč může dát přednost kliknutí před časovačem. V rámci jedné fronty ale platí metoda FIFO. Zadruhé, Event loop vezme z Task queue vždy jen jeden task a mezi dvěma tasky má prohlížeč příležitost překreslit stránku. I setTimeout(callback, 0) proto neznamená „hned“, ale „nejdříve v příštím kole Event loopu“. Při hluboce vnořeném volání časovačů navíc prohlížeče zvedají minimální prodlevu na 4 ms.
Microtask queue
Microtask queue je o dost mladší. Pojem microtask se objevil kolem roku 2011, kdy inženýři z Mozilly a Chromia navrhovali náhradu za tzv. Mutation Events – starý mechanismus, kterým šlo sledovat změny v DOM. Mutation Events se spouštěly synchronně při každé jednotlivé změně DOM, což bylo pomalé a náchylné k chybám. Nové API MutationObserver mělo změny doručovat asynchronně a najednou v dávce, ale zároveň dříve, než prohlížeč stránku překreslí nebo zpracuje další událost. Task queue na to nestačila, protože mezi dvěma tasky už k překreslení nebo ke zpracování dalšího kliknutí dojít může. Proto vznikla nová fronta, jejíž callbacky se zpracují hned, jakmile se call stack vyprázdní, ještě před dalším taskem.
Druhým a dnes nejznámějším využitím Microtask queue se staly Promises, standardizované v ECMAScript 2015 (ES6). Specifikace ECMAScriptu jim říká „jobs“, prohlížeč je ale řadí právě do Microtask queue. Promises tím dostaly důležitou garanci: callback v .then() se nikdy nezavolá synchronně, ale ani zbytečně pozdě – vždy hned po dokončení aktuálně běžícího kódu. Kolem roku 2019 pak prohlížeče přidaly funkci queueMicrotask(), pomocí které může microtask naplánovat přímo i programátor, bez obcházení přes Promise.
Do Microtask queue patří:
- callbacky Promise:
.then(),.catch(),.finally(), - pokračování async funkce za
await(async/await je postavené nad Promises), - callbacky předané do
queueMicrotask(), - callbacky
MutationObserver.
A ještě jedno důležité pravidlo: zatímco z Task queue bere Event loop vždy v daný okamžik právě jeden task, Microtask queue vyprázdní vždy celou, včetně microtasků, které přibyly během jejího zpracování. Pokud by nějaký microtask přidal další microtask, prohlížeč by se ke kliknutí ani k překreslení stránky už nedostal a stránka by zamrzla – stejně jako při nekonečném cyklu v synchronním kódu.
Event Loop
Mysteriózní Event loop se nyní ukáže jako něco relativně triviálního. Event loop není nic jiného než jedna komponenta v Javascript runtime, která neustále kontroluje, zda call stack je plný. Pokud je prázdný, je dalším úkolem Event loopu rozhodnout, zda vzít metodou FIFO první callback z Task queue nebo Microtask queue. Toť vše.