Галоўная / Артыкулы

Паспрацаванне з стакам вызваў у JavaScript: кадры, переплнення, следы стака async/await

Як працюе стак пакетаў JavaScript крок за крокам, чаму стакі выходзяць за межы, дзе знаходзяцца клозуры, і як чытаць следы стака для async/await у DevTools і Node.js.

Выдавец: REACTAPP.TOP24 хв чытання5598 слоў

Крыніцы: Day 6 of Learning JavaScript: JavaScript’s Traffic Cop: How the Call Stack Controls Everything Your Code Does

Класычныя запитанні пад час адзінкавання паказваюць калькі звіснаванняяў усередзіні іншых функцый і запытаюць, што будзе выведзена і ў якой спраўе. Кожная функцыя сама па сабе простая; тое, што ускладнюе розумэнне, — цэлая спраўа. Гэтая спраўа не залежыць ад інтуіцыі. Яе вялічыну вялікі механізм — стак звіснаванняяў, і калі вы зможаце яго уявіць, запытанні па выведзе няхай стануць механічным завданнем, перепады стака больш не будуць таёмніцай, а следы аблыкаў ператворыцца ў точныя карты. У гэтым кярыянтэ разбіраецца, як працуе стак, праглядаецца програма кадр за кадрам, пояснюецца, чаму стакі перепадаюць і як гэтага ухіліцца, а пасля паказваецца, як выкарыстоўваць інструменты разрабоцтва і следы стака для дыбаггінгу рэальнага коду.

Чаму мова з аднам вяселам патрэбуе стака звіснаванняяў

Усе інша вынікае з аднаго рашэння па дизайну: JavaScript выконвае ваш код на аднай нітку. Гэта намеральна практыка, а не прачына-памылка. У будзь-ям момантэ працоўны механізм выконвае толькі адну частку вашага коду, ніколі два паралельна.

Гэта не значыць, што нічога не выконваецца адночасова. Таймеры, запыты сеті і операцыі з файламі обрабоўваюцца сяродовым сераўерам (Web API прыборача або Node.js), за межамі працоўнага механізма JavaScript. Прыборачы перадаюць гэту роботу на аблака натыўных нітак або механізмы I/O операцыйной системы, такія як epoll, іноды ведучы комунікацію за межамі процэсаў через IPC. Калі фонавая робота завершыцца, яе калбэк падае ў очакальныя ряды заданняў. Очакванне відбываецца адночасова, але самыя калбэкі все равно выконваюцца адны за іншым на той самай аднай нітку.

Якщо гэта здаецца абстрактным, уявіце рэсторан з адним-ўсьмі кухаром. Кухар ўмелы і шырокі, але не можа адночасна нарэзаць вярзушкі і мешаць у кастрылі. Кожная задача завершаецца і застаеяеся у боку, прычымо да пачатку наступной. Двіжак JavaScript працуе так сама.

Інтэресны вопыт — што будзе, калі адна задача запускае іншую. Разглянем тры маленькія функцыі, калі кожная з яных вызывае наступную:

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);

Калі printSquare вызывае square, printSquare не можа проста так продаваць; яму спачатку трэба рэзультат. Таму якась частка павінна памятаць, што printSquare зупініўся, дзе саме зупініўся, і што кантроль павінен вернуцца да square, а не да скрыпты вышэйшага рэвэлю, калі multiply завершыцца.

Гэтае ведэнне запісаў — цэх вызываў. Цэ гэтае «запіснік» двіжка пра тое, што зараз выкананае і што чакае на продажчык. Без яго функцыя не мягча вернуцца на правы месца, а ўсё, што яна вяртае, не мягча пайсці нікуды. Цэ не якаясь фонавая оптымізацыя; цэ ўласна механізм, праз які JavaScript выкананае вызывы функцый.

Два відзэны памяці: стэк, хіп і закрыцці

Частым пытаннем у пачатківцў ёсць такое: якшо апаратызаваны працоўнік мае 16 або 32 ГБ памяці RAM, чаму програма на JavaScript выкарыстоўвае весь стэк пасля колькіх тысяч вкладзеных вызваў? Адказ такі: такія двайчыкі, як V8, дзеляць памяць на два абсалютна разныя регіёны — стэк вызваў і хіп.

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

