Звісно, JavaScript за своєю природою є однопотоковою мовою програмування, що робить його блокуючим. Але чи задумувалися ви коли-небудь: “Як асинхронні задачі виконуються одночасно з синхронними завданнями, не блокуючи виконання коду, попри те, що JavaScript є однопотоковим?” Якщо так, то п’ять з плюсом!
Увесь наш код на JavaScript виконується всередині JavaScript движка (наприклад, V8 у випадку з Chrome), який складається з стеку викликів (Call Stack) та кучі пам’яті (memory heap).
Стек викликів (Call Stack)
Стек викликів — це структура даних, яка працює за принципом LIFO (останній прийшов — перший пішов). У движку V8 стек викликів контролює порядок виконання функцій, додаючи в стек їхні контексти виконання.
Контекст виконання функції включає необхідне середовище для її успішного виконання. Це може бути область видимості змінних, аргументи функції та інша контекстна інформація, що потрібна під час виконання.
function a(){
return "a";
}
function b(){
return "b";
}
function c(){
return "c";
}
a();
b();
c();
Давайте візуалізуємо, як працює стек викликів під час виконання цього коду, враховуючи, що під час запуску JavaScript-програми у V8 створюється глобальний контекст виконання, який розміщується на дно стека. Коли ми викликаємо функцію a(), створюється новий контекст виконання для a() і додається у стек.
На цьому етапі наш стек виглядає так:
стек викликів після виклику функції a()
Як тільки завдання, яке виконує функція a(), завершується і повертає значення, її контекст виконання видаляється зі стека, щоб не блокувати подальше виконання. На цьому етапі в стеку залишається лише глобальний контекст виконання:
Після виклику a() ми викликаємо функцію b(), яка також створює свій контекст виконання і додається в стек. Цей процес триває рекурсивно, доки ми не дійдемо до кінця програми. Коли весь код виконано, глобальний контекст виконання також видаляється, і стек стає порожнім.
Тепер розглянемо цікавий сценарій із функцією, яка викликає сама себе:
function runForever(){
runForever();
}
runForever();
Так, ми створили функцію runForever()
, яка безперервно викликає саму себе.
Візуально стек для цього коду виглядає так:
Переповнення стека (Stack Overflow)
У стеку викликів є обмеження щодо кількості даних, які він може зберігати. Коли простір пам'яті стека перевищує свою ємність через надмірну кількість контекстів виконання, відбувається переповнення (overflow). У таких випадках з’являється помилка "Maximum call stack size exceeded", відома як переповнення стека (Stack Overflow).
Тепер, мабуть, ви зрозуміли, що означає термін «блокування» для JavaScript. Блокуючий код — це код, який виконується повільно і тривалий час займає стек викликів.
Подивімося на приклад блокуючого коду
const getPost = () => {
const url = `https://jsonplaceholder.typicode.com/posts`;
return fetch(url)
.then(response => response.json())
.then(posts => posts);
};
const posts = getPosts()
console.log(posts)
console.log("Готово")
Останній рядок коду повинен чекати, поки мережевий запит завершиться, і пости повернуться з сервера, незалежно від того, скільки це займає часу. Лише після цього стек викликів звільняється для виконання останнього рядка коду. Це може бути досить важкою ситуацією для управління.
Щоб вирішити цю проблему в однопотоковому JavaScript, браузери надають додаткові API, такі як DOM, Fetch, setTimeout та інші. Ці API браузера дозволяють виконувати блокуючі або повільні операції в інших потоках у середовищі браузера.
Використовуючи ці API, ми можемо делегувати тривалі завдання, такі як мережеві запити чи важкі обчислення, в окремі потоки. Це дозволяє основному потоку JavaScript залишатися доступним для інших операцій, запобігаючи блокуванню коду і забезпечуючи більш ефективну роботу для користувача.
Асинхронні операції (Async Operations)
Розглянемо механізм роботи наступного коду:
console.log("Крок 1")
setTimeout(() => {
console.log("Крок 2")
}, 2000)
console.log("Крок 3")
Тепер загляньмо всередину движка JavaScript і побачимо, що відбувається:
Зображення 1
Крок 1 (console.log(“Крок 1”)
) додається у стек викликів.
Зображення 2
Після завершення коду виводиться “Крок 1” у консолі. Контекст виконання знімається зі стека.
Зображення 3
Зображення 4
Коли стек викликів у JavaScript-движку виявляє асинхронний код, наприклад setTimeout
, він відразу передає його в контейнер Web API. Таймер у Web API відлічує 2000 мс. Тим часом JavaScript-движок продовжує виконання наступного коду. Контекст виконання створюється для “Крок 3” і входить в стек викликів.
Зображення 5
Крок 3 виконується, і в консолі виводиться “Крок 3”. Зрештою, таймер із setTimeout
завершує відлік і додає пов'язане завдання в чергу зворотного виклику (callback queue).
Цикл подій (Event Loop)
У випадку на зображенні 5 наш стек викликів порожній, а в черзі callback queue є завдання. Цикл подій (Event Loop) постійно стежить за цим. Коли він виявляє порожній стек викликів, він послідовно переносить завдання з callback queue у стек викликів.
Зображення 6
Тепер зворотний виклик виконується, і в консолі виводиться “Крок 2”.
Зображення 7
На цьому етапі, оскільки
більше немає коду для виконання, усе в стеку викликів і черзі зворотного виклику знищується.
Час для: