Przejdź do treści

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

Profile photo of Adrian Zawadzki

Adrian Zawadzki

8 min czytania

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:

  1. Wątek czyta linię const x = 10 i wpisuje do pamięci: x = 10.
  2. 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.
  3. 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):

  1. Tworzy się nowy lokalny kontekst dla double.
  2. Parametr n trafia do lokalnej pamięci tego kontekstu z wartością 10 (z argumentu).
  3. Lokalny wątek wykonuje return n * 2, czyli zwraca 20.
  4. Po return lokalny kontekst zostaje usunięty.
  5. Wartość 20 wraca do globalnej pamięci jako y.

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 return to 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.

Krok 0 / 7Start: program wystartował, na stacku tylko globalny kontekst wykonawczy.
kod
function square(n) {  return n * n;} function sumOfSquares(a, b) {  const sa = square(a);  const sb = square(b);  return sa + sb;} sumOfSquares(3, 4);
↑ szczyt (TOP)
globalny kontekst wykonawczy
brak lokalnych kontekstów
↓ podstawa
wynik·

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 return lokalny 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 swoje this. 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ą this leksykalnie 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 return to 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…

Dodaj komentarz