Стакан вызваў оптымізаваны для шыроцы і строгага порядку. Це малая, фіксаванага розмеру зона, якая храніць кадры выканання, адресы вярнення і прымітныя локальныя значэнні. Пакалі ён працюе за прынцыпам «першы ўвайшоў — першы выйшоў», адмова ад завершанага кадра ўзнікла практычна без затрат: дварчык проста перасувае паказвік стакана.

Хіп — гэта значна большы, слабка арганізаваны ресурс. Там знаходзяцца об’екты, масівы, функцыі і замкнутыя функцыі, а стакан храніць толькі лёгкі канектар да ўсіх іх, які на дыяграме паказаны як адреса на кшталт 0x8F3A. Памяць хіпа перыявляецца за дапамогою збірача сметлі, а не праз вярненне з функцыі.

Гэтыя падзелы адказваюц на парадокс. Занятасць стака не значыць, што у вашага комп’ютера няма памяці. Це значыць, што малая, спецыяльна зона, якая выкарыстоўваецца для фіксаціі актыўных вызываў, запоўнена. Спрыйміце дыяграму як ментальную модель: рэальныя двыгуны маюць большую нюансавасць, напрыклад, зменныя, зафіксаваныя праз закрыцці, зберагаюцца ў контэкстах, выдырваных з хіпа, а не ў самай рамцы.

Форма стака: «пасляльгаванне»

Стак вызываў — это структура LIFO: «пасляльгаванне». Тое, што было дадаўнае, ўсё пачаткова ўдаляецца. Уявіце стак чыстых тароў у кафетэрыі: свежыя тароўкі ставяцца зверху, і вы завжды беразеце тую, што зверху; вы ніколі не беразеце тую, што зсередзіны чы знізу.

Вызывы функцыйяў таксама павінны выконвацца за аднаковымі правіламі:

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

Што містіць кадр стака

Кожны кадр стака ўзаўсёды зв’язаны з тым, што спецыфікацыя называе контекстам выканання. Канцэптуальна ён фіксуе пяць элементаў:

  1. Функцыя і яе аргументы, напрыклад a = 5, b = 5.
  2. Локальныя зменныя, адзначаныя ў тэле функцыі.
  3. Адреса вярнення — інструкцыя ў функцыі-заклекчыку, дзе неабходна продакрацаваць выкананне пасля завершэння ціяй функцыі.
  4. Адреса ланцуга областей дасягамості — зовнішня лексычная среда, якая дазволяе функцыі чытаць зменныя з абгорткаючых областей.
  5. Зв’язка з this, якая дзейсніцца пад час вызову.

Есць адна дробязга, якую лёгка працязнаць. Перш чым запускаецца хоць якая з вашых функцый, двайнер стварае глобальны контэкст выканання для скрыпту і спачатку яго дадзеў. Ён становіць ніжню частку стака пад час выканання вашага коду высокага рэвэля.

WHEN YOUR SCRIPT STARTS:

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

Кожны выклік размешчаецца на вершыне гэтага стака, і адна правіла з’едынае ўсё гэта: энжын завжды выконвае фрэйм, які знаходзіцца на самай вершыне. Калі толькі додаецца новы фрэйм, той, які знаходзіцца пад ям, зупініцца.

Аналіз програмы па фрэймам

Давайце праследаваць printSquare(5) через стак. Аккуратна робота з гэтым разам зазвычай дастаць можлівасць автаматызаваць цей процес. Ось програма знову:

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);

Ланцюг вызваў ўключае printSquare, пасля чаго square усередзіні яго, а потым multiply усередзіні таго.

Шаг 1: запуск скрыпты

Перш чым запускаецца printSquare(5), двайнік пачынае выконванне файла і стварае глобальны контекст выконвання.

STEP 1: Script starts running

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

  OUTPUT SO FAR: (nothing yet)

Энжын таксама фіксуе значэнні функцый multiply, square і printSquare. Адзначэнне функцыі нічога не стварае; толькі ўвокалванне яе стварае кантэйнер.

Шаг 2: вызов printSquare(5)

Энжын дасягае printSquare(5), стварае для яе кантэйнер і размешчае яго зверху.

STEP 2: printSquare(5) is called

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

  OUTPUT SO FAR: (nothing yet)

printSquare зараз знаходзіцца на верхнім роўні, таму ён выконваецца. Його першая інструкцыя, const result = square(n), являе сабо ўтварэнне ўтрамбаванага калена, таму printSquare павінен перадаць керування, перш чым зможа зрабіць ўсё іншае.

