Пошук уроків, статей та іншого контенту
Побачите, як стек викликів, черга завдань і середовище виконання визначають порядок виконання асинхронного коду.
JavaScript виконує код послідовно, зверху вниз. У кожен момент часу двигун JavaScript виконує одну операцію.
console.log("Перший рядок");
console.log("Другий рядок");
console.log("Третій рядок");Результат:
Перший рядок
Другий рядок
Третій рядокОднак JavaScript також працює з операціями, які завершуються не одразу:
таймерами;
обробниками подій;
мережевими запитами;
читанням файлів у Node.js;
промісами.
Щоб не блокувати виконання всієї програми, такі операції передаються середовищу виконання. Порядок взаємодії між кодом JavaScript і середовищем визначають:
стек викликів;
середовище виконання;
черги завдань;
цикл подій.
Стек викликів — це структура даних, у якій JavaScript зберігає функції, що зараз виконуються.
Стек працює за принципом LIFO — Last In, First Out, тобто «останнім додано — першим видалено».
Розглянемо приклад:
function greet() {
console.log("Привіт");
}
function start() {
greet();
}
start();Під час виконання відбувається приблизно таке:
У стек додається глобальний код.
Викликається start() — вона додається у стек.
Усередині start() викликається greet() — вона додається у стек.
greet() виводить повідомлення.
greet() завершується і видаляється зі стеку.
start() завершується і видаляється зі стеку.
Завершується глобальний код.
Схематично стек змінюється так:
Глобальний код
Глобальний код → start
Глобальний код → start → greet
Глобальний код → start
Глобальний код
ПорожнійПоки функція перебуває у стеку, JavaScript виконує її код.
Якщо функція викликає саму себе без умови завершення, стек постійно зростає:
function repeat() {
repeat();
}
repeat();У певний момент стек переповнюється, і JavaScript викидає помилку:
RangeError: Maximum call stack size exceededЩоб рекурсія була безпечною, вона повинна мати умову завершення:
function countDown(number) {
if (number === 0) {
return;
}
console.log(number);
countDown(number - 1);
}
countDown(3);Результат:
3
2
1Синхронний код виконується по черзі. Наступна операція починається після завершення попередньої.
console.log("Початок");
for (let index = 1; index <= 3; index += 1) {
console.log(index);
}
console.log("Кінець");Результат:
Початок
1
2
3
КінецьДовга синхронна операція блокує стек викликів. Поки вона виконується, JavaScript не може перейти до інших завдань.
console.log("Початок");
for (let index = 0; index < 1_000_000_000; index += 1) {
// Імітація довгої синхронної операції
}
console.log("Кінець");У браузері така операція може «заморозити» інтерфейс: сторінка перестане реагувати на натискання та прокручування.
JavaScript-двигун відповідає за виконання JavaScript-коду, але він не працює ізольовано.
У браузері середовище виконання надає додаткові можливості:
setTimeout;
setInterval;
fetch;
console;
обробники подій DOM.
У Node.js середовище виконання також надає власні API, наприклад роботу з файлами, мережею та таймерами.
Ці API не є частиною самого синтаксису JavaScript. Вони надаються середовищем, у якому запускається програма.
Наприклад, setTimeout не виконує функцію безпосередньо зі стеку. Середовище запускає таймер і повідомляє JavaScript, коли функцію можна буде поставити в чергу.
Цикл подій — це механізм, який постійно перевіряє:
чи порожній стек викликів;
чи є готові завдання в чергах;
чи можна перемістити наступне завдання у стек.
Спрощено цей процес можна описати так:
Поки програма працює:
якщо стек викликів порожній:
взяти готове завдання з черги
додати його у стек
виконати завданняЗавдяки циклу подій JavaScript може виконувати асинхронний код, залишаючись однопотоковим у межах основного потоку.
Розглянемо приклад:
console.log("Початок");
setTimeout(() => {
console.log("Таймер");
}, 0);
console.log("Кінець");Результат:
Початок
Кінець
ТаймерЧому Таймер виводиться останнім, хоча таймер має затримку 0?
console.log("Початок") виконується одразу.
setTimeout передає функцію середовищу виконання.
JavaScript продовжує виконувати наступний рядок.
console.log("Кінець") виконується одразу.
Після завершення синхронного коду функція таймера потрапляє в чергу завдань.
Цикл подій переносить її у стек.
Виконується console.log("Таймер").
Значення 0 у setTimeout означає не «виконати негайно», а «виконати не раніше, ніж завершиться поточний синхронний код і з’явиться можливість обробити завдання».
console.log("A");
setTimeout(() => {
console.log("B");
}, 100);
setTimeout(() => {
console.log("C");
}, 0);
console.log("D");Результат:
A
D
C
BСпочатку виконується весь синхронний код: A, потім D.
Після цього виконується таймер із затримкою 0, а приблизно через 100 мілісекунд — таймер із затримкою 100.
Водночас затримка таймера не гарантує точний момент виконання. Якщо стек зайнятий іншою операцією, функція таймера чекатиме.
console.log("Початок");
setTimeout(() => {
console.log("Таймер");
}, 0);
for (let index = 0; index < 1_000_000_000; index += 1) {
// Цикл блокує стек викликів
}
console.log("Кінець");Таймер не зможе виконатися, доки не завершиться довгий цикл.
Проміси використовують окрему чергу — чергу мікрозавдань.
До мікрозавдань належать, зокрема:
функції, передані в .then();
функції, передані в .catch() і .finally();
продовження після await.
Розглянемо приклад:
console.log("Початок");
Promise.resolve().then(() => {
console.log("Проміс");
});
console.log("Кінець");Результат:
Початок
Кінець
ПромісОбробник проміса не виконується одразу. Спочатку завершується весь синхронний код, а потім цикл подій обробляє чергу мікрозавдань.
Мікрозавдання мають вищий пріоритет, ніж звичайні завдання, наприклад таймери.
console.log("Початок");
setTimeout(() => {
console.log("Таймер");
}, 0);
Promise.resolve().then(() => {
console.log("Проміс");
});
console.log("Кінець");Результат:
Початок
Кінець
Проміс
ТаймерПорядок виконання:
Початок — синхронний код.
Реєстрація таймера.
Реєстрація обробника проміса.
Кінець — синхронний код.
Обробник проміса — мікрозавдання.
Обробник таймера — звичайне завдання.
Перед переходом до наступного звичайного завдання середовище зазвичай спорожнює чергу мікрозавдань.
async і awaitКод після await також виконується як мікрозавдання.
async function run() {
console.log("Всередині: початок");
await Promise.resolve();
console.log("Всередині: після await");
}
console.log("До виклику");
run();
console.log("Після виклику");Результат:
До виклику
Всередині: початок
Після виклику
Всередині: після awaitКоли виконання доходить до await, функція призупиняється. Поточна синхронна частина завершується, а код після await буде продовжено пізніше через чергу мікрозавдань.
Наступний приклад можна запустити в браузерній консолі або в Node.js:
console.log("1: синхронний код");
setTimeout(() => {
console.log("5: звичайне завдання таймера");
}, 0);
Promise.resolve().then(() => {
console.log("4: мікрозавдання проміса");
});
async function showMessage() {
console.log("2: початок async-функції");
await Promise.resolve();
console.log("3: продовження після await");
}
showMessage();
console.log("6: кінець синхронного коду");Результат:
1: синхронний код
2: початок async-функції
6: кінець синхронного коду
4: мікрозавдання проміса
3: продовження після await
5: звичайне завдання таймераПояснення:
Перший console.log виконується синхронно.
Таймер реєструється середовищем виконання.
Обробник проміса додається до черги мікрозавдань.
showMessage() починає виконання синхронно.
Після await продовження функції буде виконано пізніше.
Виконується останній синхронний console.log.
Спочатку обробляється мікрозавдання проміса.
Потім обробляється мікрозавдання після await.
Лише після цього виконується таймер.
У браузері події користувача також можуть створювати завдання для циклу подій.
const button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("Кнопку натиснуто");
});Коли користувач натискає кнопку:
браузер фіксує подію;
функція-обробник потрапляє в чергу;
коли стек звільняється, обробник додається у стек;
виконується код обробника.
Якщо в обробнику є довга синхронна операція, сторінка може тимчасово перестати реагувати.
JavaScript може працювати з асинхронними операціями, але це не означає, що весь код виконується одночасно.
Основний потік JavaScript виконує одну функцію за раз. Асинхронність означає, що програма може передати довгу операцію середовищу, а після її завершення продовжити роботу через чергу.
Наприклад, під час очікування мережевої відповіді JavaScript може виконувати інший код, замість того щоб блокувати весь застосунок.
Щоб визначити порядок виведення, використовуйте такий алгоритм:
Знайдіть весь синхронний код.
Виконайте його зверху вниз.
Визначте, які функції потрапили до черги мікрозавдань.
Виконайте всі мікрозавдання.
Визначте наступні звичайні завдання: таймери, події тощо.
Виконайте їх, після кожного завдання перевіряючи чергу мікрозавдань.
Спробуйте передбачити результат до запуску:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
queueMicrotask(() => {
console.log("D");
});
console.log("E");Результат:
A
E
C
D
BC і D є мікрозавданнями. Вони виконуються після синхронного коду та до таймера. Оскільки C було додано до черги раніше за D, воно виконується першим.
setTimeout(..., 0) виконається негайноsetTimeout(() => {
console.log("Пізніше");
}, 0);
console.log("Зараз");Правильний результат:
Зараз
ПізнішеНульова затримка не обходить стек викликів. Таймер чекає завершення синхронного коду.
await блокує всю програмуawait призупиняє лише поточну async-функцію. Інший синхронний код може продовжити виконання.
async function load() {
await Promise.resolve();
console.log("Завершення");
}
load();
console.log("Інший код");Результат:
Інший код
ЗавершенняТаймер або обробник події не може виконатися, якщо стек зайнятий довгою функцією.
Асинхронний API не перериває поточний синхронний код.
Функція, передана в setTimeout або .then(), спочатку лише реєструється. Її виконання відбудеться пізніше, коли це дозволять стек і цикл подій.
Якщо постійно додавати нові мікрозавдання, звичайні завдання можуть довго не виконуватися:
function addMicrotask() {
queueMicrotask(addMicrotask);
}
addMicrotask();Такий код може заблокувати обробку таймерів і подій. Асинхронний код теж потрібно завершувати або обмежувати.
Стек викликів зберігає функції, які зараз виконуються.
JavaScript виконує синхронний код послідовно.
Середовище виконання надає таймери, події, мережеві запити та інші API.
Готові асинхронні функції очікують у чергах.
Мікрозавдання промісів виконуються після синхронного коду та перед звичайними завданнями.
Функції таймерів і обробники подій виконуються через чергу звичайних завдань.
Цикл подій переносить готові завдання зі черг у стек, коли стек порожній.
setTimeout(..., 0) не означає негайне виконання.
await призупиняє поточну async-функцію, але не весь JavaScript.
Довгий синхронний код блокує стек, інтерфейс і виконання асинхронних обробників.