Event Loop в JS
Як ми знаємо, двигун JS (V8) працює в одному потоці, який називається Main Thread. Щоб правильно організувати таку роботу, і щоб нічого не висло, існує Event Loop.
Event Loop, або ж цикл подій - це те, на чому заснований потік виконання в JS - всього лиш нескінченний цикл, який виконує задачі, що до нього приходять.
Перед тим, як приступити до будь-яких черг, треба класифікувати задачі, які потрапляють до Event Loop. Отже, є три типи задач:
- Макротаски - це синхронний код, підвантаження тегів
<script>
, обробка подій, а також частина Browser API:setInterval
,setTimeout
(точніше, функції, що в них передаються)... До цього типу задач відносяться майже всі, тому якщо сумніваємось - вважаємо, що це макрозадача. - Мікротаски - в основному проміси, а точніше функції, які виконуються в
then
,catch
,finally
, а також з використаннямawait
, тому що це теж обробка проміса. - Задачі рендера - все, що стосується рендеру сторінки:
Style Recalculate
,Layout
,Paint
...
Для кожного з цих 3 типів задач існує своя черга: Macrotasks queue
, Microtasks queue
та Render queue
. Завдання в ці черги потрапляють відразу ж, як вони зустрічаються в коді (ну чи в коді іншого файлу - коли ми курсор пересунули, наприклад), але це в жодному разі не означає, що вони виконуються миттєво. Ці черги якраз створені, щоб імітувати багатопоковість.
Тепер можемо подивитись на схему:
Як бачимо, є 3 різні черги і один нескінченний цикл, до якого потрапляють задачі з цих черг. А Event Loop власне просто перекидає в Call Stack те, що потрапляє до нього.
Виходить, вся багатопотоковість тут у тому, щоб правильно розставити пріоритети задач у Event Loop'і. Це зараз і розглянемо.
Але спочатку фрагмент коду. В якому порядку стрічки будуть виведені в консоль?
setTimeout(() => console.log("timeout"), 0); Promise.resolve().then(() => console.log("promise")); console.log("code");
Цю задачу дуже люблять давати на співбесідах. Поки ви думаєте, розпишу загальний алгоритм роботи Event Loop'а:
- Виконуємо одну макротаску;
- Виконуємо послідовно всі мікротаски;
- Виконуємо послідовно всі задачі рендеру (цей етап може бути оптимізований браузером, якщо він вирішить, що рендер на конкретній ітерації не потрібен).
І так по колу. Звичайно, оскільки всі задачі лежать у чергах, спочатку будуть виконані старші, а потім нові.
Теперь повернемось до фрагменту коду. Ось відповідь:
"code" "promise" "timeout"
Давайте розберемось, чому так. Спочатку V8 проходиться по всьому коду (виконує його як синхронний). Цей етап можна назвати макрозадачею, оскільки ця задача по суті - виконання коду в тезі <script>
. А теперь давайте подивимось, що ми робимо, поки виконуємо цю макрозадачу:
setTimeout(() => console.log("timeout"), 0);
- ми передаємо у функцію з Browser API свою функцію і хочемо, щоб вона виконалась одразу ж. Ну браузер так і робить - одразу ж додає її в чергу - чергу макрозадач.Promise.resolve().then(() => console.log("promise"));
- тут ми одразу ж резолвимо проміс и хочемо, щоб далі виконалась передана функция. Як і в попередньому випадку, вона повинна виконатись "миттєво". Але це вже буде мікрозадача, тому що це виконання функції вthen()
. Додаємо її у чергу мікрозадач.console.log("code");
- а це звичайний синхронний код.
Виходить, що функції з перших двох виразів потрапили до відповідних черг. А ось третій вираз виконується хоч і після перших двох, але в контексті першої макрозадачі - виконання скрипту. Саме тому рядок "code"
виведеться першим.
Тепер наш код виконався, і перша макрозадача виконана. Виходить, що Event Loop виконав одну макрозадачу, і далі за алгоритмом слід виконувати всі мікрозадачі. Мікрозадача у нас лише одна в черзі - виконання колбека промісу. Виконуємо її та виводимо в консоль "promise"
.
Далі за алгоритмом слідує виконання всіх задач рендеру, але у нас їх немає, тому пропускаємо. Ну і нарешті – знову виконуємо одну макрозадачу. У черзі макрозадач лежить лише колбек setTimeout'у
, виконуємо його і виводимо в консоль "timeout"
.
Навмисне створення макрозадач
Інколи буває корисно навмисне додати в чергу макрозадач яку-небудь функцію. Ось приклад:
<div id="progress"></div> <script> function count() { for (let i = 0; i < 1e9; i++) { i++; progress.innerHTML = i; } } count(); </script>
Тут ми імітуємо складні обчислення – рахуємо до мільярда. Неважко здогадатися, що якщо виконати цей фрагмент коду, вкладка браузера зависне на якийсь час - не реагуватиме на жодні дії користувача. Вся справа в тім, що це одна велика макрозадача. І поки вона не виконається – ніщо інше не може виконуватися.
Насправді бувають приклади дійсно складних операцій. І щоб під час їх виконання браузер не зависав (або щоб мати можливість показати прогрес-бар того, що ми робимо), користуються лайфхаками для поділу цих завдань на дрібніші. Давайте так і зробимо:
<div id="progress"></div> <script> let i = 0; const count = () => { // виконати частину великої задачі do { i++; progress.innerHTML = i; } while (i % 1e6 != 0); if (i < 1e9 - 1e6) { setTimeout(count); } } count(); </script>
Тепер цикл рахує до мільйона замість мільярда. Але після циклу є умова: якщо ми дорахували до мільярда – завершити виконання, а якщо ні – запустити цю ж функцію знову, але вже через setTimeout
. Саме це і є лайфхак із розбиттям макрозадачі на кілька шматків. Тепер сторінка зможе реагувати на наші дії, бо ми розділили одну макрозадачу на тисячу дрібніших, а також ми зможемо бачити зміни в DOM.
Як ми пам'ятаємо, після виконання однієї макрозадачі, виконуються всі мікрозадачі та задачі рендеру, а потім знову одна макрозадача, і так нескінченно. Так ось, якщо ми під час виконання першої макрозадачі, наприклад, клікнули на кнопку, вже ця макрозадача піде в чергу і буде виконана перед тим, як розпочати підрахунок наступного мільйона.
Насправді, цей фрагмент коду можна зробити ще кращим:
<div id="progress"></div> <script> let i = 0; const count = () => { if (i < 1e9 - 1e6) { setTimeout(count); } // виконати частину великої задачі do { i++; progress.innerHTML = i; } while (i % 1e6 != 0); } count(); </script>
Все, що ми змінили - перенесли setTimeout
на початок функції - це дасть нам можливість зекономити десь 4мс на кожному її виклику. Чому? Бо навіть якщо ми передали в setTimeout
значення 0, тобто нульова затримка, все одно перед додаванням колбека до черги макрозадач мине близько 4мс. А так ми їх не втрачаємо і працюємо в цей час, коли в попередньому прикладі нам довелось би чекати.
Такий лайфхак часто використовується при маніпуляціях з фокусом - щоб напевне поставити його туди, куди треба нам, при цьому уникнувши проблем (наприклад, якщо за дефолтом він стрибає на інший елемент).
Навмисне створення мікрозадач
А ось наступний лафхак цілком "легітимний" і потрібний для того, щоб запустити функцію асинхронно (після поточного коду), але до ререндеру (бо мікрозадачі виконуються до рендеру). Для цього достатньо скористатись вбудованою функцією queueMicrotask(func)
, яка одразу ж додасть нашу функцію до черги мікрозадач:
<div id="progress"></div> <script> let i = 0; const count = () => { if (i < 1e9 - 1e6) { queueMicrotask(count); } // виконати частину великої задачі do { i++; progress.innerHTML = i; } while (i % 1e6 != 0); } count(); </script>
Використавши queueMicrotask(func)
, ми отримали такий самий результат, як при виконанні синхронного коду з першого фрагменту - результат зʼявляється в DOM лише після завершення обчислень.
Нащо ж тоді це треба? Ну, насправді, тут вже кейси доволі специфічні. Детальніше можете ознайомитись ось тут: https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide, особисто мені сподобалась секція Batching Operations.
Кілька слів про Web Workers
Для тривалих важких обчислень, які не повинні блокувати цикл подій, ми можемо використовувати Web Workers. Це спосіб виконання коду в іншому, паралельному потоці.
Web Workers можуть обмінюватись повідомленнями с основним процесом, але они мають свої змінні і свой цикл подій. При цьому вони не мають доступу до DOM, тому основне їх застосування – обрахунки. Вони дозволяють використовувати кілька ядер процесора одночасно.
Висновки
Двигун JS - V8 - працює в одному потоці, який має назву Main Thread.
Event Loop - це нескінченний цикл, який є основою потоку виконання в JS, і який працює з задачами.
Щоб забезпечити імітацію багатопотоковості, задачі в JS поділяються на кілька типів: макрозадачі, мікрозадачі і задачі рендеру. Для кожного типу задач існує одноіменна черга.
Макрозадачі - основний тип задач - це синхронні задачі. До них входять виконання тегів<script>
- всього коду, виконання обробників подій, а також частина Browser API:setTimeout
,setInterval
...
Мікрозадачі - це асинхронні задачі. До них входять виконання колбеків промісів (then
,catch
,finally
), а також функції, які виконуються черезawait
.
Задачі рендеру - це все, що стосується ререндеру сторінки.
Алгоритм роботи Event Loop'a такой:
1. Виконуємо одну макрозадачу з черги макрозадач;
2. Виконуємо всі мікрозадачі з черги мікрозадач;
3. Виконуємо всі задачі рендеру з черги задач рендеру.
Для поділу важких обчислень користуємось setTimeout'ом
з нульовою затримкою, щоб дати можливість у перерві виконатись усім задачам з черг макрозадач, мікрозадач и задач рендеру.
Якщо хочемо виконати функцію асинхронно (після поточного коду), але до рендеру і обробки нових подій - користуємось функцією queueMicrotask
. Це потрібно у специфічних випадках, як-от правильна обробка батчів або кешування.
Для важких обчислень зазвичай використовують HTML Web Workers, які мають свій цикл подій і виконують код у паралельному потоці, що дозволяє не блокувати основний.
Джерела
https://learn.javascript.ru/event-loop
https://habr.com/ru/post/461401/
https://www.digitalocean.com/community/tutorials/understanding-the-event-loop-callbacks-promises-and-async-await-in-javascript