Крок 3: printSquare вызывае square(5)

Фрэйм для square(5) ставіцца вышэй за 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 зупініўся, чакаючы на значэння. Вся увага зосераджана на square, чыя ўсьмоўнае запаведзенне return multiply(n, n) спрычынае ўтрохтуючы вызов.

Крок 4: square вызывае multiply(5, 5)

Пройшоў ўсьмоўна кадр, і стак прайшоў да чатырох кадраў, чыяя глыбокае значэнне ў гэтым працэсе.

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 не вызвае нічога іншага. Ён вылічвае 5 * 5, вяртае 25 і завершаецца, таму яго рамку можна адзёрваць.

Шаг 5: multiply вяртае рэзультат, і яго рамка адзёрвана

Калі функцыя завершаецца, яе рамка адзёрвана з верху. Рамка, якая знаходзіцца прымусова пад яю, тут square, знову стае верхней і продовжвае работу там, дзе зупінілася.

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)

Для square выраз return multiply(n, n) теперь стаў return 25. Ён вяртае гэю значэнне і пасля таго выкарыстоўваецца далей.

Шаг 6: square вяртае значэнне і printSquare працюе далей

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)

У функцыі printSquare асацыяцыя const result = square(n) завершаецца так, што result равнае 25. Наступная запаведзь console.log(result) выводзіць 25.

Шаг 7: вывод дадзеных і распакоўка стака

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 непасуюча вяртае undefined і ён усунуты, залишаючы толькі глобальны контэкст. Ядзейная значэнне, якае практычна падае ў консоль, — это 25, якое запісвае printSquare; значэнні, якія вяртаюць multiply і square, былі пераданы без жадных адзначэнняў.

Весь перадак у аднам відзоре

Тыя ж семь крокаў, стиснутыя для шыранага адзору:

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

Чатыры правілы падсумавляюць усё вышэй сказанае:

  1. Выклік дадзе новую рамку на верхнюю пазуху. І ўсё час.
  2. Працэўнае завершэнне зніме тую рамку з пазухі. І ўсё час.
  3. Верхняя рамка — гэта тая, якая выконваецца. Няма выключэнняў.
  4. Выклікач — гэта рамка, якая знаходзіцца адразу пад нею. Сюды пераходзіць кантроль, калі тэчная функцыя завершае свою роботу.

Калі у стаке заканчываецца месца: переплненне стака

Што на самай працэ ў значэнні гэтай памылкі

Стак мае фіксаваны размер, выдзеленый двойчыкам. Кожная рамка займае частку яго для аргументаў, локальных зменных, адреса вярнення і ведэння статыстыкі. Якщо дадаць достатню колькість рамакоў, не знімаючы іх, месца заканчываецца. Тады двойчык выклікае памылку RangeError:

RangeError: Maximum call stack size exceeded

Это ўзбіянне стака. Знаменітны сайт з пытаннямі та адказамі для працоўнікаў програмнага забэрэжчыка насправды названы так само як і гэтая памылка. Зазвычай прычыной ёй становіцца функцыя, якая продовжае вызываць сябе без можлівасці ўпинку, што ўсё роўнася да неправільнай рекурсіі.

Бесканечная рекурсія

Найпростейшым прыкладам є функцыя, якая выконвае толькі тое, што вызывае сябе:

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

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

У візуальным выражэнні стак адгружаецца, пакуль двыгун не паспеліць працаваць далей:

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

Кожны ўвоклек forever() дадае новы фрам, і адтуды, што нічога не вяртаецца, нічога таксама не выкарыстоўваецца. У канцэ настае выкарыстанне всіх звярнутых ресурсоў і выклікаецца RangeError.

Сама рекурсія не ўсё тое проблема. Це легітымная і часта элегантная тэхніка. Проблема — рекурсія без можлівасці выйсця. Кожная рекурэнтная функцыя патрэбуе базовага кэсу, умовы, пад якой яна перастае вызываць сябе і вяртаецца.

Базовы кэс, якога ніколі не досягаюць

Іншая, болей суптыльная версія спрабоўвае атакаваць багацо разработчыкаў. У прыкладны функцыі є базовы кэс, але её рекурэнтны крок рухаецца не тым кірмом:

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

