Execution Context i Call Stack - jak JavaScript wykonuje twój kod

Każda linia JS musi gdzieś się wykonać, każda zmienna gdzieś żyć, każde wywołanie funkcji po sobie wrócić. Silnik trzyma to wszystko w dwóch strukturach - execution context (gdzie aktualnie jestem) i call stack (skąd przyszedłem). To one stoją za closure, this i asynchronicznością.
Każda linia JS, którą piszesz, musi gdzieś się wykonać. Każda zmienna - gdzieś żyć. Każde wywołanie funkcji - po jakimś czasie wrócić w to miejsce, z którego zostało wywołane. Silnik JS trzyma to wszystko w dwóch strukturach: execution context (kontekst wykonawczy) i call stack.
Na co dzień nie musisz o nich myśleć - przez większość kodu intuicja wystarczy. Ale na nich opiera się sporo rzeczy w JS, m.in.:
- closure - funkcją „pamiętającą” zmienne ze swojego dawnego kontekstu;
this- którego wartość zależy od tego, jak funkcja została wywołana (cztery reguły bindingu plus arrow functions);- „tylko jedno na raz” charakterem JS - bo stack jest jeden i wątek wykonawczy też jeden.
To nie jest pełna lista - cała asynchroniczność (event loop, promisy, async/await), hoisting, stack trace przy błędach, generatory, a nawet to, jak działa class w ES2015, też wynika z tego mechanizmu. Gdy zobaczysz go od środka, reszta układa się w spójną całość.
#Co tworzy się, gdy odpalasz plik JS
Gdy uruchamiasz plik JS (node script.js, otwarcie HTML w przeglądarce), silnik tworzy globalny kontekst wykonawczy - Global Execution Context. Powstaje raz, na początku, i żyje przez cały czas działania programu.
Składa się z dwóch rzeczy:
- Wątek wykonawczy (thread of execution) - to on czyta kod linia po linii i go wykonuje. Synchronicznie, jeden krok naraz, bez przeplatania.
- Pamięć (variable environment) - magazyn na zmienne i deklaracje funkcji, które żyją w globalu.
Spójrz na ten kod:
const x = 10;
function double(n) {
return n * 2;
}
const y = double(x);Co dzieje się w globalnym kontekście wykonawczym, krok po kroku:
- Wątek czyta linię
const x = 10i wpisuje do pamięci:x = 10. - Wątek czyta deklarację
function double(n) { ... }. Tworzy funkcję jako obiekt i wpisuje go do pamięci pod nazwądouble. Nie wchodzi do środka funkcji - po prostu rejestruje, że taka istnieje. - Wątek czyta linię
const y = double(x). Widzi wywołanie funkcji - i tu zaczyna się ciekawa część.
#Wywołanie funkcji = nowy lokalny kontekst
Kiedy wywołujesz funkcję, silnik tworzy nowy kontekst wykonawczy - dla tej konkretnej funkcji, z tymi konkretnymi argumentami. Lokalny kontekst ma to samo, co globalny, tylko w mniejszej skali:
- lokalną pamięć - tu trafiają parametry i zmienne lokalne funkcji;
- lokalny wątek - który teraz wykonuje kod wewnątrz funkcji, linia po linii.
Krok po kroku dla double(10):
- Tworzy się nowy lokalny kontekst dla
double. - Parametr
ntrafia do lokalnej pamięci tego kontekstu z wartością10(z argumentu). - Lokalny wątek wykonuje
return n * 2, czyli zwraca20. - Po
returnlokalny kontekst zostaje usunięty. - Wartość
20wraca do globalnej pamięci jakoy.
I dopiero teraz globalny wątek podejmuje wykonywanie kolejnych linii.
Dopóki lokalny kontekst żyje, globalny wątek jest wstrzymany. Silnik nie wraca do głównego biegu kodu, dopóki funkcja nie zwróci wartości. To dlatego mówimy, że JS wykonuje się synchronicznie - wątek jest w jednym miejscu naraz.
#Po co Call Stack
Wyobraź sobie funkcję, która wywołuje inną funkcję. Albo trzecią. Albo rekurencyjną:
function square(n) {
return n * n;
}
function sumOfSquares(a, b) {
const sa = square(a);
const sb = square(b);
return sa + sb;
}
sumOfSquares(3, 4);Skąd silnik wie:
- gdzie aktualnie jest wątek wykonawczy?
- do którego kontekstu wrócić, gdy aktualna funkcja zwróci wartość?
Odpowiedzią jest call stack - struktura danych, na której silnik trzyma listę aktywnych kontekstów. „Stack” to znaczy:
- każdy nowy kontekst wykonawczy to push - dorzucenie na górę;
- każdy
returnto pop - zdjęcie z góry; - na dole zawsze siedzi globalny kontekst (do końca działania programu);
- na szczycie - kontekst, który aktualnie się wykonuje.
LIFO, czyli last in, first out: ostatni włożony kontekst jest pierwszym, który zostaje zdjęty.
Tak wygląda to krok po kroku dla sumOfSquares(3, 4) z naszego przykładu. Każde wciśnięcie Dalej to jedna akcja silnika - push przy wywołaniu funkcji, pop przy return. Aktywny kontekst (TOP) jest zawsze podświetlony. Zobacz, jak razem z nim przesuwa się aktywna linia kodu po lewej i jak rośnie lokalna pamięć ramki, kiedy do sumOfSquares dolatuje kolejny wynik z square.
function square(n) { return n * n;} function sumOfSquares(a, b) { const sa = square(a); const sb = square(b); return sa + sb;} sumOfSquares(3, 4);
Cokolwiek jest na szczycie stacku - tam aktualnie siedzi wątek wykonawczy. Tam wykonują się linie kodu. Pozostałe konteksty „czekają w kolejce” na powrót.
#Single-threaded - jeden wątek, jeden stack
JavaScript ma jeden wątek wykonawczy. Jedna aplikacja JS = jeden call stack. Linia po linii, synchronicznie, jeden krok naraz.
To dlatego mówi się, że JS jest blokujący. Jeśli funkcja zajmuje 5 sekund (np. ciężka pętla, parsowanie wielkiego JSON-a), cała aplikacja stoi przez te 5 sekund. Wątek nie może robić nic innego - wszystkie inne callbacki, kliknięcia, requesty czekają, aż obecna funkcja skończy.
Wyjątkiem są web workers. Każdy worker dostaje:
- własny wątek wykonawczy,
- własny call stack,
- własną pamięć.
Worker żyje w izolacji - nie widzi zmiennych głównego wątku, nie ma dostępu do DOM-u. Komunikuje się z main threadem przez wiadomości (postMessage / onmessage). To dwa równoległe światy, które rozmawiają jak dwa programy przez kanał.
W praktyce: dla większości kodu JS to bez różnicy. Jesteś na jednym wątku, jednym stacku, i piszesz tak, jakby świat był synchroniczny.
#Co z tego wynika
Ten prosty mechanizm - kontekst wykonawczy plus call stack plus jeden wątek - jest fundamentem trzech pozornie różnych spraw:
-
Closure. Po
returnlokalny kontekst normalnie zostaje usunięty. Ale jeśli funkcja zwróciła inną funkcję, która „trzyma” zmienne ze swojego rodzica - te zmienne nie giną. Silnik je zachowuje, bo ktoś jeszcze ich potrzebuje. To jest closure. -
this. Każdy kontekst wykonawczy ma swojethis. Jego wartość zależy od jak funkcja została wywołana (a nie gdzie zdefiniowana) - cztery reguły bindingu (default / implicit / explicit / new) plus arrow functions, które wyłamują się z systemu i czerpiąthisleksykalnie z otoczenia. -
Asynchroniczność. Skoro wątek jest jeden i synchroniczny, to gdzie „dzieją się”
setTimeout,fetch, promisy? Odpowiedzią jest event loop: callbacki czekają poza stackiem (w specjalnej kolejce - microtask lub task queue) i są wkładane na stack dopiero, gdy stack jest pusty. Wszystko, co wygląda na „równoległe” w JS, jest w rzeczywistości sekwencyjne - tylko wątek przeplata zadania.
Te trzy konsekwencje wyglądają na osobne tematy, ale to po prostu różne sposoby wykorzystania tego samego silnika.
#Puenta
Sprowadza się to do dwóch pojęć:
- Execution context - pojemnik na to, co aktualnie się wykonuje (wątek + pamięć). Jeden globalny, plus jeden lokalny dla każdej aktywnej funkcji.
- Call stack - lista tych pojemników, ułożonych od najstarszego (globalny, na dole) do aktualnie wykonywanego (na szczycie). Każde wywołanie funkcji to push, każdy
returnto pop.
Każda linia JS, którą piszesz, jest wykonywana w jakimś kontekście. Każde wywołanie funkcji tworzy nowy. Każdy return ten kontekst zamyka. Stack pamięta drogę powrotną.
Reszta - closure, this, asynchroniczność - to konsekwencje tego prostego mechanizmu.
Komentarze
Wczytywanie komentarzy…