Jak JavaScript uruchamia twój kod: stos, sterta pamięci, pętla zdarzeń i generatory
Przewodnica po środowisku wykonywania JavaScript: stos wywołań i ramy, referencje w heapie, pętla zdarzeń Node.js, DOM, funkcje zwrotne, obietnice, async/await oraz generatory.
Źródła: JavaScript
Język JavaScript asynchroniczny często zachowuje się w sposób zaskakujący dla programistów: linia z logiem pojawia się „zbyt wcześnie”, instrukcja await wydaje się przeskakiwać do kolejnych kroków, a generator wyświetla tylko połowę swoich komunikatów. Prawie każde z tych zaskoczeń znika, gdy ma się jasny model tego, jak silnik wykonuje kod. Ten artykuł buduje ten model krok po kroku – od stosu wywołań i pamięci heap, przez pętlę zdarzeń Node.js i DOM, aż po obietnice (promises), strukturę async/await oraz generatory – dzięki czemu można przewidzieć wynik każdego fragmentu kodu jeszcze przed jego przeczytaniem.
Części silnika wykonawczego
Język JavaScript działa na pojedynczej głównej wątku, a mimo to programy mogą czekać na timerzy, odpowiedzi sieciowe i pliki bez zatrzymywania się, ponieważ silnik nie działa samodzielnie:
- Stos wywołań, który śledzi funkcje aktualnie wykonywane
Kod jest wykonywany na stosie wywołań, podczas gdy środowisko wykonawcze zajmuje się wolniejszymi operacjami. Gdy jedna z nich zostanie zakończona, jej funkcja powrotna trafia do kolejki, a pętla zdarzeń umieszcza ją na stosie, gdy ten stanie się wolny.
Pętla zdarzeń Node.js w skrócie
Diagram przedstawia cykl Node.js. Najpierw wykonywany jest kod synchroniczny, następnie kolejka z mikrozadaniami, a potem fazy pętli: timerzy (setTimeout, setInterval), czekające callbacki, operacja poll (gdzie wykonywane są większość callbacki plikowych i sieciowych), operacja check (setImmediate) oraz callbacki zamykające.
┌───────────────────┐
│ CALL STACK │
│ JavaScript │
└─────────┬─────────┘
│
│ Synchronous code finishes
▼
┌───────────────────┐
│ MICROTASK QUEUE │
│ │
│ Promise.then() │
│ Promise.catch() │
│ queueMicrotask() │
└─────────┬─────────┘
│
▼
╔═════════════════════════════════════════════╗
║ EVENT LOOP ║
║ ║
║ ┌───────────────┐ ║
║ │ TIMERS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ PENDING │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ POLL │ ║
║ │ I/O │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CHECK │ ║
║ │ setImmediate │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CLOSE │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ └───────────────────────────────┐ ║
║ │ ║
╚══════════════════════════════════════════╪══╝
│
▼
┌───────────────────┐
│ CALL STACK │
│ Execute JS │
└───────────────────┘
Dwa aspekty są istotne. Mikrozadania nie są przetwarzane tylko raz na cykl: Node opróżnia kolejkę mikrozadań (oraz kolejkę process.nextTick przed nią) po każdym callbacku, więc obsługa .then obietnicy wykonywana jest przed następnym callbackiem timera. Przeglądarki stosują zasadę zadania plus mikrozadanie, ale nie posiadają mechanizmów setImmediate ani faz libuv, a ponadto mieszają procesy renderowania. Szczegóły specyficzne dla Node można znaleźć w artykule o tym, jak libuv, pętla zdarzeń i zbiór wątków współpracują ze sobą.
Stos wywołań
Stos wywołań rejestruje, która funkcja jest aktualnie wykonywana oraz które funkcje czekają na jej zakończenie. Jest to struktura typu LIFO – ostatnia dodana funkcja jest pierwszą usuwaną: funkcja wywołana najpóźniej musi zostać zakończona, zanim funkcja poniżej niej będzie mogła kontynuować działanie.
W poniższym przykładzie func1 wywołuje func2, który z kolei wywołuje func3, a ten zapisuje komunikat:
function func1() {
func2();
}
function func2() {
func3();
}
function func3() {
console.log("Hello");
}
func1();
Wywołania kumulują się w tej kolejności:
func1()
↓
func2()
↓
func3()
↓
console.log()
Podobnie jak stos talerzy, console.log jest wykonywane na końcu i pierwsze, a następnie func3, func2 i func1 zwracają się po kolei. Funkcje zwrotne umieszczone w kolejce mogą zostać wykonyane dopiero po opróżnieniu stosu, dlatego długi pętla synchroniczna blokuje wszystko inne.
Heap
Heap to pamięć przeznaczona dla danych, których czas trwania nie jest powiązany z jednym konkretnym wywołaniem, takich jak obiekty, tablice i zamknięcia.
Tworzenie literala obiektu alokuje go właśnie tam:
const user = {
name: "Rayane",
age: 20
};
Zmienna przechowuje jedynie odwołanie do obiektu zapisanego w heapie:
CALL STACK HEAP
┌───────────────┐ ┌────────────────────┐
│ user variable │─────────▶│ { │
│ │ │ name: "Rayane", │
└───────────────┘ │ age: 20 │
│ } │
└────────────────────┘
Przypisanie zmiennej user do innej zmiennej kopiuje odwołanie, a nie sam obiekt, więc zmiany wprowadzone w jednej z nich są widoczne również w drugiej. Gdy nie ma już żadnego dostępnego odwołania do obiektu, kolektor śmieci może go zwolnić; zamknięcie o długim czasie życia, które przechowuje obiekt, utrzymuje go w stanie aktywnym.
Czym kieruje się pętla zdarzeń
Pętla zdarzeń łączy twój kod z zakończeniem pracy asynchronicznej. W Node.js kod jest wykonywany przez V8; wezwanie API Node, takie jak odczyt pliku, trafia do libuv, które przekazuje je systemowi operacyjnemu lub jego puli wątków. Gdy praca zostanie zakończona, funkcja zwrotna staje się dostępna i pętla zdarzeń ostatecznie ją wykona na stosie.
JavaScript code
│
▼
V8 Engine
│
▼
Call Stack
│
▼
Node.js APIs
│
▼
libuv
│
├───────────────┐
▼ ▼
OS Thread Pool
│ │
└───────┬───────┘
▼
Asynchronous operation
│
▼
Callback becomes ready
│
▼
Event Loop
│
▼
Callback Queue
│
▼
Call Stack
Trasa zależy od rodzaju operacji: gniazda sieciowe są zazwyczaj obsługiwane przez system operacyjny za pomocą epoll lub kqueue, natomiast praca z systemem plików oraz niektóre operacje kryptograficzne i DNS wykorzystują pulę wątków.
Ramki stosu
Każde wywołanie dodaje ramkę stosu, która przechowuje to, czego funkcja potrzebuje podczas wykonywania się:
- jej parametry
- jej zmienne lokalne
- adres zwrotu, czyli miejsce, gdzie kontynuuje się wykonywanie w funkcji wywołującej
- inne informacje potrzebne do zarządzania przez silnik
Weźmy funkcję, która dodaje dwa liczby:
function add(a, b) {
const result = a + b;
return result;
}
add(2, 3);
Gdy wykonywana jest instrukcja add(2, 3), jej ramka zawiera argumenty, lokalny wynik oraz odnośnik do funkcji wywołującej:
CALL STACK
┌─────────────────────┐
│ stack frame: add │
│ │
│ a = 2 │
│ b = 3 │
│ result = 5 │
│ return → caller │
└─────────────────────┘
Gdy funkcja zakończy wykonywanie, jej ramka jest usuwana:
CALL STACK
┌─────────────────────┐
│ frame removed │
└─────────────────────┘
Cały cykl życia takiej ramki jest krótki:
Function call
↓
Stack Frame created
↓
Function executes
↓
Function returns
↓
Stack Frame removed
Rekurencja idąca zbyt głęboko gromadzi ramki, aż silnik rzuci błąd RangeError z powodu przekroczenia maksymalnej wielkości stosu wywołań.
DOM
Model obiektów dokumentu to dynamiczny drzewo węzłów przeglądarki utworzone na podstawie dokumentu HTML. Dzięki niemu JavaScript może:
- czytać elementy
- mieniać ich zawartość
- wstawiać nowe elementy
- usuwać istniejące
- modyfikować style i atrybuty
DOM to API przeglądarki, a nie część języka, dlatego document nie istnieje w zwykłym Node.js.
Oto pojedynczy nagłówek w HTML:
<h1 id="title">Hello</h1>
Skrypt może uzyskać odniesienie do tego elementu za pomocą jego id:
const title = document.getElementById("title");
a następnie zastąpić jego tekst:
title.textContent = "Hello Rayane!";
Przed zmianą węzeł tekstu potomny elementu zawierał oryginalną ciąg znaków:
document
│
▼
<h1 id="title">
│
▼
"Hello"
Po wykonaniu tej samej instrukcji przypisania:
title.textContent = "Hello Rayane!";
drzewo odzwierciedla nowy tekst:
document
│
▼
<h1 id="title">
│
▼
"Hello Rayane!"
Następnie przeglądarka ponownie rysuje element. W prawdziwym kodzie należy sprawdzić wartość null, którą zwraca getElementById, gdy nic nie pasuje; ponadto dla niepewnych ciągów znaków lepiej używać textContent zamiast innerHTML, ponieważ ten pierwszy nigdy nie analizuje HTML.
Callbacki
Callback to funkcja przekazywana innej funkcji, aby mogła zostać wywołana później. W tym przykładzie greet jest przekazywany do execute, który wywołuje ją podając imię:
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
Program wyświetla:
Hello Ajami
Wewnątrz execute parametr o nazwie callback odnosi się do greet, a ta linia go wywołuje:
callback("Ajami");
Callback nie jest z natury asynchroniczny; ten konkretny wykonywany jest natychmiast. Staje się asynchroniczny, gdy funkcja odbierająca go wywołuje po jakimś zdarzeniu, na przykład po upływie czasu.
Promesy
Obietnica reprezentuje ostateczny wynik operacji asynchronicznej. Rozpoczynasz operację, kontynuujesz wykonywanie innego kodu, a wynik obsługujesz po tym, jak obietnica zostanie rozstrzygnięta.
fetch to typowa API zwracająca obietnice:
const promise = fetch("https://api.example.com/data");
Reagujesz na wynik, przypisując obsługę zdarzeń:
.then()dla udanego wyniku.catch()w przypadku błędu.finally()do czyszczenia, które wykonywane jest we wszystkich przypadkach
fetch("https://api.example.com/data")
.then((response) => {
console.log("Request finished");
})
.catch((error) => {
console.log("Something went wrong:", error.message);
})
.finally(() => {
console.log("Request completed");
});
Rozpoczęcie żądania nie blokuje; obsługa zdarzeń wykonywana jest później jako mikrozadania. Należy pamiętać, że fetch odrzuca wynik tylko w przypadku błędów sieciowych. Kod 404 lub 500 nadal realizuje obietnicę, więc sam sprawdź wartość response.ok.
Stany obietnicy
Obietnica zawsze znajduje się w jednym z trzech stanów:
PENDING
│
├───────────────┐
▼ ▼
FULFILLED REJECTED
- Czekające: operacja jeszcze się nie zakończyła.
- Zrealizowane: zakończyła się pomyślnie i ma wartość.
- Odrzucone: nie udała się i istnieje powód, zwykle błąd.
Promise zostaje rozstrzygnięte dokładnie raz, a obsługi dodane później nadal otrzymują wynik rozstrzygnięcia.
Async/await
Funkcja async zawsze zwraca promise, a await umożliwia sekwencyjne pisanie kroków asynchronicznych. Ta funkcja loguje informację, czeka na żądanie i ponownie loguje, a po wywołaniu następuje kolejny zapis:
async function leetfinish() {
console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");
Prawdopodobny wynik to:
The function starts
Let's review our code
The request finished
Dlaczego pierwszy log jest wyświetlany jako drugi
Funkcja wyświetla swoją pierwszą wiadomość i dociera do tej linii:
const res = await fetch("https://api.example.com/finish");
fetch zwraca obietnicę w stanie oczekiwania, a await zawiesza funkcję i zwraca kontrolę do wywołującego, nie zamrażając wątku. Wywołujący wyświetla swoją wiadomość; funkcja wznowia działanie dopiero po otrzymaniu odpowiedzi. Dlatego:
The function starts
Let's review our code
The request finished
await wstrzymuje funkcję, a nie wątek. A „prawdopodobnie” ma znaczenie: jeśli żądanie się nie powiedzie, ostatni wiersz nigdy nie zostanie wyświetlony, a nieprzechwycony błąd staje się nierozwiązanym odrzuceniem.
Rozwiązywanie błędów za pomocą try, catch i finally
W modelu async/await używa się zwykłych struktur try/catch/finally. Ten przykład pobiera dane, weryfikuje odpowiedź oraz budżet i zawsze resetuje flagę ładowania. Jego komentarze wskazują na dwa aspekty: błędy HTTP nie powodują odrzucenia, a response.json() również zwraca obietnicę.
let isLoading = true;
async function checkBudget() {
try {
// fetch() returns a Promise.
// await pauses this function until the Promise settles.
const response = await fetch(
"https://api.example.com/khouribga/business/budget"
);
// fetch() does NOT reject automatically for HTTP errors
// such as 404 or 500.
if (!response.ok) {
throw new Error("Failed to fetch business data");
}
// response.json() also returns a Promise.
const business = await response.json();
if (business.budget < 10) {
throw new Error("Budget is too low");
}
console.log("Budget is sufficient:", business.budget);
} catch (error) {
console.log("Something went wrong:", error.message);
} finally {
// This runs whether the request succeeds or fails.
isLoading = false;
console.log("Loading finished:", isLoading);
}
}
checkBudget();
Krok po kroku:
fetch()wysyła żądanie, aawaitwstrzymuje wykonywanie funkcji.- Gdy przychodzi odpowiedź, funkcja kontynuuje swoje działanie.
- Nieudany status HTTP jest przekształcany w błąd rzucony przez program.
- Ciało danych w formacie JSON jest analizowane, a budżet sprawdzany.
- Każdy błąd w bloku
try, w tym problemy sieciowe, trafia do blokucatch. - Blok
finallyzawsze jest wykonywany i usuwa stan ładowania.
Ćwiczenie: wykonywanie funkcji asynchronicznych równolegle
Dobrym ćwiczeniem jest zadanie z LeetCode nr 2721, które wymaga rozpoczęcia kilku operacji jednocześnie zamiast oczekiwania na każdą z nich po kolei, co w praktyce polega na napisaniu własnej wersji funkcji Promise.all.
Generatorzy
Funkcja generatora może być wstrzymywana i kontynuowana, wytwarzając wartości w czasie. Deklarowana jest za pomocą function*, a punkty wstrzymania oznaczane są słowem yield:
function* generatorAjami() {
// Instructions ...
yield i;
// More instructions ...
yield i;
// More instructions ...
}
Porównaj yield z return:
returnzwraca wartość i kończy funkcję.yieldzwraca wartość i wstrzymuje wykonywanie funkcji, zachowując jej stan.
Kroczenie przez generatora
Ten generator wyświetla komunikat przed każdym z trzech instrukcji yield:
function* generatorAjami() {
console.log("First born in casa");
yield 10;
console.log("Second move to Khouribga");
yield 20;
console.log("Third live in Khouribga");
yield 30;
}
Jego wywołanie nie uruchamia żadnej części ciała funkcji:
const generator = generatorAjami();
Otrzymujesz obiekt generatora, iterator, z którego pobierasz wartości za pomocą .next():
console.log(generator.next());
Pierwsze wywołanie wykonuje kod aż do pierwszego yield, wyświetlając pierwszy komunikat:
First born in casa
{ value: 10, done: false }
Wykonywanie zatrzymuje się przy tej instrukcji:
yield 10;
i zwraca 10. Każdy wynik .next() ma dwie właściwości:
value, wartość, która została zwrócona
done – oznacza, czy generator zakończył pracęA więc następuje taki wynik:
{ value: 10, done: false }
oznacza „tu jest 10, a będzie jeszcze więcej”.
Wezwanie .next() po raz drugi:
console.log(generator.next());
przynosi:
Second move to Khouribga
{ value: 20, done: false }
Druga wiadomość pojawia się dopiero teraz, ponieważ generator kontynuuje pracę zaraz po poprzednim yield.
Trzecie wezwanie:
console.log(generator.next());
wyświetla trzecią wiadomość i zwraca 30:
Third live in Khouribga
{ value: 30, done: false }
A czwarte:
console.log(generator.next());
informuje, że nic już nie zostało:
{ value: undefined, done: true }
Bez wyraźnego return ostateczna wartość wynosi undefined.
Czym naprawdę jest generator
Generator nie jest wykonywany kilka razy: jedna jego eksploatacja rozpoczyna się i kończy raz, ale może być wstrzymana i wznowiona, aby zwrócić wiele wartości. Pierwsze wezwanie działa do yield 10:
generator.next()
↓
Execute instructions
↓
yield 10
↓
Pause and return { value: 10, done: false }
Druge wezwanie wznowia się i zatrzymuje przy yield 20:
generator.next()
↓
Resume after yield 10
↓
yield 20
↓
Pause and return { value: 20, done: false }
Trzecie wezwanie wznowia się i zatrzymuje przy yield 30:
generator.next()
↓
Resume after yield 20
↓
yield 30
↓
Pause and return { value: 30, done: false }
Czwarte wezwanie nie znajduje już żadnych instrukcji i zgłasza zakończenie:
generator.next()
↓
No more instructions
↓
Return { value: undefined, done: true }
for...of oraz składnia rozszerzania wywołują .next() za nas i zatrzymują się przy done: true. Aby zapoznać się z więcej wzorcami, patrz funkcje możliwe do wstrzymania i iteracja opóźniona przy użyciu generatorów.
Ćwiczenie: nieskończony generator Fibonacciego
Zadanie LeetCode nr 2648 doskonale pasuje do tego przypadku. Generator wytwarza aktualną wartość, a następnie zwiększa obie bieżące wartości. Pętla while (true) jest bezpieczna, ponieważ każda iteracja zatrzymuje się przy instrukcji yield.
var fibGenerator = function* () {
let a = 0;
let b = 1;
while (true) {
yield a;
const next = a + b;
a = b;
b = next;
}
};
Stan zmienia się w następujący sposób:
a = 0, b = 1
↓
yield 0
↓
a = 1, b = 1
↓
yield 1
↓
a = 1, b = 2
↓
yield 1
↓
a = 2, b = 3
↓
yield 2
Wartości są obliczane w sposób opóźniony, po jednej na każde wezwanie .next(); nigdy nie należy ich rozprzestrzeniać ani iterować bez określenia granicy.
Kompletowanie modelu
Dla Node.js: V8 uruchamia kod z wykorzystaniem swojej półki i stosu, API Node przekazują powolne operacje do libuv z jego pętlą wydarzeń i pulą wątków, a system operacyjny zajmuje się faktyczną komunikacją sieciową.
┌─────────────────────────────────────────────┐
│ NODE.JS │
│ │
│ ┌─────────────┐ │
│ │ V8 │ │
│ │ │ │
│ │ Call Stack │ │
│ │ Heap │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Node.js APIs │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ libuv │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Event │ │ │
│ │ │ Loop │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Thread Pool │ │
│ └───────┬───────┘ │
│ │ │
└───────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ OS │
│ │
│ Networking │
│ Kernel │
│ epoll/kqueue │
└───────────────┘
Główne wnioski
- Żadna operacja z kolejki nie zostanie wykonyana, dopóki stos wezwań nie będzie pusty, więc długotrwałe operacje synchroniczne blokują wszystko.
- Zmienne przechowują odniesienia do obiektów na stosie; nieosiągalne obiekty są zbierane.
fetch odrzuca żądanie tylko w przypadku błędów sieciowych, dlatego należy sprawdzić response.ok.await wstrzymuje funkcję, a nie wątek.yield i kontynuują działanie po wywołaniu .next(), co umożliwia użycie sekwencji opóźnionych, a nawet nieskończonych.Literatura pokrewna
- Jak process.nextTick() cicho głodzi pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić problem głodzenia pętli zdarzeń za pomocą setImmediate().
- Scheduler w React i pętla zdarzeń: Kto naprawdę decyduje o czasie wykonywania zadań — Dowiedz się, jak współpracujący Scheduler w React działa wewnątrz pętli zdarzeń JavaScript, dlaczego transycje mogą zostać przerwane oraz dlaczego żaden scheduler nie może uratować zablokowanej wątku.
- Czytanie stosu wezwań w JavaScript: ramki, przekroczenia i trasy stosu — Praktyczny przewodnik po stosie wezwań w JavaScript: jak dodawane i usuwane są ramki, dlaczego występują przekroczenia stosu, jak naprawić głęboką rekurencję oraz jak czytać trasy stosu.
- Wyjaśnianie JavaScriptu w przeglądarce podczas rozmów kwalifikacyjnych: kolejki, heap, wycieki pamięci, closures — Ustrukturyzowana prezentacja środowiska wykonawczego przeglądarki przygotowująca do rozmów kwalifikacyjnych: stos wywołań, kolejki zadań i mikrozadań, heap oraz zbieranie śmieci, wycieki pamięci, delegacja, prototypy i closures.