Як працює цикл подій у JavaScript?

Хоча для створення повноцінного робочого коду може знадобитися глибоке знання мов програмування, таких як C++ і C, часто для роботи з JavaScript достатньо лише базового розуміння його можливостей.

Такі ідеї, як передача функцій зворотного виклику або створення асинхронного коду, зазвичай не є складними в реалізації, тому більшість розробників JavaScript не надто заглиблюються у внутрішні процеси. Вони просто не відчувають потреби розуміти тонкощі, які мова програмування від них приховала.

Але як розробнику JavaScript стає все важливішим розуміти, що насправді відбувається “під капотом” і як працюють ці складні механізми, які від нас абстраговані. Це дозволяє нам приймати більш виважені рішення, що може значно підвищити ефективність нашого коду.

Ця стаття присвячена одному з ключових, але часто недостатньо зрозумілих понять у JavaScript: циклу подій!

В JavaScript неможливо уникнути написання асинхронного коду, але що ж насправді означає асинхронний код? Відповідь криється у циклі подій.

Перш ніж ми зможемо зрозуміти, як функціонує цикл подій, нам необхідно з’ясувати, що таке JavaScript і як він працює!

Що таке JavaScript?

Перш ніж рухатися далі, давайте повернемось до самих основ. Що ж таке JavaScript? Можна визначити JavaScript як:

JavaScript – це високорівнева, інтерпретована, однопотокова, неблокуюча, асинхронна мова програмування, що працює паралельно.

Звучить як визначення з підручника, чи не так? 🤔

Давайте розберемося!

Ключові слова для цієї статті: однопотоковий, неблокуючий, паралельний і асинхронний.

Однопотоковий

Потік виконання – це мінімальна послідовність програмних інструкцій, якою може керувати планувальник. Однопотокова мова програмування здатна виконувати лише одне завдання або операцію в конкретний момент часу. Це означає, що вона виконує весь процес від початку до кінця, не перериваючи і не зупиняючи потік виконання.

На відміну від багатопотокових мов, де декілька процесів можуть виконуватися в різних потоках одночасно, не блокуючи один одного.

Але як JavaScript може бути однопотоковим і неблокуючим одночасно?

Що ж означає “блокування”?

Неблокуючий

Не існує чіткого визначення “блокування”. Це просто означає процеси, які повільно виконуються у потоці. Відповідно, неблокуючий означає процеси, які не гальмують виконання в потоці.

Зачекайте, чи не казав я, що JavaScript працює в одному потоці? І що він неблокуючий, тобто завдання виконуються швидко? Як таке можливо? А що з таймерами? Циклами?

Не хвилюйтеся! Ми розберемося трохи пізніше 😉.

Паралельний

Паралельне виконання означає, що код виконується одночасно в кількох потоках.

Ось тут стає по-справжньому заплутано. Як JavaScript може бути однопотоковим і виконувати код паралельно, тобто у кількох потоках?

Асинхронний

Асинхронне програмування означає, що код виконується у циклі подій. Коли відбувається блокуюча операція, запускається подія. Блокуючий код продовжує виконуватися, не гальмуючи основний потік виконання. Після завершення блокуючого коду, результат операцій ставиться у чергу та повертається назад до стеку.

Але в JavaScript є лише один потік? Що ж тоді виконує блокуючий код, дозволяючи іншому коду в потоці працювати?

Перш ніж продовжити, давайте підсумуємо:

  • JavaScript є однопотоковим
  • JavaScript є неблокуючим, тобто повільні процеси не зупиняють його виконання
  • JavaScript є паралельним, тобто код виконується одночасно у кількох потоках
  • JavaScript є асинхронним, тобто блокуючий код запускається в іншому місці.

Але вищезазначене не зовсім пояснює, як однопотокова мова може бути неблокуючою, паралельною та асинхронною?

Давайте заглибимося і розглянемо механізми виконання JavaScript, V8, можливо, там є якісь приховані потоки, про які ми не знаємо.

Рушій V8

Рушій V8 – це високопродуктивний двигун виконання відкритого коду, написаний Google на C++, який використовується для обробки веб-збірки JavaScript. Більшість браузерів використовують двигун V8 для запуску JavaScript, а також популярне середовище виконання Node.js.

Простою мовою, V8 – це програма на C++, яка приймає код JavaScript, компілює його та виконує.

V8 виконує дві основні функції:

  • Виділення пам’яті купи
  • Контекст виконання стеку викликів

На жаль, наші підозри не підтвердилися. V8 має лише один стек викликів, уявіть стек викликів як потік.

Один потік === один стек викликів === одне виконання за раз.

Зображення – Hacker Noon

