JS
January 12, 2021

Event Loop в JS

Як ми знаємо, двигун JS (V8) працює в одному потоці, який називається Main Thread. Щоб правильно організувати таку роботу, і щоб нічого не висло, існує Event Loop.

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

Перед тим, як приступити до будь-яких черг, треба класифікувати задачі, які потрапляють до Event Loop. Отже, є три типи задач:

  • Macrotask (макрозадача) - також називають просто Task;
  • Microtask (мікрозадача);
  • Render Task.

А тепер детальніше:

  • Макротаски - це синхронний код, підвантаження тегів<script>, обробка подій, а також частина Browser API: setInterval, setTimeout (точніше, функції, що в них передаються)... До цього типу задач відносяться майже всі, тому якщо сумніваємось - вважаємо, що це макрозадача.
  • Мікротаски - в основному проміси, а точніше функції, які виконуються в then, catch, finally, а також з використанням await, тому що це теж обробка проміса.
  • Задачі рендера - все, що стосується рендеру сторінки: Style Recalculate, Layout, Paint...

Для кожного з цих 3 типів задач існує своя черга: Macrotasks queue, Microtasks queue та Render queue. Завдання в ці черги потрапляють відразу ж, як вони зустрічаються в коді (ну чи в коді іншого файлу - коли ми курсор пересунули, наприклад), але це в жодному разі не означає, що вони виконуються миттєво. Ці черги якраз створені, щоб імітувати багатопоковість.

Тепер можемо подивитись на схему:

Різні черги і один Event Loop

Як бачимо, є 3 різні черги і один нескінченний цикл, до якого потрапляють задачі з цих черг. А Event Loop власне просто перекидає в Call Stack те, що потрапляє до нього.

Виходить, вся багатопотоковість тут у тому, щоб правильно розставити пріоритети задач у Event Loop'і. Це зараз і розглянемо.

Але спочатку фрагмент коду. В якому порядку стрічки будуть виведені в консоль?

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => console.log("promise"));

console.log("code");

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

  1. Виконуємо одну макротаску;
  2. Виконуємо послідовно всі мікротаски;
  3. Виконуємо послідовно всі задачі рендеру (цей етап може бути оптимізований браузером, якщо він вирішить, що рендер на конкретній ітерації не потрібен).

І так по колу. Звичайно, оскільки всі задачі лежать у чергах, спочатку будуть виконані старші, а потім нові.

Теперь повернемось до фрагменту коду. Ось відповідь:

"code"
"promise"
"timeout"

Давайте розберемось, чому так. Спочатку V8 проходиться по всьому коду (виконує його як синхронний). Цей етап можна назвати макрозадачею, оскільки ця задача по суті - виконання коду в тезі <script>. А теперь давайте подивимось, що ми робимо, поки виконуємо цю макрозадачу:

  1. setTimeout(() => console.log("timeout"), 0); - ми передаємо у функцію з Browser API свою функцію і хочемо, щоб вона виконалась одразу ж. Ну браузер так і робить - одразу ж додає її в чергу - чергу макрозадач.
  2. Promise.resolve().then(() => console.log("promise")); - тут ми одразу ж резолвимо проміс и хочемо, щоб далі виконалась передана функция. Як і в попередньому випадку, вона повинна виконатись "миттєво". Але це вже буде мікрозадача, тому що це виконання функції в then(). Додаємо її у чергу мікрозадач.
  3. 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