Існуе захоўні код if (n === 0) return, але рекурсыўны вызов перадае n + 1. Пачынаючы з 1, значэнні зростаюць да 2, 3, 4 і так далей, таму нуля так і не досягаецца, і стак у будзь разе перепрацоўваецца. Пасправленая версія підрахоўвае у напрамку знизу і выкарыстоўвае n <= 0 як захоўні механізм, тады нават нечаканае ад’ёмнае або дробавае значэння на пачатку прыводзіць да завершэння:

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

Непрямая рекурсыя

Цыкл таксама можа распаўляцца на калькі функцый. Ёсць прыклад, калі ping вызывае pong, а pong зноў вызывае ping:

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

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

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

Ніяка з функцый не вызывае сябе, але ўжо пара стварае бесканцовы цыкл вызоў, і рэзультат застаецца тым жа — переплыв памяці. Непрамыя цыклы важкая адзначыць у рэальным кодзе, таму што два ўжо часткі часта знаходзяцца ў разных модулях, напрыклад, працоўнік адбытка, які запускае апцэнт, які зноў актывае той самы адбытак.

Наскількі глыбока можа быть стак?

Максымальная глыбка залежыць ад двіжка і ад таго, сколькі памяці патрэбна кожнаму фрэйму. Ціфры нижэй ўсьмо толькі апрыксамныя значэнні, а не гарантіі; яны разлічаюцца ў залежнасці ад версый, платформ і флагаў налаштаванняў, таму, якщо ліміт мае значэнне, замеры трэба выконваць у вашай сабе цэльнай среды:

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

Прычына такой разніцы заключаецца у тым, што ліміт на самае справа ёсць бюджет памяці, а не колькісна характэрыстыка вызоў. Функцыя з большым колікствам параметраў і локальных зменных стварае большыя фрэмы, таму ўсё менш іх можа поместыцца пры дасягненні гэтага ліміту.

Усуненне переплнення стака на практыцы

Калі глыбока рекурсія спрычынае збой у рэальнам коде, калькі надзеяных патэранаў дапамагаюць яго рашыць.

Замена рекурсіі на цыкл

Калі вхідныя даны можаць быть великімі, перапісайте рекурсію як for або while цыкл. Цыкл выконваецца внутрь аднаго фрэма, таму мільйоны ітерацый зовсім не збільшуюць глыбіню стака. У прыкладзе нижэй рекурсіўны спосаб сумавання, верагатна, переплніцца пры глыбіні ад 20 000 у V8, тады як ітератыўная версія без проблем справляецца з мільйонам:

// 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)

Трампалінінг

Якщо вы вярбяеце рекурсыўны стыль, трампалін дапамагае яго адрабатваць без збільшэння глыбі стака. У замяну таго, каб рекурсыўная функцыя вызывала сябе, яна вяртае танк – функцыю без аргументаў, якая описвае наступны крок. Трампалін выкананае этых танкаў по аднаму ў плоскай цыкле while; кожны танк вяртаецца раней, чым пачынаецца наступны, таму глыба стака застаецца стацыярной.

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!)

Зверніце ўвагу на параметр накоплівача acc у функціі safeSum: сума, яка нарабатываецца, перадаецца далей у аргументах, а не залишаецца на стэке. Недзея гэтага падходу — дадатковая выдзелка памяці для кожнага крока і тое, што такая практыка менш зрозумела для чытачаў, таму такія механізмы лепей викорыстоўваць толькі у справжніх рекуррэнтных алгорытмах.

Утримайцеся ад перадачы вялічзіных масів у вызывы функцый

Часта неспадзянка: калі перадаецца вельмі вялікая маса ў вызыв функцыі, кожны елемент перадаецца як околічны аргумент, і гэтыя аргументы таксама патрабуюць прастору на стэке.

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);

Залежна ад дварчыка, вызов на кшталт Math.max(...hugeArray) можа выклікваць той самы RangeError, калі масіва мае прыблізна сто тысяч элементаў або больш. Функцыя reduce чыстая цыклічная структура обрабоцуюць даныя без такога ліміту.

Не пакладайцеся на оптымізацыю вызоў з заканчыцы

Старэйшыя нарадчыкі і спецыфікацыя ECMAScript 2015 описваюць Proper Tail Calls (PTC), якія часта называюць оптымізацыяй вызоў з заканчыцы. Сэрца ідэі заключаецца ў тым, што калі апошнім крокам функцыі ў строгім режыме є вярненне рэзультата іншага вызову, дварчык можа перадаць у паўтарэнне тэкучую рамку замест таго, каб стварыць новую, што дазволяе безмежнай рэкурсіі працаваць у стацыонарным прыёмніку.

