Strona główna / Artykuły

Wyjaśnienie stosu wywołań w JavaScript: ramki, przepełnienia, ślady stosu async/await

Jak praca stosu wywołań w JavaScript odbywa się krok po kroku, dlaczego stosy się przepełniają, gdzie znajdują się zamknięcia, oraz jak czytać ślady stosu w trybie async/await w DevTools i Node.js.

Wydawca: REACTAPP.TOP24 min czytania5598 słów

Źródła: Day 6 of Learning JavaScript: JavaScript’s Traffic Cop: How the Call Stack Controls Everything Your Code Does

Klasyczne pytanie z wywiadu przedstawia kilka nawarstwionych wywołań funkcji i pyta, co zostanie wydrukowane oraz w jakiej kolejności. Każda funkcja sama w sobie jest prosta; to sekwencja wywołań sprawia trudności. Ta sekwencja nie opiera się na intuicji – jest określana przez mechanizm zwany stosem wywołań. Gdy już go sobie wyobrażysz, pytania dotyczące wyników stają się prostym ćwiczeniem, przepełnienia stosu przestają być tajemnicze, a ślady błędów zamieniają się w precyzyjne mapy. Ten przewodnik wyjaśnia, jak działa stos, pokazuje program krok po kroku, tłumaczy, dlaczego stosy się przepełniają i jak temu zapobiec, a następnie pokazuje, jak używać narzędzi DevTools oraz śladów stosu do debugowania rzeczywistego kodu.

Dlaczego język jednowątkowy potrzebuje stosu wywołań

Wszystko inne wynika z jednej decyzji projektowej: JavaScript wykonywa twojego kodu na pojedynczej nitce. Jest to celowe, a nie przeoczenie. W każdym momencie silnik wykonywa dokładnie jeden fragment twojego kodu, nigdy dwa równocześnie.

To nie oznacza, że nic nie dzieje się jednocześnie. Timery, żądania sieciowe i operacje plikowe są obsługiwane przez środowisko hosta (Web API przeglądarki lub Node.js), poza silnikiem JavaScript. Przeglądarki delegują tę pracę do lokalnych pul nici lub mechanizmów I/O systemu operacyjnego, takich jak epoll, czasami komunikując się między procesami za pośrednictwem IPC. Gdy praca w tle zostanie zakończona, jej funkcja zwrotna trafia do kolejki zadań. Czekanie odbywa się równocześnie, ale same funkcje zwrotne nadal są wykonywane jedna po drugiej na tej samej, pojedynczej nitce.

Jeśli to wydaje się abstrakcyjne, wyobraź sobie restaurację z dokładnie jednym kucharzem. Kucharz jest szybki i dobrze zorganizowany, ale nie może kroić warzyw i mieszać w garnku jednocześnie. Każde zadanie jest ukończone i odłożone, zanim rozpocznie się następne. Silnik JavaScript działa w ten sam sposób.

Ciekawe pytanie brzmi, co się dzieje, gdy jedno zadanie uruchamia inne. Rozważmy trzy małe funkcje, z których każda wywołuje następną:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

Gdy printSquare wywołuje square, printSquare nie może po prostu kontynuować działania – najpierw potrzebuje wyniku. Dlatego coś musi pamiętać, że printSquare został wstrzymany, dokładnie w którym miejscu, a kontrola musi wrócić do square, a nie do skryptu najwyższego poziomu, po zakończeniu wykonywania multiply.

Tą mechanizmem zarządzania jest stos wywołań. Jest to rejestr silnika, który odnotowuje, co jest aktualnie wykonywane, a co czeka na wznowienie. Bez niego funkcja nie miałaby sposobu, by wrócić we właściwe miejsce, a jej wartość zwracana nie miałaby dokąd trafić. Nie jest to żadna optymalizacja w tle – to właśnie ten mechanizm umożliwia JavaScriptowi wykonywanie wywołań funkcji.

Dwa rodzaje pamięci: stos, sterta i zamknięcia

Częstym pytaniem początkujących jest to: jeśli komputer ma 16 lub 32 GB pamięci RAM, dlaczego program w JavaScriptu wyczerpuje pamięć stosu po zaledwie kilku tysiącach nawarstwionych wywołań? Odpowiedź brzmi, że silniki takie jak V8 dzielą pamięć na dwie zupełnie różne obszary – stos wywołań i stos pamięci.

TWO SIDES OF JAVASCRIPT MEMORY:

CALL STACK (Execution & Order)   MEMORY HEAP (Storage & Data)
┌─────────────────────────────┐  ┌─────────────────────────────┐
│ [Frame: square(5)]          │  │ Object: { name: "Alice" }   │
│   n: 5                      │  │ Array: [10, 20, 30, 40]     │
│   ref: 0x8F3A ──────────────┼─>│ Function: multiply()        │
├─────────────────────────────┤  │ Object: { role: "admin" }   │
│ [Global Execution Context]  │  │ Large Strings & Closures    │
└─────────────────────────────┘  └─────────────────────────────┘
- Fast, ordered (LIFO)           - Large, unstructured
- Small, fixed memory limit      - Gigabytes dynamic storage
- Auto-cleared on return         - Cleaned by Garbage Collect

Stos wywołań jest zoptymalizowany pod kątem szybkości i ścisłego porządku. Jest to mała, o stałej wielkości strefa przechowująca ramy wykonywania, adresy zwrotu oraz proste wartości lokalne. Ponieważ działa według zasady ostatni włożony, pierwszy wyjęty, usunięcie zakończonej ramy jest praktycznie bezkosztowe: silnik po prostu przesuwa wskaźnik stosu.

