20 лютого 2024 р.

Цикл подій (event loop): мікрозавдання (microtasks) та макрозавдання (macrotasks)

Потік виконання JavaScript в браузері, так само як і в Node.js, базується на циклі подій (event loop).

Розуміння принципу роботи циклу подій важливе для оптимізації, та іноді для правильної архітектури.

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

Цикл подій (event loop)

Концепція циклу подій дуже проста. Існує нескінченний цикл, в якому рушій JavaScript очікує завдання, виконує їх, а потім переходить в режим очікування нових завдань.

Загальний алгоритм рушія:

  1. Поки є завдання:
    • виконати їх, починаючи з найстарішого.
  2. Очікувати поки завдання не з’явиться, потім перейти до пункту 1.

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

Приклади завдань:

  • Коли завантажується зовнішній скрипт <script src="...">, тоді завдання полягає в виконанні цього скрипта.
  • Коли користувач рухає мишкою, тоді завдання згенерувати подію mousemove і виконати її обробники.
  • Коли пройде час, запрограмований в setTimeout, тоді завдання запустити його колбек.
  • …і так далі.

З’являються задачі для виконання – рушій виконує їх – потім очікує нових завдань (майже не навантажуючи процесор в режимі очікування).

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

Чергу з таких завдань називають “чергою макрозавдань” (“macrotask queue”, термін v8):

Наприклад, поки рушій виконує script, користувач може порухати мишкою, що спричинить появу події mousemove, та може вийти час, запрограмований в setTimeout і так далі. Ці завдання сформують чергу, як показано на схемі вище.

Задачі з черги виконуються за правилом “перший прийшов – перший пішов”. Коли рушій браузера закінчить виконання script, він обробить подію mousemove, потім виконає обробник setTimeout тощо.

Доволі просто наразі, чи не так?

Ще декілька деталей:

  1. Рендеринг ніколи не відбувається поки рушій виконує завдання. Не має значення наскільки довго виконується завдання. Зміни в DOM будуть відмальовані лише після завершення завдання.
  2. Якщо виконання завдання займає надто багато часу, браузер не зможе виконувати інші завдання, наприклад, обробляти користувацькі події. Тож після недовгого часу “зависання” з’явиться оповіщення “Сторінка не відповідає” і пропозиція вбити процес виконання завдання разом з цілою сторінкою. Таке трапляється коли код містить багато складних обрахунків або виникає програмна помилка, що створює нескінченний цикл.

Що ж, це була теорія. Тепер побачимо як можна використати ці знання на практиці.

Приклад 1: розбиття ресурсозатратних завдань

Припустимо у нас є завдання, що потребує значних ресурсів процесора.

Наприклад, підсвічування синтаксису (використовується для виділення кольором коду на цій сторінці) доволі важке завдання для процесора. Щоб розмалювати код, процесор його аналізує, створює багато кольорових елементів, додає їх в документ – для великих об’ємів тексту це займає багато часу.

Поки рушій зайнятий підсвічуванням синтаксису він не може виконувати інші речі, пов’язані з DOM, обробляти користувацькі події тощо. Це може спричинити “зависання” браузера, що є неприйнятним.

Ми можемо уникнути проблем шляхом розбивання великого завдання на шматочки. Підсвітити перші 100 рядків, потім поставити setTimeout (з нульовою затримкою) для наступних 100 рядків і так далі.

Щоб продемонструвати такий підхід, замість підсвічування для спрощення візьмемо функцію, яка рахує від 1 до 1000000000.

Якщо ви запустите код нижче, рушій “зависне” на деякий час. Для серверного JS це буде явно видно, а якщо ви запускаєте це в браузері, то спробуйте понатискати інші кнопки на сторінці – ви побачите, що жодна з подій не спрацює поки цей код не завершиться.

let i = 0;

let start = Date.now();

function count() {

  // робимо важку роботу
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("Виконано за " + (Date.now() - start) + 'мс');
}

count();

Браузер навіть може показати повідомлення “скрипт виконується надто довго”.

Давайте розіб’ємо роботу на частини, використавши вкладені виклики setTimeout:

let i = 0;

let start = Date.now();

function count() {

  // робимо частину важкої роботи (*)
  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Done in " + (Date.now() - start) + 'ms');
  } else {
    setTimeout(count); // плануємо новий виклик (**)
  }

}

count();

Тепер інтерфейс браузера повністю робочий під час виконання процесу “обчислення”.

Простий виклик count робить частину роботи (*), і потім планує свій же виклик (**), якщо це необхідно:

  1. Перше виконання обчислює: i=1...1000000.
  2. Друге виконання обчислює: i=1000001..2000000.
  3. …і так далі.

