Як JavaScript выконвае ваш код: стак, хіп, цыкл здачы справ і генераторы
Экскурсія па рэалайзаванні JavaScript: стак дзвянакоў і фрэмы, кансаекты хіпу, цыкл здарэнняў Node.js, DOM, калбэкі, праграмы, async/await і генератары.
Крыніцы: JavaScript
Асінхронны JavaScript частаць ведаецца так, што дзіўі разработчыкаў: лінія журналу з’являецца „занадта рана“, await здаецца прыскаквае далей, генератор выдрукоўвае толькі частку сваіх запаведзянняў. Практычна кожная з гэтых неспадзянак зникае, калі у вас ёсць чыстая модэль таго, як среда выканання експлуатуе код. У гэтым артыкуле гэтая модэль ствараецца пашагоўна — ад стака вызваў і хіта через цыкл задач Node.js і DOM да праграм, async/await і генератораў, так што вы можете запрацаваць рэзультат кожнага фрагмента прычынам яго чытання.
Элементы среды выканання
JavaScript выканана на аднай галоўной ніткаі, пры тым програмы чакаюць на таймеры, адпаведзі з сеті і файлы без замарожвання, таму што двайнік не працюе самастоятельна:
- стак вызваў, які фіксуе функцыі, якія зараз выкананы
Ваш код выконваецца на стаке вызваў, тады калі сэрвіс выконання адпрацоўвае повольныя операцыі. Калі якая-небудзь з іх завершыцца, ўсунутая яе вызваў падаецца у рэяд, а цыкл адбытка падзеяў перакладае яе на стак, калі ён стане вольным.
Цыкл адбытка падзеяў у Node.js
У дыяграме паказан цыкл Node.js. Спачатку выконваецца сінхронны код, пасля — мікразадачы з кошчыку, а потым фазы цыклу: таймеры (setTimeout, setInterval), запланаваныя калебры, процес польнага (poll, дзе выконваюцца большасць калебраў для файлаў і сеті), перагляд (setImmediate) і калебры закрыцья.
┌───────────────────┐
│ CALL STACK │
│ JavaScript │
└─────────┬─────────┘
│
│ Synchronous code finishes
▼
┌───────────────────┐
│ MICROTASK QUEUE │
│ │
│ Promise.then() │
│ Promise.catch() │
│ queueMicrotask() │
└─────────┬─────────┘
│
▼
╔═════════════════════════════════════════════╗
║ EVENT LOOP ║
║ ║
║ ┌───────────────┐ ║
║ │ TIMERS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ PENDING │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ POLL │ ║
║ │ I/O │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CHECK │ ║
║ │ setImmediate │ ║
║ └───────┬───────┘ ║
║ ▼ ║
║ ┌───────────────┐ ║
║ │ CLOSE │ ║
║ │ CALLBACKS │ ║
║ └───────┬───────┘ ║
║ └───────────────────────────────┐ ║
║ │ ║
╚══════════════════════════════════════════╪══╝
│
▼
┌───────────────────┐
│ CALL STACK │
│ Execute JS │
└───────────────────┘
Важны два аспекты. Мікразадачы обрабоцваны не толькі адна раз на кожны цикл: Node спрачоўвае чергу мікразадач (а таксама і чергу process.nextTick да яе) пасля кожнага калебэка, таму працэсар .then абявы прагнення выкананы раней, чым запускаецца наступны калебэк таймера. А браузеры выкарыстоўваюць прынцып задач плюс мікразадач, але не маюць функцый setImmediate чы ўзлосу libuv, і яны перамішваюць процесы атрыбутавання. Па деталям, спецыфічным для Node, адзірніце як работаюць разам libuv, цыкл змагання і пул задач.
Стак вызваў
Стак вызваў фіксуе, якая функцыя зараз выканана і якія функцыі чакаюць, пакуль вона не завершыцца. Це структура LIFO — «пасляльгавае выведанне»: функцыя, якая была вызвана апошней, павінна завершыцца, перш чым функцыя, якая знаходзіцца нижэй, зможа продаваць роботу.
У наступным прыкладзе func1 вызывае func2, які зноў вызывае func3, які фіксуе паведамленне:
function func1() {
func2();
}
function func2() {
func3();
}
function func3() {
console.log("Hello");
}
func1();
Вызывы выконваюцца ў такой спрабе:
func1()
↓
func2()
↓
func3()
↓
console.log()
Падобна до стакана з тарэлкамі, console.log выканана ў канцы і першая, пасля чаго адпаведна вяртаюцца func3, func2 і func1. Кале стакан спражоўваецца, толькі тады можна запусціць зачаканыя функцыі-вызывы, і самэ гэта прычына таго, што дзейсненне дужа довгаг сінхроннага цыклу блакуе всё іншае.
Heap
Heap — это памяц для дадзеных, чыя трываласць жыцця не прыязана да адного вызыву, такіх як об’екты, масівы і клозуры.
Стварэнне літералу об’екта выдзеляе яго ў heap:
const user = {
name: "Rayane",
age: 20
};
Зменная храніць толькі аб’ектны кансэльт до об’екта, які знаходзіцца ў heap:
CALL STACK HEAP
┌───────────────┐ ┌────────────────────┐
│ user variable │─────────▶│ { │
│ │ │ name: "Rayane", │
└───────────────┘ │ age: 20 │
│ } │
└────────────────────┘
Калі user прызначаецца іншайяй зменный, копіюецца толькі абсалютна нарадка, а не сам об’ект, таму змяны, якія відбуваюцца ў будзь-кай з іх, стаюць виднымі ў обох. Калі не застаецца жаднойя доступнай нарадкі, якая бы вказвала на об’ект, калекцыя сметлінага мусуе яго звалічыць; длительна закрыцця, якая зафіксавае об’ект, прымушвае яго застацца жывым.
Што координуе цикл здарэнняў
Цикл здарэнняў спаявае ваш код з завершэннем асінхронных заданняў. У Node.js ваш код выкананы V8; вызов API Node, такі як чытанне файла, пераходзіць у libuv, якая передае його оператывнай системе або ў яе пул нитак. Калі заданне завершыцца, калбэк стае гатовым, і цикл здарэнняў востанні час выканае яго на стэкі.
JavaScript code
│
▼
V8 Engine
│
▼
Call Stack
│
▼
Node.js APIs
│
▼
libuv
│
├───────────────┐
▼ ▼
OS Thread Pool
│ │
└───────┬───────┘
▼
Asynchronous operation
│
▼
Callback becomes ready
│
▼
Event Loop
│
▼
Callback Queue
│
▼
Call Stack
Маршрут залежыць ад операцыі: сокеты зазвычай обрабоўваюцца оператывной системай через epoll або kqueue, тады як робота з файлавай системай, а таксама дзеяння, якія стосуюцца крыптаграфіі і DNS, выкананы за дапамою пулу нитак.
Рамкі стака
Кожны вызов дадзець новую рамку стака, яка храніць тое, што патрэбна функцыі пад час ўжывання:
- яе параметры
- яе локальныя зменны
- адрэс вароткі, тое ў чым продовжваецца выкананне ў вызываючай функцыі
- іншыя даны для ведэння лісты вызываў
Возьмімо функцыю, якая дадае два числы:
function add(a, b) {
const result = a + b;
return result;
}
add(2, 3);
Колі выканана функцыя add(2, 3), яе рамка містіць аргументы, локальны результат і вяліканец да вызываючай функцыі:
CALL STACK
┌─────────────────────┐
│ stack frame: add │
│ │
│ a = 2 │
│ b = 3 │
│ result = 5 │
│ return → caller │
└─────────────────────┘
Калі функцыя вяртаецца, яе рамка абрактуюцца:
CALL STACK
┌─────────────────────┐
│ frame removed │
└─────────────────────┘
Усі етапы жыццёвага циклу функцыі є стиснутымі:
Function call
↓
Stack Frame created
↓
Function executes
↓
Function returns
↓
Stack Frame removed
Рекурсія, якая заходзіць занадта глыбока, накаплюе рамкі стака, пакуль двайчыннік не выдае прымэту RangeError за перакрыцча максымальнага размеру стака вызываў.
DOM
Модэль об’екта дакумента — это жывая структура вяроўак, якая ствараецца браузерам на адной з HTML-дакументаў. За ўзмоцю яе JavaScript можа:
- чытаць элементы
- зменяць ўхідны тэкст
- дадаць новыя элементы
- выдаліць існуючыя
- мэндзіраваць стылі і атрыбуты
DOM — это API браузера, а не частка самага языка; таму ключ document не існуе ў чыстам Node.js.
Ось прыклад аднаго заглавняка ў HTML:
<h1 id="title">Hello</h1>
Скрыпт можа атрымаць канект да гэтага элемента праз яго ідентыфікатор:
const title = document.getElementById("title");
а пасля — заменіць яго тэкст:
title.textContent = "Hello Rayane!";
Да змены элементы меў у сабе первісны тэкст:
document
│
▼
<h1 id="title">
│
▼
"Hello"
Пасля выкарыстання гэтага прызначэння:
title.textContent = "Hello Rayane!";
структура вяроўак прадставляе новы тэкст:
document
│
▼
<h1 id="title">
│
▼
"Hello Rayane!"
Пасульнік пасля таго перазрасавляе экран. У рэальным кодзе трэба пераканаліцца, чы не вяртае null функцыя getElementById, калі нічога не падпадае пад умову; а таксама для стрэлкі, якія не ўзяты з абавесцю, лепш выкарыстоўваць textContent замест innerHTML, адтакуе ён ніколі не парсуе HTML.
Callback-ы
Callback-ам ёсць функцыя, якая перадаецца іншай функцыі, каб яе можна было выклікаць пазней. У гэтым прыкладзе greet перадаецца функцыі execute, якая выклікае ёў з іменем:
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
Програма выведае:
Hello Ajami
Унутры execute параметар пад назвай callback апісваеся на greet, і гэты рядок выклікае яго:
callback("Ajami");
Callback-ам не ёсць асінхронным па свайбе; гэты выклікаецца немедленна. Ён стае асінхронным, калі яго выклікае той, хто яго прымоўчае, пасля якога-небудзь здарэння, напрыклад пасля запуску таймера.
Promises
Праміса выражае пасляўны рэзультат асінхронной операцыі. Вы запускаеце операцыю, продовжаеце выкананне іншага коду, а рэзультат обрабоўваеце, калі праміса стане вялікай.
fetch — это типовы API, яны ўтвараюць прамісы:
const promise = fetch("https://api.example.com/data");
Вы реагуеце на рэзультат, прыкрепіваючы обрабоўчыкі:
.then()для успешнага рэзультата.catch()для неудачы.finally()для чысткі, якая выканана ў будзь-яму случае
fetch("https://api.example.com/data")
.then((response) => {
console.log("Request finished");
})
.catch((error) => {
console.log("Something went wrong:", error.message);
})
.finally(() => {
console.log("Request completed");
});
Запуск запытку не блакуе; обрабоўчыкі выкананы пазней як мікрозадачы. Заўважыце, что fetch адхіляеся толькі у разе сяброўства сеті. Коды 404 або 500 все равно выпалняюць прамісу, таму самі пераканайцеся ў response.ok.
Станы прамісы
Праміса завжды знаходзіцца ў аднам з трох станоў:
PENDING
│
├───────────────┐
▼ ▼
FULFILLED REJECTED
- Чакаецца: аператыя яшчэ не завершылася.
- Завершана: яна завершылася успешна і мае значэнне.
- Адраджана: яна зазнала нявыходу і ўсё ж мае прычыну, зазвычай канфлікт.
Promise завершаецца толькі адначасова, і прыемнікі, якія былі прыўязаны пазней, таксама атрымуюць готовы рэзультат.
Async/await
Функцыя async завжды вяртае promise, а await дазволяе пісаць асінхронныя крокі последоўна. Што стосуецца гэтай функцыі, яна логавае, чакае на запит і зноў логавае, а пасля вызову — яшчэ раз:
async function leetfinish() {
console.log("The function starts");
const res = await fetch("https://api.example.com/finish");
console.log("The request finished");
}
leetfinish();
console.log("Let's review our code");
Верагодны выходны результат:
The function starts
Let's review our code
The request finished
Чаму зовнішні лог выкананы другі
Функцыя выводзіць сваё першае паведамленне і доходзіць да гэтай лініі:
const res = await fetch("https://api.example.com/finish");
fetch вяртае обявленую праміс, а await призупошвае функцыю і вяртае кантроль вызывачу, не заморажваючы нитку. Вызывач выводзіць свойя паведамленні; толькі пасля прыбыць адпаведзя функцыя продовжвае сваю роботу. Таму:
The function starts
Let's review our code
The request finished
await призупошвае функцыю, а не нітку. І «ймовірна» ситуацыя мае значэнне: якщо запрос не выйшоў, апошняе рэченне ніколі не будзе выведзена, а неспраўжаная памылка стане неконтрольванай адмоваю.
Контроль памылак за дапамою try, catch і finally
За дапамою async/await вы вжываеце звычныя try/catch/finally. У гэтым прыкладзе запрашваюцца даны, перакантроліванае адпаведзя і бюджет, пры чым завжды скасоўваецца флаг загрузкі. У коментарах паказаны два моменты: HTTP-памылкі не вызываюць адмову, а response.json() таксама вяртае праміс.
let isLoading = true;
async function checkBudget() {
try {
// fetch() returns a Promise.
// await pauses this function until the Promise settles.
const response = await fetch(
"https://api.example.com/khouribga/business/budget"
);
// fetch() does NOT reject automatically for HTTP errors
// such as 404 or 500.
if (!response.ok) {
throw new Error("Failed to fetch business data");
}
// response.json() also returns a Promise.
const business = await response.json();
if (business.budget < 10) {
throw new Error("Budget is too low");
}
console.log("Budget is sufficient:", business.budget);
} catch (error) {
console.log("Something went wrong:", error.message);
} finally {
// This runs whether the request succeeds or fails.
isLoading = false;
console.log("Loading finished:", isLoading);
}
}
checkBudget();
Шаг за шагам:
fetch()адрасуе запыт, аawaitнацішчае выкананне функцыі.- Калі прыходзіць адпаведна адказа, функцыя продовжвае сваё выкананне.
- Неспяшны статус HTTP ператвараецца на паданыя адказу працоўнікі.
- Тэла даных у формате JSON аналізуецца, а бюджет пераканальваецца.
- Будзь-якія працоўнікі адказу ў блоке
try, уключаючы проблемы з сетью, перадаюцца ў блокcatch. - Блок
finallyзавжды выкананы і скасоўвае статус завантажэння.
Практыка: выкананне асінхронных функцый паралельна
Хорашым абавязкам яўнаеся задача 2721 на сайце LeetCode, якая вымагае запускання калькольных операцый адразу, замест таго каб чакаць на выкананне кожной з іх по аднай, фактычна ствараючы свой варыянт Promise.all.
Генераторы
Функцыя-генератор можа націшчаць сваё выкананне і пракрацаваць далей, выдаваючы значэнні з часам. Яна прызначаецца за дапамою function*, а точкі націшчэння пазначаюцца сілабам yield:
function* generatorAjami() {
// Instructions ...
yield i;
// More instructions ...
yield i;
// More instructions ...
}
Порашчыце yield з return:
returnвыдае значэнне і завершае функцыю.yieldвыдае значэнне і призупіняе функцыю, захоўваючы ўсё яе стана.
Адработка генератора
Этот генератор выводзіць запаведзю перад кожным з трох запаведзей yield:
function* generatorAjami() {
console.log("First born in casa");
yield 10;
console.log("Second move to Khouribga");
yield 20;
console.log("Third live in Khouribga");
yield 30;
}
Калі яго вызваць, ніяка частка тэлу не будзе выканана:
const generator = generatorAjami();
Вы отрымліе об’ект генератора, ітэратар, з якога можна выцягваць значэнні за дапамою .next():
console.log(generator.next());
Першы вызов выкананы да першага yield, праз што выводзіцца першая запаведзь:
First born in casa
{ value: 10, done: false }
Выкананне зупіняецца на гэтай запаведзі:
yield 10;
і вяртае 10. Кожны рэзультат .next() мае два атрыбуты:
value— значэнне, якое было выданае
done – чы гэнератар завершыў сваю роботуТады выходнае рэзультат:
{ value: 10, done: false }
значыць «тут 10, і ўсё яшчэ будзе».
Выклік .next() во другі раз:
console.log(generator.next());
дае:
Second move to Khouribga
{ value: 20, done: false }
Другая паведамленне з’яўляецца толькі зараз, калі гэнератар працюе далей адразу пасля паканальнаго yield.
Трэці выклік:
console.log(generator.next());
выводзіць трэцяе паведамленне і выдае 30:
Third live in Khouribga
{ value: 30, done: false }
і чэтырты:
console.log(generator.next());
паведамляе, што нічога не засталося:
{ value: undefined, done: true }
Без явнага return фінальная value ёсць undefined.
Што на самай працы ёсць гэнератар
Генератор не выкананяецца колькаразо: адна екзекуцыя пачынаецца і заканчваецца адназначы, але можа быць павярнутая і продовжаная, каб выдаты многія значэнні. Першы вызов выкананяецца да yield 10:
generator.next()
↓
Execute instructions
↓
yield 10
↓
Pause and return { value: 10, done: false }
Другі вызов павярнуўся і зупыніўся на yield 20:
generator.next()
↓
Resume after yield 10
↓
yield 20
↓
Pause and return { value: 20, done: false }
Трэці вызов павярнуўся і зупыніўся на yield 30:
generator.next()
↓
Resume after yield 20
↓
yield 30
↓
Pause and return { value: 30, done: false }
Чатырты вызов не знаходзіць больш запаведзяў і абавешчае прымусоваяе завершэнне:
generator.next()
↓
No more instructions
↓
Return { value: undefined, done: true }
for...of і синтаксі распашчэння вызываюць .next() замест нас і зупыняюцца на done: true. Для большых прыкладаў адзірніце функцыі, якія можна павярнуць, і ледзьве-ітерацыю з генератарамі.
Практыка: бесканечны генератор Фібоначчі
Задача LeetCode 2648 ідзе на лягкість. Генератор выдае текущую значэнне, а пасля — прыбліжае оба дзейнуючыя значэння. Цикл while (true) ў безпецы, таму што кожная ітерацыя зупіняецца на yield.
var fibGenerator = function* () {
let a = 0;
let b = 1;
while (true) {
yield a;
const next = a + b;
a = b;
b = next;
}
};
Стан развіваецца так:
a = 0, b = 1
↓
yield 0
↓
a = 1, b = 1
↓
yield 1
↓
a = 1, b = 2
↓
yield 1
↓
a = 2, b = 3
↓
yield 2
Значэнні вычысляюцца ледзь, по аднаму за кожны вызов .next(); проста ніколи не распрасцварваеце і не ітеруеце яго без ліміту.
Складанне модэлю
Для Node.js: V8 запускае ваш код зі сваёй стакоўай і купкай памяці, API Node перадаюць повольную роботу у libuv з його цыклам задач і пулом адносоў, а оператывная система здзейснюе рэальную сетевую работу.
┌─────────────────────────────────────────────┐
│ NODE.JS │
│ │
│ ┌─────────────┐ │
│ │ V8 │ │
│ │ │ │
│ │ Call Stack │ │
│ │ Heap │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ Node.js APIs │ │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ │
│ │ libuv │ │
│ │ │ │
│ │ ┌─────────┐ │ │
│ │ │ Event │ │ │
│ │ │ Loop │ │ │
│ │ └─────────┘ │ │
│ │ │ │
│ │ Thread Pool │ │
│ └───────┬───────┘ │
│ │ │
└───────────┼─────────────────────────────────┘
│
▼
┌───────────────┐
│ OS │
│ │
│ Networking │
│ Kernel │
│ epoll/kqueue │
└───────────────┘
Ключовыя выводы
- Нічога з чергі не запускаецца пакуль стак вызоў не будзе порожнім, таму довгая сінхронная робота блакуе все.
- Зменныя храняць канкрэтнасці да об’ектаў у купцы памяці; недоступныя об’екты збіраюцца.
fetch адхоўвае толькі праз канектныя бяды, таму трэба пераканацца ў response.ok.await паставляе на паузу функцыю, а не нитку.yield і продовжуюць роботу праз .next(), чым ствараецца можлівасць для ледзьвыканальных, нават бесконечных, серыйяў.