Heap to znacznie większy, luźno zorganizowany zasób pamięci. Tam znajdują się obiekty, tablice, funkcje i zamknięcia, a stos przechowuje jedynie lekkie odniesienie do nich, przedstawione na diagramie jako adres taki jak 0x8F3A. Pamięć heap jest zwalniana przez zbieracza śmieci, a nie poprzez powrót z funkcji.

To rozdzielenie wyjaśnia paradoks. Przekroczenie limitu pamięci stosu nie oznacza, że komputer ma mało pamięci – oznacza to, że mała, dedykowana obszara używana do śledzenia aktywnych wywołań jest wypełniona. Traktuj ten diagram jako model mentalny: rzeczywiste silniki mają więcej niuansów, na przykład zmienne przechwycone przez zamknięcia są przechowywane w kontekstach przydzielonych z pamięci heap, a nie w samym frame’u.

Kształt stosu: ostatni dodany, pierwszy usunięty

Stos wywołań to struktura typu LIFO: ostatni dodany, pierwszy usunięty. To, co zostało dodane najpóźniej, jest pierwszym, co zostaje usunięte. Wyobraź sobie stos czystych talerzy w kafeterii – świeże talerze trafiają na wierzch, a my zawsze bierzemy ten z góry; nigdy nie bierzemy talerza z środka ani z dołu.

Wywołania funkcji podążają dokładnie za tymi samymi zasadami:

Every time you call a function   → a new "plate" (frame) is placed on TOP
Every time a function finishes   → its "plate" is removed from the TOP
JavaScript always runs the function whose plate is currently on TOP

Co zawiera ramka stosu

Każda taka ramka to ramka stosu, ściśle powiązana z tym, co specyfikacja nazywa kontekstem wykonywania. Koncepcyjnie rejestruje ona pięć elementów:

  1. Funkcję i jej argumenty, takie jak a = 5, b = 5.
  2. Zmienne lokalne zadeklarowane wewnątrz ciała funkcji.
  3. Adres zwrotu, czyli instrukcję w funkcji wywołującej, gdzie wykonywanie musi zostać kontynuowane po zakończeniu tej funkcji.
  4. Odwołanie do łańcucha zakresów, zewnętrznego środowiska leksykalnego, które umożliwia funkcji odczytywanie zmiennych z zakresów otaczających.
  5. Przypisanie this, które obowiązuje podczas wywołania.

Jeden szczegół łatwo umknąć uwadze. Zanim uruchomi się jakakolwiek z twoich funkcji, silnik tworzy globalny kontekst wykonywania skryptu i umieszcza go na początku. Stanowi on dno stosu podczas wykonywania kodu najwyższego poziomu.

WHEN YOUR SCRIPT STARTS:

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Always here first
  └──────────────────────────────────┘

Każde wezwanie jest umieszczane na wierzchu tej sterty, a jedna zasada łączy je wszystkie: silnik zawsze wykonywać będzie ramkę znajdującą się na samym szczycie. Gdy tylko zostanie dodana nowa ramka, ta poniżej zostaje wstrzymana.

Śledzenie programu krok po kroku, ramka po ramce

Prześledźmy printSquare(5) wzdłuż sterty. Dokonanie tego starannie raz zazwyczaj wystarcza, aby proces stał się automatyczny. Oto ponownie ten program:

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function printSquare(n) {
  const result = square(n);
  console.log(result);
}

printSquare(5);

Łańcuch wywołań to printSquare, następnie square w jego obrębie, a potem multiply w obrębie tego ostatniego.

Krok 1: rozpoczynanie skryptu

Zanim zostanie wywołane printSquare(5), silnik rozpoczyna wykonywanie pliku i tworzy globalny kontekst wykonywania.

STEP 1: Script starts running

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Pushed first, always
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

Silnik rejestruje również definicje funkcji multiply, square oraz printSquare. Definicja funkcji nic nie wpływa; tylko jej wywołanie tworzy nową strukturę.

Krok 2: wywołanie printSquare(5)

Silnik dociera do printSquare(5), tworzy dla niej strukturę i umieszcza ją na wierzchu.

STEP 2: printSquare(5) is called

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare znajduje się teraz na górze, więc jest wykonywany. Jego pierwsze zdanie, const result = square(n), to kolejne wezwanie, więc printSquare musi przekazać kontrolę, zanim będzie mógł zrobić cokolwiek innego.

Krok 3: printSquare wywołuje square(5)

Struktura dla square(5) jest umieszczana powyżej printSquare.

STEP 3: square(5) is called inside printSquare

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- PUSHED on top
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting, paused
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

printSquare jest w stanie pauzy, czekając na wartość. Cała uwaga skupiona jest na square, którego jedyna instrukcja, return multiply(n, n), wywołuje kolejne wezwanie.

Krok 4: square wywołuje multiply(5, 5)

Mija jeszcze jedna klatka czasu, a stos osiąga cztery klatki, co stanowi jego najgłębszy punkt w tym programie.

STEP 4: multiply(5, 5) is called inside square -- Stack peak!

  CALL STACK:
  ┌──────────────────────────────────┐
  │  multiply(5, 5)                  │  <-- TOP - runs now
  ├──────────────────────────────────┤
  │  square(5)                       │  <-- Waiting
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  OUTPUT SO FAR: (nothing yet)