У практыцы, среды большых двайчоў, толькі JavaScriptCore у Safari яго рэалізуе. V8, які викорыстоўваецца ў Chrome і Node.js, а таксама SpiderMonkey у Firefox не падтрымліваюць яго для стандартнага JavaScript, галоўная прычына — адмова ад відображэння кадроў ведае про історыю выконання, ад якой залежаюць стэк-трэйсы і засобы дыбаггіну. У прымэтнам коде спрыткі з заканчальнымыя вызывамі трэба спрыяваць як недоступныя, а для глыбокага рекурсу викорыстоўваць цыклы або спецыяльныя механізмы.

Прагназаванне выходу ў запытаннях пад час адбору

Запытанні пра выход — гэта запытанні пра стэк

Запытанні на кшталт «Што выведзе гэты код?» з’яўляюцца практычна ў кожным адборе на JavaScript, і большасць з іх є фактычна трэнуваннямі з роботы з стэкам вызываў. Адбірач не пераканваецца ў запам’ятованні; ён пераканваецца, чы вы розумееце порядак выконання, а гэты порядак цалкам вялічыцца стэкам. Паспрабуйце яго, перш чым чытаць далей:

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();

Адзін кадр за адзінам адстэпаваючы

Програма выводзіць:

a start
b start
c
b end
a end

Багато людзей спакульваюць, што a end з’явіцца раней. Усё таму, што гэтага не выходзіць. Калі вызываецца a(), яго фрэйм праганяецца вперад, і a пачынае выконвацца.

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

Першая запісь у a фіксуе пачатак. Наступная — це вызов b(), таму фрэйм для b дадаўцца на верхнюю пазіцыю.

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

a зараз на паузе, і яго последній console.log("a end") яшчэ не выкананы; ён не можа гэта зрабіць пакуль b не завершыцца цэлкам. Усередзіне b фіксуецца b start, а пасля вызываецца c(), што дадае трэці кадр.

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

c логуе c, большэй нічога не мае чыніць і вяртаецца. Яго фрэйм знімаецца, і b знову стае на верху.

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

b працюе працэйна пасля вызову c(), запісвае b end і вяртаецца. Його фрэйм адключаецца, і a знову стае на верхнім месцы.

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

a працюе далей пасля вызову b(), фіксуе a end, вяртаецца і выкарыстоўваецца. Застаецца толькі глобальны контэкст. Полны выхід, па порядку:

a start
b start
c
b end
a end

Правіла, якія трэба запам’ятаваць: званае працоўка не можа продаваць далей пасля вызову, пакуль вызваная функцыя не завершыцца цэлкам. a end было неабходна чакаць, пакуль завершыцца весь b, а b у свою чаргу чакаў, пакуль завершыцца весь c. a была першай функцыяй, якая выклікала іншыя функцыі, і таму апошней, якая завершылася.

Трывідмовы метод для будзь-яга пытання пра выходны данны

  1. Указаць вызовы па порядку, пачынаючы з самага зовнішняга і праходзячы ўнутрь. Кожны вызов зупіняе сваю званую працоўку.
  2. Стежыць за вершынай стака. Тое, што знаходзіцца на вершыне, выконваецца; все, што знаходзіцца пад ям, чакае.
  3. Размістыць кожны лог. Логі, якія з’яўляюцца ўнутрь вызванай функцыі, праказуюцца раней за будзь-які лог, які прыходзіць пасля вызову у званай працоўцы.

Шаблон завжоды аднаковы: спускаемся да самага глыбокага вызову, а потым паверху, у зворачным порядкам. Гэта стосуецца сінхроннага коду. Кале-бэкі з таймераў, прамісаў і запускаеў дадаюць кялейку на верху гэтага модэлю; наша статыя пра закрыцція і цыкл запускаеў раскрывае гэты наступны слой.

Аналіз стака ў рэальны час у Chrome DevTools

Аналіз на паперы ідеальны для адбора на работу, але ў повсякдзеннай працы вы зазвычай адразу бачыце стак у дэбагеры браузера.

Паўза з выказамам debugger

Дадаўшы debugger; у лінію, вы павернуце выконанне там на паўзу кожны раз, калі DevTools ачынены. Ачыніце яго за дапамогою F12, або правым клікам на сторунку выберіце «Аналізаваць» і перайдзіце на вкладку «Ўтворы».

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();