Оскільки V8 має лише один стек викликів, як JavaScript працює паралельно та асинхронно, не блокуючи основний потік виконання?

Давайте спробуємо розібратися, написавши простий, але поширений асинхронний код і проаналізуємо його разом.

JavaScript виконує кожен рядок коду послідовно (однопотоково). Як і очікувалося, перший рядок виводиться на консоль, але чому останній рядок друкується раніше, ніж код з затримкою? Чому процес виконання не чекає на завершення коду затримки, перед тим як виконати останній рядок?

Схоже, якийсь інший потік допоміг нам виконати цей тайм-аут, оскільки ми майже впевнені, що потік може виконувати лише одне завдання в будь-який момент часу.

Давайте на мить заглянемо у вихідний код V8.

Чекайте, що??!!! У V8 немає функцій таймера, немає DOM? Немає подій? Немає AJAX? … Есссс!!!

Події, DOM, таймери і т.д. не є частиною основної реалізації JavaScript. JavaScript суворо дотримується специфікацій сценаріїв Ecma, а різні його версії часто називають відповідно до специфікацій сценаріїв Ecma (ES X).

Процес виконання

Події, таймери, запити Ajax надаються браузерами на стороні клієнта і часто називаються веб-API. Саме вони дозволяють однопоточному JavaScript бути неблокуючим, паралельним і асинхронним! Але як?

У процесі виконання будь-якої програми JavaScript є три основні компоненти: стек викликів, веб-API та черга завдань.

Стек викликів

Стек – це структура даних, де останній доданий елемент завжди видаляється першим. Його можна представити як стопку тарілок, з якої можна зняти лише верхню тарілку. Стек викликів – це структура даних стека, де завдання або код виконуються послідовно.

Розглянемо наступний приклад:

Джерело – https://youtu.be/8aGhZQkoFbQ

Коли викликається функція `printSquare()`, вона потрапляє у стек викликів, функція `printSquare()` викликає функцію `square()`. Функція `square()` потрапляє у стек і викликає функцію `multiply()`. Функція `multiply()` також потрапляє у стек. Оскільки функція `multiply()` повертає результат і є останньою функцією, яка була передана до стеку, вона першою завершує виконання та видаляється зі стеку, потім функція `square()`, а потім функція `printSquare()`.

Веб API

Тут виконується код, який не обробляється двигуном V8, щоб не блокувати основний потік виконання. Коли стек викликів зустрічає функцію веб-API, процес негайно передається до веб-API, де він виконується, звільняючи стек викликів для інших операцій.

Повернемося до нашого прикладу з `setTimeout`:

Коли ми запускаємо код, перший рядок `console.log` потрапляє до стеку, і результат виводиться майже одразу. Коли досягається тайм-аут, таймери обробляються браузером і не є частиною основної реалізації V8, вони передаються до веб-API, звільняючи стек для інших операцій.

Поки тайм-аут ще триває, стек переходить до наступного рядка дій і виконує останній `console.log`, що пояснює, чому ми отримуємо цей вивід перед виводом таймера. Коли таймер закінчується, щось відбувається. Таймер `console.log` знову з’являється у стеку викликів!

Як?

Цикл подій

Перш ніж ми перейдемо до обговорення циклу подій, давайте розглянемо чергу завдань.

Повернемося до нашого прикладу тайм-ауту. Щойно веб-API завершує виконання завдання, він не автоматично повертається до стеку викликів. Він переходить до черги завдань.

Черга – це структура даних, яка працює за принципом “першим прийшов, першим вийшов”, тому коли завдання надходять до черги, вони виходять у тому ж порядку. Завдання, виконані веб-API, передаються до черги завдань, а потім повертаються до стеку викликів для виведення результату.

Але зачекайте. Що ж таке цикл подій???

Джерело – https://youtu.be/8aGhZQkoFbQ

Цикл подій – це процес, який очікує, поки стек викликів буде очищено, перед тим як передати зворотні виклики з черги завдань до стеку. Коли стек очищено, цикл подій запускається і перевіряє чергу завдань на наявність доступних зворотних викликів. Якщо вони є, він передає їх до стеку викликів, очікує, поки стек викликів знову очиститься, і повторює той самий процес.

Джерело – https://www.quora.com/How-does-an-event-loop-work/answer/Timothy-Maxwell

Наведена вище схема ілюструє базовий робочий процес між циклом подій і чергою завдань.

Висновок

Хоча це досить просте введення, концепція асинхронного програмування в JavaScript дає достатнє розуміння того, що відбувається під капотом і як JavaScript може працювати паралельно та асинхронно, використовуючи лише один потік.

JavaScript завжди доступний для вивчення, і якщо вам цікаво, я б радив вам переглянути цей курс Udemy.