multiply nie wywołuje niczego innego. Oblicza 5 * 5, zwraca 25 i kończy swoją pracę, dzięki czemu jego struktura może zostać usunięta.

Krok 5: multiply zwraca wartość, a jego struktura jest usuwana

Gdy funkcja się zakończy, jej struktura jest usuwana z góry listy. Struktura znajdująca się bezpośrednio poniżej, czyli square, ponownie staje się najwyższą i kontynuuje działanie dokładnie tam, gdzie przerwała.

STEP 5: multiply(5, 5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  square(5)                       │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  printSquare(5)                  │  <-- Still waiting
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)

Dla square wyrażenie return multiply(n, n) stało się teraz return 25. Zwraca tę wartość i jest kolejno usuwane.

Krok 6: square zwraca wartość, a printSquare kontynuuje

STEP 6: square(5) returns 25 -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  printSquare(5)                  │  <-- NOW at top, resumes
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RETURN VALUES SO FAR:
  1. multiply(5, 5) --> returned 25 (passed back to square)
  2. square(5)       --> returned 25 (stored as const result inside printSquare)

W funkcji printSquare instrukcja przypisania const result = square(n) kończy się wtedy, gdy result przyjmuje wartość 25. Następna instrukcja, console.log(result), wyświetla wartość 25.

Krok 7: wykonywany jest log, a stos jest opróżniany

STEP 7: console.log(25) executes, then printSquare finishes -- Frame popped

  CALL STACK:
  ┌──────────────────────────────────┐
  │  Global Execution Context (main) │  <-- Program done
  └──────────────────────────────────┘

  FINAL OUTPUT:
  1. multiply(5, 5) --> returned 25
  2. square(5)       --> returned 25
  3. console.log(25) --> PRINTED: 25
  4. printSquare(5)  --> finished (returns undefined implicitly)

printSquare w sposób domyślny zwraca undefined i jest usuwany, pozostawiając jedynie kontekst globalny. Jedyną wartością, która trafia do konsoli, jest 25 zapisane przez printSquare; wartości zwracane przez multiply i square były przekazywane bez żadnych efektów widocznych.

Cały ślad w jednym spojrzeniu

Tych samych siedem kroków, skompresowanych dla szybszego przeglądania:

STEP 1: Script starts
  Stack: Global
  Output: (none)

STEP 2: printSquare(5) called
  Stack: printSquare -> Global
  Output: (none)

STEP 3: square(5) called
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 4: multiply(5, 5) called
  Stack: multiply -> square -> printSquare -> Global
  Output: (none)

STEP 5: multiply returns 25
  Stack: square -> printSquare -> Global
  Output: (none)

STEP 6: square returns 25
  Stack: printSquare -> Global
  Output: (none)

STEP 7: console.log(25), printSquare done
  Stack: Global
  Output: 25

Four rules summarize everything above:

  1. A call pushes a new frame on top. Every time.
  2. A return pops that frame off. Every time.
  3. The topmost frame is the one executing. No exceptions.
  4. The caller is the frame directly beneath. That is where control goes when the current function returns.

When the stack runs out of room: stack overflow

What the error really means

The stack has a fixed size, reserved by the engine. Each frame consumes part of it for arguments, locals, the return address and bookkeeping. Push enough frames without popping them and the space runs out. The engine then throws a RangeError:

RangeError: Maximum call stack size exceeded

To jest przeciążenie stosu. Dobrze znany serwis Q&A dla programistów został właśnie tak nazwany na cześć tego błędu. Zwykłym powodem jest funkcja, która nieustannie wywołuje samą siebie bez żadnego mechanizmu zatrzymania, co stanowi błąd w rekurencji.

Nieskończona rekurencja

Najprostszym możliwym przykładem jest funkcja, której jedynym zadaniem jest wywoływanie samej siebie:

// No base case -- this never stops
function forever() {
  return forever();
}

forever(); // RangeError: Maximum call stack size exceeded

Gdy to zobrazujemy, stos rośnie, aż silnik odmawia dalszej pracy:

STACK GROWING OUT OF CONTROL:

  ┌──────────────────────────────────┐
  │  forever()                       │  <-- frame #10,000+ ... crash!
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  forever()                       │
  ├──────────────────────────────────┤
  │  ...                             │  (thousands more)
  ├──────────────────────────────────┤
  │  forever()                       │  <-- frame #1
  ├──────────────────────────────────┤
  │  Global Execution Context (main) │
  └──────────────────────────────────┘

  RESULT: RangeError: Maximum call stack size exceeded

Każde wywołanie forever() dodaje jeden ramek, a ponieważ nic nie jest nigdy zwracane, nic też nie jest usuwane. W końcu wyczerpuje się zarezerwowane miejsce i zostaje rzucony błąd RangeError.

Sama rekurencja nie jest problemem – to uzasadniona i często elegancka technika. Problemem jest rekurencja bez możliwości wyjścia. Każda funkcja rekurencyjna potrzebuje przypadek bazowy, czyli warunku, przy którym przestaje samą siebie wywoływać i zwraca wartość.

Przypadek bazowy, do którego nigdy nie dochodzi

Częstym problemem jest subtelniejsza wersja, która myli wielu programistów. Poniższa funkcja posiada przypadek bazowy, ale jej krok rekurencyjny zmierza w niewłaściwym kierunku:

function countDown(n) {
  if (n === 0) return; // Base case IS here, but never reached
  countDown(n + 1);    // Bug: going UP, not down -- n never reaches 0
}