Калі выкананне зупініцца на рэядку debugger, бокавая панель паказвае чырвоную стэк-вісць разам з зменнымі, якія знаходзяцца ў дыапазоне:

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                                 │
  └───────────────────────┘  └──────────────────────────────────────────────┘

У вікні Call Stack спачатку паказана функция calculateTax, падаючыя пад яю — checkoutCart, а самым ніжнім ўзначаны анонімны скрыпт вышэйшага рэвэлю, які паўтарае тую ж структуру, што і раней. Адбіраючы checkoutCart, рэдагувальнік пераходзіць на 9-ю лінію, а вікно Scope пераключаецца на зменныя таго фрэйма, паказваючы total: 100 так сама, як і тады, калі была вызвана функция calculateTax. Магчымасць аналізаваць стан функціі, якая знаходзіцца у стані паузы, ёсць адна з найкорыстнейшых можлівасцэй для адлагоджэння.

Пераход за вызывамі

Інструментальная палітра дэбаггера прысвячана трохцу кантролі за пераходам, якія безпасебна адпавядаюць операцыям стака:

  1. Step Over (F10) запускае текущую лінію коду і зупініцца на наступной лініі той самой функцыі. Якщо на данай лініі ёсць вызыв іншай функцыі, той вызыв выконваецца цэлы, без паузы дэбаггера ўсередзіні яго.
  2. Step Into (F11) далей развівае вызванне на текущай лініі, додае новы фрэм і зупініцца на перайшым яе запісе.
  3. Step Out (Shift+F11) выкананае рэштка текущай функцыі, выкарыстоўваецца яе фрэм і зупініцца ў вызвальнай частцы адразу пасля таго, як вызванне завершылася.

За дапамогою гэтых трох кантролей адлагоджэнне стае цялеспрямованым пераглядам стэка, а не хаотычным спробамі.

Следы стэка: стэк вызваў у момент аберання

Што паведамляе слэд стэка

Калі выйшоў аберанне і яго не падхватілі, двайнер паведамляе слэд стэка: кадруванне кожнага фрэма, які быў у стэку у момент аберання. Ён паведамляе, што выкананае, хто яго вызваў, і точную лінію, дзе ўсё зламалася. Слэд — это проста стэк вызваў, запісаны ў відметку, таму всё, што было сказана раней, таксама прымаецца пад час чытання яго.

Следы частаца выглядаюць застрашаюча — блакат з маршрутаў і цэфраў, але калі структура стане знайомай, яны стаюць найшырэйшым шляхам да выявлення бага.

Анатамія следу

Возьмім маленькі ланцуг, у яком самая внутранняя функцыя выклікае:

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

function b() {
  c();
}

function a() {
  b();
}

a();

Калі ўжоць выканана ў Node.js, яна прадае такі выхід:

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)

Першыя лініі паказваюць тип і паведамленне пра адзію, Error і something went wrong. Гэта тое, што зайшло не так. Іншыя типы, з якімі вы часта станавіцеся, включаюць TypeError, ReferenceError, SyntaxError і RangeError.

Кожная лінія, яка пачынаецца з at, — это адна ступеня стака, калі выйшла адзія, у такім формате:

at functionName (filename:lineNumber:columnNumber)

Такім чынам at c (/box/index.js:2:9) означае, што функцыя c выканана ў файле /box/index.js на 2-й лініі, у 9-му столбце, таму, дзе знаходзіцца саме оператыва throw.

at Object.<anonymous> пазначае код, які выкананы на верхнім рэвэлі модуля, за межамі будзь-якай іменаванай функцыі. Хутчэй, a() вызываецца з верхньага рэвэля модуля. У іншых частках логу <anonymous> зазвычай адзначае вбудованы калебэк, які не мае імені.

Нижэйшыя лініі, такія як Module._compile, Module.load і wrapModuleLoad, — це внутрашні элементы Node.js, якія завантажаюць і выкананяюць ваш файл. Яны не былі напісаны вам і зазвычай можна іх прыхіліць.

Як чытаць

Сама практыка аранжавання ўскладнюе людзям розуменне. Спачатку выдруковываецца найсвежэйшы фрагмент, таму спіс практыкуецца у працоўным напрамку да порядку, у яком былі зданы запыткі:

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

Ніжная частка трэсу — это точка запуску, дзе пачалася екзекуцыя. Верхняя частка — там, дзе выйшла памылка. Екзекуцыя працавала адным рядам праз разныя фрэмы, пакуль памылка яе не зупініла.