Тепер, якщо з’являється нове стороннє завдання (таке як подія onclick) поки рушій виконує частину 1, воно стає в чергу і виконується після закінчення частини 1, перед наступною частиною. Періодичні повернення в цикл подій між виконанням count дають рушію достатньо “простору”, щоб зробити щось іще, відреагувати на дії користувача.

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

Щоб зменшити цю різницю ще сильніше, давайте внесемо покращення.

Ми перенесемо планування виклику в початок count():

let i = 0;

let start = Date.now();

function count() {

  // переносимо планування виклику в початок
  if (i < 1e9 - 1e6) {
    setTimeout(count); // плануємо новий виклик
  }

  do {
    i++;
  } while (i % 1e6 != 0);

  if (i == 1e9) {
    alert("Виконано за " + (Date.now() - start) + 'мс');
  }

}

count();

Тепер коли ми викликаємо count() і бачимо, що нам потрібно викликати count() ще, ми плануємо це негайно, ще перед тим як виконувати роботу.

Якщо ви запустите це, то легко зауважите, що виконання займає значно менше часу.

Чому?

Все просто: як ви знаєте, в браузера є мінімальна затримка в 4мс при багатьох вкладених викликах setTimeout. Навіть якщо ми встановимо 0, насправді це буде 4ms (або трохи більше). Тож чим раніше ми заплануємо виклик – тим швидше виконається код.

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

Приклад 2: індикація прогресу

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

Як зазначено раніше, зміни в DOM відмальовуються лише після завершення поточного завдання, не важливо як довго воно виконується.

З одного боку, це чудово, тому що наша функція може створити багато елементів, додати їх один за одним в документ і змінити їх стилі – користувач не побачить жодного “недоробленого”, незакінченого стану. Це важливо, чи не так?

Розглянемо приклад, в якому зміни i не буде видно поки функція не завершиться, тож ми побачимо лише останнє значення:

<div id="progress"></div>

<script>

  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }

  count();
</script>

…Але можливо ми хочемо показати щось під час виконання завдання, наприклад індикатор прогресу.

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

Це вже виглядає краще:

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // зробити шматочок важкої роботи (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e7) {
      setTimeout(count);
    }

  }

  count();
</script>

Тепер <div> показує i, яке поступово збільшується, щось схоже на індикатор прогресу.

Приклад 3: виконання чогось після події

В обробнику подій ми можемо вирішити відкласти певні дії поки подія не вспливе і не буде оброблена на всіх рівнях. Ми можемо зробити це огорнувши код в setTimeout з нульовою затримкою.

В розділі Запуск користувацьких подій ми бачили приклад: кастомна подія menu-open генерується через setTimeout для того, щоб виконатись після того як подія “click” буде повністю оброблена.

menu.onclick = function() {
  // ...

  // створюємо кастомну подію з даними клікнутого пункту меню
  let customEvent = new CustomEvent("menu-open", {
    bubbles: true
  });

  // асинхронно згенерувати кастомну подію
  setTimeout(() => menu.dispatchEvent(customEvent));
};

Макрозавдання (Macrotasks) та Мікрозавдання (Microtasks)

Разом з макрозавданнями (macrotasks), описаними в цьому розділі, існують мікрозавдання (microtasks), описані в розділі Мікрозадачі.

Мікрозавдання приходять лише з нашого коду. Їх зазвичай створюють проміси: виконання обробника .then/catch/finally стає мікрозавданням. Мікрозавдання також використовуються “під капотом” await, так як це форма обробки проміса.

Також існує спеціальна функція queueMicrotask(func), яка ставить func в чергу мікрозавдань.

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

Наприклад, подивіться:

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

Який тут буде порядок виконання?

  1. code буде показано першим, тому що це звичайний синхронний виклик.
  2. promise буде показано другим, тому що .then проходить через чергу мікрозадач, і виконується після поточного синхронного коду.
  3. timeout буде показано останнім, тому що це макрозавдання.

Більш детальна ілюстрація циклу подій виглядає так (порядок з верху до низу: спочатку script, потім мікрозавдання, рендеринг і так далі):

Всі мікрозавдання завершуються до обробки будь-яких подій чи рендерингу чи виконання інших макрозавдань.

Це важливо, тому що це гарантує, що середовище застосунку залишається незмінним між мікрозадачами (не змінились координати мишки, не з’явились нові дані через мережу тощо).