countDown(1); // RangeError: Maximum call stack size exceeded

Istnieje warunek ochronny if (n === 0) return, ale wywołanie rekurencyjne przekazuje wartość n + 1. Począwszy od 1, wartości rosną do 2, 3, 4 i dalej, więc nigdy nie osiąga się zera, a stos i tak ulega przepełnieniu. Wersja poprawiona liczy w dół i używa n <= 0 jako zabezpieczenia, dzięki czemu nawet nieoczekiwana ujemna lub ułamkowa wartość początkowa powoduje zakończenie procesu:

function countDown(n) {
  if (n <= 0) return; // Base case: stop when we reach or pass zero
  console.log(n);
  countDown(n - 1);   // Subtracting: we're actually getting closer to 0
}

countDown(5); // Prints: 5, 4, 3, 2, 1

Rekurencja pośrednia

Cykl może również obejmować kilka funkcji. Tutaj ping wywołuje pong, a pong z kolei wywołuje z powrotem ping:

function ping() {
  pong(); // ping calls pong
}

function pong() {
  ping(); // pong calls ping back: circular loop
}

ping(); // RangeError: Maximum call stack size exceeded

Ani jedna z tych funkcji nie wywołuje samej siebie, ale razem tworzą nieskończony cykl wywołań, co skutkuje tym samym przepełnieniem pamięci. Pośrednie cykle są trudniejsze do wykrycia w rzeczywistym kodzie, ponieważ obie części często znajdują się w różnych modułach – na przykład obsługa zdarzenia wyzwalająca aktualizację, która z kolei ponownie uruchamia to samo zdarzenie.

Jak głęboki może być stos?

Maksymalna głębokość zależy od silnika oraz od ilości pamięci potrzebnej przez każdą ramkę. Podane poniżej wartości są jedynie przybliżeniami, a nie gwarancjami; różnią się w zależności od wersji, platformy i ustawień konfiguracyjnych, dlatego jeśli limit ma znaczenie, sprawdź go w swoim środowisku docelowym.

CALL STACK LIMITS BY RUNTIME (APPROXIMATE):

  - Chrome & Node.js (V8 Engine):        ~10,000 to 12,000 frames
  - Safari (JavaScriptCore Engine):       ~40,000 to 45,000 frames
  - Firefox (SpiderMonkey Engine):        ~50,000+ frames

Powodem tej różnicy jest to, że limit ten to w rzeczywistości budżet pamięci, a nie liczba wywołań. Funkcja z wieloma parametrami i zmiennymi lokalnymi tworzy większe ramki, więc przed osiągnięciem tego limitu mieści się ich mniej.

Rozwiązywanie problemu przepełnienia stosu w praktyce

Gdy głęboka rekurencja powoduje awarię w rzeczywistym kodzie, kilka sprawdzonych wzorców może to rozwiązać.

Zastąpienie rekurencji pętlą

Gdy dane wejściowe mogą być duże, należy przepisać rekurencję jako pętlę for lub while. Pętla działa w ramach jednej ramki, więc miliony iteracji w ogóle nie zwiększają głębokości stosu. W poniższym przykładzie rekurencyjne sumowanie prawdopodobnie przepełni się przy głębokości około 20 000 w V8, podczas gdy wersja iteracyjna bez trudu radzi sobie z milionem iteracji:

// Recursive version: crashes if n is 20,000
function sumRecursive(n) {
  if (n === 0) return 0;
  return n + sumRecursive(n - 1);
}

// Iterative version: uses 1 stack frame, handles any n safely
function sumIterative(n) {
  let total = 0;
  for (let i = 1; i <= n; i++) {
    total += i;
  }
  return total;
}

console.log(sumIterative(1000000)); // 500000500000 (instant, zero stack growth)

Trampolining

Jeśli wolisz styl rekurencyjny, trampolin umożliwia jego stosowanie bez zwiększania głębokości stosu. Zamiast wywoływać samego siebie, funkcja rekurencyjna zwraca „thunk” – funkcję bez argumentów opisującą następny krok. Trampolin wykonywa te thunks po kolei w prostym pętli while; każdy thunk zwraca się przed rozpoczęciem następnego, dzięki czemu głębokość stosu pozostaje stała.

function trampoline(fn) {
  return function (...args) {
    let result = fn(...args);
    while (typeof result === "function") {
      result = result();
    }
    return result;
  };
}

// Return a function thunk instead of a direct recursive call
function safeSum(n, acc = 0) {
  if (n === 0) return acc;
  return () => safeSum(n - 1, acc + n);
}

const runSum = trampoline(safeSum);
console.log(runSum(100000)); // 5000050000 (no stack overflow!)

Zwróć uwagę na parametr akumulatora acc w funkcji safeSum: łączna suma jest przenoszona dalej w argumentach, zamiast być przechowywana na stosie. Kompromisem jest dodatkowa alokacja pamięci na funkcję na każdym kroku oraz konwencja mniej oczywista dla czytelników, dlatego mechanizmy tego typu najlepiej stosować tylko w algorytmach rzeczywiście rekurencyjnych.

Unikaj rozpraszania dużych tablic w wywołaniach funkcji

Częste zaskoczenie: rozpraszanie bardzo dużej tablicy w wywołaniu funkcji oznacza przekazywanie każdego elementu jako osobnego argumentu, a te argumenty również wymagają przestrzeni na stosie.

const hugeArray = new Array(150000).fill(1);

