Як JavaScript виконує ваш код: стек, купа, цикл подій та генератори
Екскурсія по середовищу виконання JavaScript: стек викликів та фрейми, посилання на хіп, цикл подій Node.js, DOM, калебеки, обіцянки, async/await та генератори.
Джерела: JavaScript
Асинхронний JavaScript часто поводиться так, що дивує розробників: рядок журналу з’являється „занадто рано“, await здається пропускає певні кроки, генератор виводить лише половину своїх повідомлень. Майже кожне з цих сюрпризів зникає, як тільки у вас є чітка модель того, як середовище виконання обробляє код. У цій статті ця модель будується поступово — від стеку викликів та купи пам’яті через цикл подій Node.js та DOM до обіцянок, асинхронності/await та генераторів, щоб ви могли передбачити результат кожного фрагмента коду ще до його читання.
Компоненти середовища виконання
JavaScript працює на одній основній нитці, проте програми чекають на таймери, відповіді мережі та файли без зупинки, оскільки двигун не діє самостійно:
- Стек викликів, який відстежує функції, що зараз виконуються
Ваш код виконується у стеку викликів, тоді як система виконання обробляє повільні операції. Коли одна з них завершується, її callback додається до очереді, а цикл подій переміщує його у стек, як тільки він звільняється.
Цикл подій 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 — це пам’ять для даних, термін життя яких не пов’язаний з одним конкретним викликом, таких як об’єкти, масиви та замикання.
Створення літерального об’єкта розміщує його саме там:
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 браузера, а не частина мови, тому в звичайному Node.js не існує об’єкта document.
Ось один заголовок у HTML:
<h1 id="title">Hello</h1>
Скрипт може отримати посилання на цей елемент за його id:
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.
Корутини-відповіді
Корутина-відповідь — це функція, яку передають іншій функції для її подальшого виклику. У цьому прикладі greet передається функції execute, яка викликає її з ім’ям:
function greet(name) {
console.log(`Hello ${name}`);
}
function execute(callback) {
callback("Ajami");
}
execute(greet);
Програма виводить:
Hello Ajami
Усередині execute параметр під назвою callback вказує на greet, і цей рядок її викликає:
callback("Ajami");
Корутина-відповідь за своєю суттю не є асинхронною; ця конкретна корутина виконується негайно. Вона стає асинхронною, коли її викликають після певної події, наприклад після спрацювання таймера.
Обіцянки
Обіцянка представляє кінцевий результат асинхронної операції. Ви запускаєте операцію, продовжуєте виконувати інший код та обробляєте результат, як тільки обіцянка досягне свого стану.
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 завжди повертає обіцянку, а 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завжди виконується та скидає стан завантаження.
Практика: паралельне виконання асинхронних функцій
Гарним вправою є задача LeetCode 2721, яка пропонує запустити кілька операцій одночасно замість очікування кожної по черзі, фактично створивши власну версію 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(), що дозволяє створювати послідовності з відкладеним виконанням, навіть нескінченні.