Якщо ми хочемо виконати функцію асинхронно (після поточного коду), але до відображення змін чи обробки нових подій, ми можемо запланувати її за допомогою queueMicrotask.

Наступний приклад показує індикатор прогресу, схожий на попередній, але queueMicrotask використовується замість setTimeout. Зауважте, що відмалювання відбувається лише в самому кінці. Так як і з синхронним кодом:

<div id="progress"></div>

<script>
  let i = 0;

  function count() {

    // зробити частину великого завдання (*)
    do {
      i++;
      progress.innerHTML = i;
    } while (i % 1e3 != 0);

    if (i < 1e6) {
      queueMicrotask(count);
    }

  }

  count();
</script>

Підсумки

Більш детальний алгоритм циклу подій (хоч і спрощений порівняно зі специфікацією):

  1. Обрати і виконати найстаріше завдання з черги макрозавдань (наприклад, “script”).
  2. Виконати всі мікрозавдання:
    • Поки черга мікрозавдань не пуста:
      • Обрати з черги і виконати найстаріше мікрозавдання.
  3. Відмалювати зміни, якщо вони є.
  4. Якщо черга макрозавдань пуста, зачекати поки макрозавдання з’явиться.
  5. Перейти до кроку 1.

Щоб додати в чергу нове макрозавдання:

  • Використайте setTimeout(f) з нульовою затримкою.

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

Також це використовується в обробниках подій, щоб відкласти дію до моменту повної обробки події (вспливання завершене).

Щоб запланувати нове мікрозавдання

  • Використайте queueMicrotask(f).
  • Також обробники промісів виконуються в черзі мікрозавдань.

Жодні UI або мережеві події не обробляються між мікрозавданнями: мікрозавдання виконуються негайно одне за одним.

Тому queueMicrotask можна використати для асинхронного виконання фунції, але в одному й тому ж стані середовища.

Web Workers

Для довгих важких обчислень, які не повинні блокувати цикл подій, ми можемо використати Web Workers.

Це спосіб запустити код в іншому, паралельному потоці.

Web Workers можуть обмінюватись повідомленнями з основним процесом, але вони мають власні змінні і власний цикл подій.

Web Workers не мають доступу до DOM, тож вони корисні переважно для обчислень. Вони можуть використовувати декілька ядер процесора одночасно.

Завдання

важливість: 5
console.log(1);

setTimeout(() => console.log(2));

Promise.resolve().then(() => console.log(3));

Promise.resolve().then(() => setTimeout(() => console.log(4)));

Promise.resolve().then(() => console.log(5));

setTimeout(() => console.log(6));

console.log(7);

У консолі ми побачимо: 1 7 3 5 2 6 4.

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

Подивимося, що відбувається, крок за кроком.

console.log(1);
// Перший рядок виконується негайно і виводить `1`.
// На даний момент черги макрозадач і мікрозадач порожні.

setTimeout(() => console.log(2));
// `setTimeout` додає зворотний виклик функції до черги макрозадач.
// - вміст черги макрозадач:
//   `console.log(2)`

Promise.resolve().then(() => console.log(3));
// Зворотний виклик функції додається до черги мікрозадач.
// - вміст черги мікрозадач:
//   `console.log(3)`

Promise.resolve().then(() => setTimeout(() => console.log(4)));
// Зворотний виклик функції із `setTimeout(...4)` додається до мікрозадач
// - вміст черги мікрозадач:
//   `console.log(3); setTimeout(...4)`

Promise.resolve().then(() => console.log(5));
// Зворотний виклик додається до черги мікрозадач
// - вміст черги мікрозадач:
//   `console.log(3); setTimeout(...4); console.log(5)`

setTimeout(() => console.log(6));
// `setTimeout` додає зворотний виклик функції до макрозадач
// - вміст черги макрозадач:
//   `console.log(2); console.log(6)`

console.log(7);
// Негайно виводить 7.

Підведемо підсумки,

  1. Числа 1 і 7 з’являються відразу, тому що прості виклики console.log не використовують жодних черг.
  2. Потім, після завершення основного потоку коду, запускається черга мікрозадач.
    • Він містить команди: console.log(3); setTimeout(...4); console.log(5).
    • З’являються числа 3 і 5, а setTimeout(() => console.log(4)) додає виклик console.log(4) в кінець черги макрозадач.
    • Черга макрозадач тепер така: console.log(2); console.log(6); console.log(4).
  3. Після того як черга мікрозадач стає порожньою, виконується черга макрозадач. І він виводить 2, 6, 4.

Нарешті, маємо у консолі: 1 7 3 5 2 6 4.

Навчальна карта