// CRASH: RangeError: Maximum call stack size exceeded
// Math.max(...hugeArray);

// SAFE: Process with a loop or reduce
const maxVal = hugeArray.reduce((max, val) => (val > max ? val : max), -Infinity);

Zależnie od silnika, wywołanie takie jak Math.max(...hugeArray) może wywołać ten sam błąd RangeError, gdy tablica zawiera około stu tysięcy elementów lub więcej. Funkcja reduce lub zwykły pętla przetwarzają dane bez takiego ograniczenia.

Nie polegaj na optymalizacji wywołań końcowych

Stare tutoriale oraz specyfikacja ECMAScript 2015 opisują Proper Tail Calls (PTC), często nazywane optymalizacją wywołań końcowych. Chodzi o to, że gdy ostatnią czynnością funkcji w trybie strict jest zwrócenie wyniku innego wywołania, silnik może ponownie wykorzystać obecną strukturę danych zamiast tworzyć nową, co umożliwia nieskończoną rekurencję przy stałej ilości pamięci stosu.

W praktyce, spośród głównych silników, tylko JavaScriptCore w Safari go implementuje. V8, używany przez Chrome i Node.js, oraz SpiderMonkey w Firefox postanowiły nie włączyć go do standardowego JavaScript, głównie dlatego, że usuwanie ramusów niszczy historię, od której zależą ślady stosu i narzędzia debugowania. W kodzie produkcyjnym traktuj wywołania typu „tail call” jako niedostępne i używaj pętli lub mechanizmów typu trampolina do głębokiej rekurencji.

Przewidywanie wyników w pytaniach na rozmowie kwalifikacyjnej

Pytania o wynik to w rzeczywistości pytania o stos wywołań

Pytania typu „Co wyświetli ten kod?” pojawiają się niemal we wszystkich rozmowach kwalifikacyjnych dotyczących JavaScript, a większość z nich to w rzeczywistości ćwiczenia z stosu wywołań w przebraniu. Rozmówca nie sprawdza pamięci – chce sprawdzić, czy rozumiesz kolejność wykonywania instrukcji, a ta kolejność jest w całości określana przez stos. Spróbuj rozwiązać to zadanie przed kontynuowaniem lektury:

function a() {
  console.log("a start");
  b();
  console.log("a end");
}

function b() {
  console.log("b start");
  c();
  console.log("b end");
}

function c() {
  console.log("c");
}

a();

Śledzenie klatka po klatce

Program wyświetla:

a start
b start
c
b end
a end

Wielu ludzi oczekuje, że a end pojawi się wcześniej. Oto dlaczego tak nie jest. Wywołanie a() uruchamia jego strukturę, a a zaczyna działać.

CALL STACK after a() is called:
  ┌──────────────────┐
  │  a()             │  <-- running
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

Pierwsze oświadczenie w a rejestruje rozpoczęcie. Następne to wywołanie b(), więc ramka dla b jest umieszczana na wierzchu.

CALL STACK after b() is called inside a:
  ┌──────────────────┐
  │  b()             │  <-- running now
  ├──────────────────┤
  │  a()             │  <-- paused, waiting for b to finish
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a jest teraz w stanie pauzy, a jego ostatnia instrukcja console.log("a end") jeszcze nie została wykonywana; nie może tego zrobić, dopóki b nie zostanie w pełni zakończone. Wewnątrz b zapisywany jest komunikat b start, a następnie wywoływana jest funkcja c(), co dodaje trzeci kadr.

CALL STACK after c() is called inside b:
  ┌──────────────────┐
  │  c()             │  <-- running now
  ├──────────────────┤
  │  b()             │  <-- paused
  ├──────────────────┤
  │  a()             │  <-- paused
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

c loguje c, nie ma już nic do zrobienia i wraca. Jego ramka jest usuwana, a b znów znajduje się na górze.

CALL STACK after c() returns:
  ┌──────────────────┐
  │  b()             │  <-- resumes now
  ├──────────────────┤
  │  a()             │  <-- still waiting
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

b kontynuuje bezpośrednio po wywołaniu c(), zapisuje b end i zwraca się. Jego struktura jest usuwana, co przywraca a na górę.

CALL STACK after b() returns:
  ┌──────────────────┐
  │  a()             │  <-- resumes now
  ├──────────────────┤
  │  Global Context  │
  └──────────────────┘

a kontynuuje działanie po wywołaniu b(), zapisuje a end, zwraca się i jest usuwany. Pozostaje tylko kontekst globalny. Pełny wynik, w kolejności:

a start
b start
c
b end
a end

Zasada do zapamiętania: wołający nie przechodzi dalej po wykonaniu wywołania, dopóki wywoływana funkcja nie zakończy się całkowicie. a end musiało czekać na zakończenie całego b, które z kolei czekało na zakończenie całego c. a było pierwszą funkcją, która wywołała inne funkcje, i dlatego ostatnią, która zakończyła pracę.

Trójetapowa metoda na każde pytanie dotyczące wyników

  1. Wymień wywołania w kolejności, zaczynając od najzewnętrzniejszych i przechodząc do wewnętrznych. Każde wywołanie wstrzymuje swojego wołającego.
  2. Śledź wierzch stosu. To, co znajduje się na wierzchu, jest wykonywane; wszystko poniżej czeka.
  3. Umieść każdy zapis. Zapisy z wywoływanej funkcji pojawiają się przed każdym zapisem z funkcji wołającej, który następuje po tym wywołaniu.