Для дэбагавання пачніце з верхней часткі і ачыніце файл і рядок першага запісу at: там і адбылася крах. Потым чытайце адным рядам вярху, таму што справжня прычына часта знаходзіцца на адну-две фрэмы нижэй. Памылка з’явілася ў c, але який вызывач даў c некоректныя даны? Пераканайцеся ў b. А хто так вызваў b? Пераканайцеся ў a. Верхняя фрэма паказвае сімптамы; фрэмы пад яю зазвычай маюць прычыну.

Надзеяны алгорытм для будзь-якага трэсу:

  1. Чытайце тип і паведамленне памылкі, ўбачыце, які тип аблучэння адбыўся.
  2. Знайдзіце перыяд at, які вказуе на ваш сэнтэкс, пры чым ігнаруйце фрагменты, асоўваныя з часам выканання і бібліятэкамі.
  3. Ачынце тыя файл і перыяд і разберіце, што там відбываецца.
  4. Прабачыце фрагмент, які знаходзіцеся адразу пад яным, адтуды часта выходзіць некоректная значэнне або памылковы вызов.

Выдрукаванне стэка за патрэбама за дапамою console.trace()

Вы можете выдрукаваць текущы стэк у будзь-ям момант, без адныя памылкі, вызвавшы console.trace():

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

function middle() {
  deepFunction();
}

function outer() {
  middle();
}

outer();

Рэзультат выглядае так:

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)

Гэта ўсё боле корыстна, калі працуеце з незнайомым кодам і не можаце з’ясавіць, звядзе калікуюцца функцыя: дадзіце console.trace(), запустіце сцэнарый, і будзе паказаны полны шлях. Адзёрвайце гэтыя вызовы перад выкладкай продукту.

Даўайце імены своім функцыям

Праўда анонімныя функцыі паказваюцца як <anonymous> у логах, што практычна не дае можлівасці для пошуку. Нижэйшыя прыклады паказваюць разлік, хоць першыя трэбуе корэкцыі.

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

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

У сучасных двойчыках функцыя-стрэлка, якая прызначаецца безпасяродна да const, отрымае свае імя з самай зменнай, таму ў гэтым конкрэтным прыкладзе фактычна паказваецца at doSomething, а не <anonymous>. Некорыстныя пазначэнні <anonymous> выходзяць з айнлайн-калебэкаў, якія прызначаюцца безпасяродна да такіх функцый, як map, then чыста слухачаў запуску. Заявлення з іменам не вызывае ніякіх неяснасцей:

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

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

У кодавой базе, заполненайй інлайн-калебэкамі, дзесяткі ідэнтычных <anonymous> рамакоў робяць аналіз следаў складным. Называнне калебэкаў або ўзяцце іх у якіясь іменаваныя функцыі дапамагае залічыць следы чытабельнымі.

Следы асінхронных задач async/await і інструменты для працы ў рэальным часе ў V8 і Node.js

Для сервісаў бэкенду і большых фронтэнд-зялоў V8 апускае API-і для кантролю таго, як запісваюцца следы.

Запісваў больш рамакоў за дапамогою Error.stackTraceLimit

Па значэнні за замовчаннем V8 запісвае ў максымум 10 рамакоў у error.stack. У кодзе з глыбокай іерархіяй, такім як пайплайны запытоў у Express або NestJS чы ў ланцюгах мідлвэра Redux, гэтыя дзесяць рамакоў можу быть выкарыстаныя кодам фрэймворку раней, чым з’явяцца вашы сабственныя функцыі. Вы можете падняць гэты ліміт у глобальным масштабе:

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

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

Гэты настройкі кантролюе толькі тое, сколькі кадраў запішваецца ў лог-даных. Яна не вплывае на практычную максымальную глыбіню стака, а вельмі вялікія значэння, у чыяму числе Infinity, робяць стварэнне кожнай памылкі болей складным, таму у прымэтнай роботе лепей выбіраць умерзенныя значэння.

Чыстэйшыя спецыяльныя памылкі за дапамогою Error.captureStackTrace

Калі вы визначаеце сабее класы памылак, сам канстрактар зазвычай павінен быць у лог-даных. Error.captureStackTrace дазволяе яго выключыць, тады лог-даныя пачынаюцца там, дзе памылка фактычна выйшла:

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);
    }
  }
}