Wzorzec jest zawsze taki sam: schodzi się aż do najgłębszej funkcji wywołanej, a następnie wychodzi w odwrotnej kolejności. Obejmuje to kod synchroniczny. Funkcje zwrotne z timerów, obietnic i zdarzeń dodają kolejkę na wierzchu tego modelu; nasz artykuł na temat zamknięć i pętli zdarzeń omawia tę kolejną warstwę.

Inspekcja stosu w czasie rzeczywistym w Chrome DevTools

Rysowanie schematów na papierze jest idealne podczas rozmów kwalifikacyjnych, ale na co dzień zazwyczaj obserwuje się stos bezpośrednio w debuggerze przeglądarki.

Zatrzymywanie wykonywania za pomocą instrukcji debugger

Dodanie debugger; do danej linii zatrzymuje jej wykonywanie, gdy tylko DevTools są otwarte. Otwórz je przy użyciu klawisza F12, albo kliknij prawym przyciskiem myszy na stronie, wybierz „Inspekcja” i przejdź do karty „Źródła”.

function calculateTax(subtotal) {
  const rate = 0.08;
  debugger; // Execution pauses right here
  return subtotal * rate;
}

function checkoutCart() {
  const total = 100;
  const tax = calculateTax(total);
  console.log(total + tax);
}

checkoutCart();

Gdy wykonywanie programu zatrzymuje się na wierszu debugger, pasek bocznzy pokazuje aktualną stosową strukturę wraz z zmiennymi w zasięgu dostępu:

DEVTOOLS SOURCES PANEL BREAKDOWN:

  ┌───────────────────────┐  ┌──────────────────────────────────────────────┐
  │ CALL STACK PANE       │  │ SCOPE PANE (Updates based on clicked frame)  │
  ├───────────────────────┤  ├──────────────────────────────────────────────┤
  │ calculateTax  (line 3)│  │ Local Scope:                                 │
  │ checkoutCart  (line 9)│  │   subtotal: 100                              │
  │ (anonymous)   (line 13│  │   rate: 0.08                                 │
  └───────────────────────┘  └──────────────────────────────────────────────┘

W oknie Call Stack na górze znajduje się calculateTax, poniżej go checkoutCart, a na dole anonimowy skrypt najwyższego poziomu – to ta sama struktura, którą obserwowaliśmy wcześniej. Kliknięcie na checkoutCart przenosi edytor na linię 9, a okno Scope przełącza się na zmienne tego kontekstu, pokazując total: 100 dokładnie tak, jak było w momencie wywołania calculateTax. Możliwość inspekcji stanu zatrzymanego wywołującego jest jedną z najprzydatniejszych funkcji debugowania.

Krojenie przez wywołania

Bara narzędzi debuggera oferuje trzy kontrolki do krojenia, które bezpośrednio odpowiadają operacjom stosu:

  1. Step Over (F10) uruchamia bieżącą linię i zatrzymuje się na następnej linii tej samej funkcji. Jeśli linia ta wywołuje inną funkcję, to wywołanie jest wykonane w całości, bez zatrzymywania debuggera w jego obrębie.
  2. Step Into (F11) kontynuuje wykonywanie kodu na bieżącej linii, dodając nową ramkę i zatrzymując się przy pierwszym poleceniu w niej.
  3. Step Out (Shift+F11) uruchamia pozostałą część bieżącej funkcji, usuwa jej ramkę i zatrzymuje się w funkcji wywołującej tuż po zakończeniu wywołania.

Dzięki tym trzem funkcjom debugowanie staje się celowym przeglądaniem stosu wywołań zamiast przypadkowych prób rozwiązania problemu.

Ślady stosu: stos wywołań w momencie błędu

Co mówi nam ślad stosu

Gdy wystąpi błąd i nie zostanie on przechwycony, silnik generuje ślad stosu – zrzut ekranu każdej ramki znajdującej się na stosie w momencie błędu. Pokazuje on, co było wykonywane, kto to uruchomił oraz dokładną linię, w której doszło do awarii. Ślad stosu to po prostu stos wywołań zapisany w formie tekstu, więc wszystko, o czym mowa do tej pory, odnosi się również do jego analizy.

Ślady często wyglądają przerażająco – jak zbiór ścieżek i numerów – ale gdy już poznasz ich strukturę, okazują się najszybszą drogą do znalezienia błędu.

Anatomia śladu

Weźmy mały łańcuch, w którym najgłębsza funkcja rzuca błędem:

function c() {
  throw new Error("something went wrong");
}

function b() {
  c();
}

function a() {
  b();
}

a();

Uruchomiony w Node.js generuje następujący wynik:

Error: something went wrong
    at c (/box/index.js:2:9)
    at b (/box/index.js:6:3)
    at a (/box/index.js:10:3)
    at Object.<anonymous> (/box/index.js:13:1)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)

Pierwszy wiersz podaje typ błędu i jego komunikat, Error oraz something went wrong. To właśnie poszło nie tak. Inne typy, z którymi często się spotykamy, to TypeError, ReferenceError, SyntaxError i RangeError.

Każdy wiersz zaczynający się od at przedstawia jeden poziom stosu z chwili wystąpienia błędu, w następującym formacie:

at functionName (filename:lineNumber:columnNumber)

Zatem at c (/box/index.js:2:9) oznacza, że funkcja c była wykonywana w pliku /box/index.js, na linii 2, w kolumnie 9, co dokładnie odpowiada instrukcji throw.

at Object.<anonymous> wskazuje na kod wykonywany na najwyższym poziomie modułu, poza jakąkolwiek nazwaną funkcją. Tutaj a() jest wywoływane z najwyższego poziomu modułu. W innych częściach śledzenia <anonymous> zazwyczaj oznacza wewnętrzną funkcję zwrotną bez nazwy.

Niższe linie, takie jak Module._compile, Module.load i wrapModuleLoad, to elementy wewnętrzne Node.js, które ładują i wykonują Twój plik. Nie napisałeś ich i zazwyczaj możesz je pominąć.

W jaki sposób czytać

To właśnie kolejność zamówień myli ludzi. Najnowszy ramek jest drukowany pierwszy, więc lista jest ułożona odwrotnie do kolejności, w której dokonano połączeń:

Read this direction (for debugging):
  ↑ TOP = where the error actually happened
  |
  | at c (/box/index.js:2:9)       <-- Error thrown HERE
  | at b (/box/index.js:6:3)       <-- b called c
  | at a (/box/index.js:10:3)      <-- a called b
  | at Object.<anonymous> (...)    <-- global code called a
  ↓
BOTTOM = where execution started

Dolna część śladu to punkt wejścia, gdzie rozpoczęła się eksploatacja. Górna część to miejsce, w którym pojawił się błąd. Eksploatacja przemieszczała się w górę przez poszczególne ramki, aż przerwał ją błąd.

Aby naprawić problem, zacznij od góry i otwórz plik oraz linię pierwszego wpisu typu at – to właśnie tam doszło do awarii. Następnie czytaj w dół, ponieważ prawdziwa przyczyna znajduje się zazwyczaj o jedną lub dwie ramki niżej. Błąd pojawił się w c, ale który wywołujący przekazał c błędne dane? Sprawdź b. A kto w ten sposób wywołał b? Sprawdź a. Górna ramka pokazuje objawy; ramki poniżej zazwyczaj zawierają przyczynę.

Niezawodna procedura dla każdego śladu:

  1. Przeczytaj typ i komunikat błędu, aby zrozumieć, jaki rodzaj awarii wystąpił.
  2. Znajdź pierwszy wiersz at, który odnosi się do twojego własnego kodu, pomijając ramy związane z wykonywaniem i bibliotekami.
  3. Otwórz ten plik i wiersz oraz sprawdź, co się tam dzieje.
  4. Zbadaj ramę znajdującą się tuż poniżej, ponieważ błędna wartość lub nieprawidłowa wywołanie często pochodzą od funkcji, która je wywołała.

Drukowanie stosu na żądanie za pomocą console.trace()

Możesz wydrukować aktualny stos w dowolnym momencie, bez wystąpienia błędu, poprzez wywołanie console.trace():

function deepFunction() {
  console.trace("Who called me?");
  // prints current call stack right here
}

function middle() {
  deepFunction();
}

function outer() {
  middle();
}

outer();

Wynik wygląda tak:

Trace: Who called me?
    at deepFunction (/box/index.js:2:11)
    at middle (/box/index.js:7:3)
    at outer (/box/index.js:11:3)
    at Object.<anonymous> (/box/index.js:15:1)

To jest szczególnie przydatne przy nieznajomym kodzie, gdy nie można stwierdzić, skąd wywoływana jest funkcja: dodaj console.trace(), uruchom scenariusz, a pojawi się pełna ścieżka. Usuń te wywołania przed publikacją.

Przypisz nazwy swoim funkcjom

Prawdziwie anonimowe funkcje pojawiają się jako <anonymous> w zapisach śledzenia, co praktycznie nie daje żadnych punktów do poszukiwań. Poniższe przykłady ilustrują tę różnicę, chociaż pierwszy wymaga korekty.

const doSomething = () => {
  throw new Error("broken");
};

doSomething();
Error: broken
    at <anonymous> (/box/index.js:2:9)  <-- useless for debugging

W obecnych silnikach funkcja strzałkowa przypisana bezpośrednio do zmiennej const otrzymuje nazwę wywnioskowaną z samej zmiennej, dlatego w tym konkretnym przykładzie widnieje at doSomething, a nie <anonymous>. Nieprzydatne oznaczenie <anonymous> pojawia się w przypadku callbacki wplecionej w kod, przekazywanych bezpośrednio do funkcji takich jak map, then czy słuchaczy zdarzeń. Deklaracja z nazwą jest jednoznaczna:

function doSomething() {
  throw new Error("broken");
}

doSomething();
Error: broken
    at doSomething (/box/index.js:2:9)  <-- instantly tells you what crashed

W bazie kodu pełnej callbacki wewnątrz kodu, dziesiątki identycznych ram <anonymous> utrudniają śledzenie śladów działania. Nadawanie nazw callbackom lub przenoszenie ich do funkcji o nazwach sprawia, że ślady są łatwiejsze do odczytania.

Ślady działania Async/await oraz narzędzia produkcyjne w V8 i Node.js

Dla usług backendowych oraz dużych aplikacji frontendowych V8 dostarcza API do kontrolowania sposobu przechwytywania śladów działania.

Przechwytywanie większej liczby ram za pomocą Error.stackTraceLimit

Domyślnie V8 rejestruje maksymalnie 10 ram w error.stack. W kodzie o wielu warstwach, takim jak łańcuchy zapytań w Express lub NestJS lub łańcuchy middleware w Redux, te dziesięć ram może zostać wykorzystanych przez kod frameworku, zanim pojawią się twoje własne funkcje. Możesz podnieść ten limit globalnie:

// Capture up to 50 frames for deep diagnostics
Error.stackTraceLimit = 50;

// Or capture everything without any limit:
// Error.stackTraceLimit = Infinity;

To ustawienie kontroluje jedynie liczbę klatek rejestrowanych w śladzie. Nie ma wpływu na rzeczywistą maksymalną głębokość stosu, a bardzo wysokie wartości, szczególnie Infinity, sprawiają, że powstawanie każdego błędu jest droższe, dlatego w środowisku produkcyjnym należy wybrać umiarkowaną liczbę.

Czystsze błędy niestandardowe za pomocą Error.captureStackTrace

Gdy definiujesz własne klasy błędów, sam konstruktor zazwyczaj pojawiałby się w śladzie. Error.captureStackTrace umożliwia jego wykluczenie, dzięki czemu ślad zaczyna się od momentu, w którym błąd faktycznie wystąpił:

class DatabaseError extends Error {
  constructor(message) {
    super(message);
    this.name = "DatabaseError";
    // Omit the constructor call itself from the stack trace
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DatabaseError);
    }
  }
}

Drugi argument mówi V8, aby pominąć tę funkcję oraz wszystkie frame powyżej niej. Warunek if zapewnia działanie klasy w silnikach, które nie posiadają tej specyficznej dla V8 API.

Ślady stosu asynchroniczne z async/await

W przeszłości kod asynchroniczny generował skrócone ślady. Funkcja zwrotna z setTimeout lub obietnica (promise) wykonywana jest na nowym stosie długo po tym, jak pierwotny wywołujący zakończył działanie, więc łańcuch wywołujących znika.

Współczesny V8, zarówno w Chrome, jak i Node.js, oferuje bezpłatne ślady stosu asynchroniczne dla async/await. Jeśli coś wywoła błąd po wznowieniu działania po await, V8 odbudowuje łańcuch funkcji oczekujących tylko wtedy, gdy ślad jest rzeczywiście potrzebny, oznaczając te frame jako async:

Error: Query failed
    at executeQuery (db.js:14:11)
    at async getUserData (user.js:22:9)
    at async handleRequest (server.js:45:5)

Korzyść ta dotyczy natywnych struktur async/await. Długie, ręcznie pisane łańcuchy funkcji .then() oraz kod oparty na funkcjach zwrotnych nie korzystają z takiej samej możliwości rekonstrukcji, co jest kolejnym powodem, by preferować await w kodzie, który zamierzasz debugować.

Stos stack w kilku zasadach

  • JavaScript wykonywa twojego kodu na jednym wątku. Środowisko hostowe obsługuje prace w tle równocześnie, ale każda funkcja zwrotna czeka na swoją kolej na tym wątku.
  • Stos stack to mała, szybka struktura o stałej pojemności typu LIFO, która rejestruje, która funkcja jest aktualnie wykonywana oraz gdzie każdy wywołujący ma wznowić działanie; obiekty i zamknięcia znajdują się w pamięci heap i są odwoływane z stosu.
  • Każde wywołanie dodaje nowy element do stosu, a powrót usuwa go. Element na szczycie stosu jest zawsze wykonywany, natomiast wszystkie elementy poniżej są w stanie pauzy.
  • Przekroczenie limitu pamięci stosu, zgłaszane jako RangeError: Maximum call stack size exceeded, oznacza wyczerpanie budżetu pamięci stosu, zwykle z powodu rekurencji bez dostępnego przypadku bazowego, bezpośredniego lub pośredniego.
  • W przypadku głębokiej rekurencji lepiej używać pętli lub mechanizmów typu trampolina oraz unikać przekazywania bardzo dużych tablic do wywołań funkcji. Odpowiednie wywołania ogonowe są dostępne tylko w Safari, więc nie należy na nie polegać.
  • W narzędziach DevTools opcje Step Over, Step Into i Step Out odpowiadają odpowiednio wywołaniu funkcji, wejściu do jej ramy i wyjściu z niej.
  • Ślad stosu to reprezentacja stosu w momencie wystąpienia błędu: jego wierzchołek to miejsce, w którym pojawił się błąd, dno to punkt wejścia, a przyczyna zazwyczaj znajduje się jedną lub dwie ramy poniżej wierzchołka.
  • console.trace(), funkcje nazwane, Error.stackTraceLimit, Error.captureStackTrace oraz wbudowane async/await sprawiają, że informacje o śladach działania są bardziej szczegółowe.
  • Backoffy takie jak obsługi setTimeout uruchamiają się dopiero po całkowitym opróżnieniu stosu, dlatego właśnie stos wywołań stanowi podstawę zrozumienia asynchronicznego JavaScriptu.

Dlaczego to się opłaca

Wiele błędów, które zajmują godziny na rozwiązanie, ma u swojego podstawy problemy z stosem: funkcja zwracająca wynik wcześniej niż oczekiwano, funkcja powrotna wykonywana w nieoczekiwanej kolejności, wartość undefined, gdy nie powinna nim być. Rozwojowcy, którzy szybko rozwiązują takie problemy, nie są mądrzejsi; po prostu poznali strukturę stosu i jego ślady raz i teraz od razu przechodzą do odpowiedniego kontekstu wykonywania kodu. Ślad stosu to mechaniczny zapis tego, co faktycznie zostało wykonywane, a gdy mu zaufasz, debugowanie staje się znacznie łatwiejsze.