Другій аргумент паведамляе V8 пра тое, каб ён не выканаў тую функцыю і ўсія фрэмы, якія знаходзяцца вышэй яе. Умова if дазволяе класу працаваць у двыжках, якія не маюць гэтага спецыфічнага для V8 API.

Асінхронныя стэк-трэсы з async/await

У працоўнай практыцы асінхронны код ствараў скрацаныя стэк-трэсы. Калебак з setTimeout або праграма з типам promise выкананыя на новым стэку дужа пасля таго, як пачатковы вызывальнік вярнуўся, таму ланцуг вызывальнікаў зникае.

Сучасны V8, як у Chrome, так і у Node.js, апранае безкоштовныя асінхронныя стэк-трэсы для async/await. Якщо пасля вярнення з await выйдзе якая-небудзь памылка, V8 перабудоввае ланцуг функцый, якія чакаюць, толькі тады, калі гэты трэс насправды патрэбны, пазначаючы тыя фрэмы атрыбутам async:

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

Гэтыя прыямыя дапамогі дапраўляюцца для стандартных async/await. Дужа дзьялёгія лініі .then(), напісаныя вручную, і код, базаваны на калбэках, не атрымуюць такога ж пераконструювання, што ўсё больша прычына выбіраць await у кодзе, які плануеце дыбагаваць.

Стак дзвянакоў: калькі правілаў

  • JavaScript выкананае на аднай ніті. Сераўісна среда адрабатвае задачы на фоне адночасна, але кожны калбэк чакае сваей разы на гэтай ніті.
  • Стак дзвянакоў — это маленькая, быстрая структура фіксаванага розмеру типу LIFO, якая фіксуе, якая функцыя выкананая і дзе кожны вызываючы элемент будзе продаваць выкананне; об’екты і клозуры знаходзяцца ў хіпе і ўсілякія зв’язкі з імі ведуцца зі стака.
  • Кожны вызыв дадае новы фрэйм у стак, а вярненне выкарыстоўваее яго, ўзяўшы з стака. Верхні фрэйм завжды выкананы, а всі фрэймы пад ям выключаны.
  • Занос стакана, які выказваецца як RangeError: Maximum call stack size exceeded, означае, што выкараны ліміт памяці стакана, зазвычай унаследкі рекурсіі без можлівасці дасягнуць базовага кэсу, будзь то безпосередняя чы рэклюзыўная.
  • Для глыбокай рекурсіі лепш выкарыстоўваць цыклы чы спецыяльныя механізмы, а таксама ухіліцца ад перадачы вельмі большых масэў дадзеных у вызовы функцый. Правільныя вызовы з заканчыццем у верхней часты стакана падтрымляюцца толькі ў Safari, таму не трэба на іх пасляваліцца.
  • У інструментах разработчыка каманды Step Over, Step Into і Step Out адпавядаюць запуску вызову, выходу ў пэўны фрэйм і выходу з яго.
  • След стакана — это пераказ стакана у момент аберання: верхняя часты — гэта месца, дзе выйшла памылка, нижняя — пункт запуску, а прычына часта знаходзіцца на адзін чы два фрэймы нижэй за верхней часты.
  • console.trace(), функцыі з іменамі, Error.stackTraceLimit, Error.captureStackTrace а таксама вбудованыя async/await спрыяюць таму, што логі з’яўляюцца болей інформатыўнымі.
  • Функцыі-выкліканні, якія знаходзяцца у черзі, напрыклад адпаведнікі setTimeout, запускаюцца толькі пасля таго, як стак павнэста запрацаваны, і самэ гэта робіць стак выклікаў адной з аснов для розумэння асінхроннага JavaScript.

Чаму гэта мае значэнне

Большая частка багоў, які займаюць гадзіны на рашэнне, ў сутнасі ўскладненні, вытэклія з стака: функцыя, якая вяртаецца раней, чым чакалася, калбэк, які выконваецца не таму, дзе трэба, або значэнне, якое ў стане undefined, калі гэтага не павінна быць. Разработчыкі, якія шыбкаа рашаюць такія проблемы, не ўжо спрытнейшыя; яны проста раз усвядомілі структуру стака і його трэсаў, і тепер можаць адразу перайсці да адпаведнага фрэйма. Трэс стака — это механічны запіс таго, што на самай працоўвало, і калі вы яму дазволяеце, налагоджэнне стае значна простыям.