Skip to main content

Event Loop: мікрозадачі vs макрозадачі

Event Loop (цикл подій) - це планувальник задач в JavaScript: спочатку виконується весь синхронний код, потім повністю вичерпується черга мікрозадач (Promise, queueMicrotask), за потреби відбувається рендеринг, потім береться одна макрозадача (setTimeout, I/O), і так по колу.

Теорія

TL;DR

  • Аналогія: один кухар (call stack), термінові замовлення (мікрозадачі), звичайні замовлення (макрозадачі). Усі термінові виконуються перед тим як береться наступне звичайне.
  • Мікрозадачі виконуються пачкою після кожного синхронного блоку. Макрозадачі по одній, між ними браузер може малювати.
  • Мікрозадачі блокують рендеринг, якщо постійно ставлять одна одну в чергу. Одинарний setTimeout цього не робить.
  • Потрібен результат до наступного paint? Мікрозадача. Може почекати кадр? Макрозадача.

Швидкий приклад

javascript
console.log('1. Sync'); setTimeout(() => console.log('2. Macrotask'), 0); Promise.resolve() .then(() => console.log('3. Microtask')); console.log('4. Sync'); // Вивід: // 1. Sync // 4. Sync // 3. Microtask // 2. Macrotask

Синхронний код виконується зверху вниз. Потім мікрозадачі. Потім макрозадачі - навіть якщо затримка в таймері 0ms.

Головна різниця

Черга мікрозадач завжди вичерпується повністю до того як відбудеться щось інше. Якщо мікрозадача ставить у чергу іншу мікрозадачу, та теж виконається в цьому ж циклі, до будь-якого рендеру чи макрозадачі. З макрозадачами інакше: браузер бере рівно одну за цикл, перевіряє чи треба малювати, і лише потім знову перевіряє мікрозадачі. Саме тому сотня ланцюжкових Promise може заморозити UI, а цикл setTimeout(..., 0) не може.

Коли що використовувати

  • Мікрозадачі: оновлення стану, які мають відобразитись до наступного кадру (React batching, Vue реактивність), вимірювання DOM, валідація що йде в той самий рендер
  • Макрозадачі: таймери, I/O (fetch, читання файлів), обробники подій (click, scroll), все що може поступитися браузеру між кадрами

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

Таблиця порівняння

МікрозадачіМакрозадачі
ПрикладиPromise.then, queueMicrotask(), MutationObserversetTimeout, setInterval, fetch, DOM events
Момент виконанняПісля поточного скрипту, до рендеруПо одній за цикл, після перевірки рендеру
Блокує рендерингТак, якщо їх багатоНі, браузер малює між ними
Поведінка чергиПовністю вичерпується за циклОдна задача за цикл
Коли використовуватиОновлення стану, DOM-читання, валідаціяЗатримки, I/O, взаємодії користувача

Як це працює всередині

Рушій JavaScript тримає дві окремі черги. Після очищення call stack запускається MicrotaskQueue::RunMicrotasks() і це продовжується поки черга порожня. Тільки потім браузер вирішує чи треба перемалювати сторінку. Тільки потім береться одна макрозадача. Мікрозадачі є частиною поточного контексту виконання. Макрозадачі являють собою нову роботу ззовні.

Типові помилки

Помилка 1: вважати що setTimeout(..., 0) виконується одразу

javascript
setTimeout(() => console.log('швидко'), 0); console.log('повільно'); // Вивід: повільно, швидко // setTimeout - макрозадача. Вона завжди чекає поки вичерпається черга мікрозадач.

Якщо потрібно виконати щось одразу після поточного скрипту, використовуй Promise.resolve().then() або queueMicrotask(), а не setTimeout.

Помилка 2: рекурсивні мікрозадачі заморожують UI

javascript
// Погано: браузер ніколи не отримує черги на рендер function starveUI() { function loop() { Promise.resolve().then(loop); // ставить себе у мікрочергу } loop(); } // Виправлення: поступайся через макрозадачу function safeLoop() { function step() { console.log('step'); setTimeout(step, 0); // між кроками браузер може малювати } step(); }

Помилка 3: думати що await в циклі поступається браузеру

javascript
// Неправильно: await resolved Promise - це мікрозадача, рендер не відбудеться async function processItems(items) { for (const item of items) { await Promise.resolve(); // мікрозадача, рендеринг НЕ відбувається expensiveCalculation(item); } } // Правильно: yield через setTimeout, тоді браузер встигає намалювати кадр async function processItems(items) { for (const item of items) { await new Promise(r => setTimeout(r, 0)); // макрозадача, браузер може рендерити expensiveCalculation(item); } }

Помилка 4: ігнорувати вартість MutationObserver у щільних циклах

javascript
// Погано: 1000 мутацій DOM = 1000 колбеків у мікрочерзі до рендеру const observer = new MutationObserver(() => updateUI()); for (let i = 0; i < 1000; i++) { element.textContent = i; } // Виправлення: одна зміна DOM = один колбек element.textContent = 999;

Помилка 5: вважати що Node.js і браузер поводяться однаково

У браузерах і Node.js v11+ мікрозадачі завжди виконуються перед наступною макрозадачею. У Node.js v10 і раніше колбеки таймерів могли виконуватись раніше мікрозадач в певних фазах циклу подій. Якщо підтримуєш старіші версії Node, перевіряй поведінку там, а не роби припущення.

Де це використовується

  • React: збирає кілька setState через Promise.resolve().then() і застосовує їх одним рендером замість кількох
  • Vue: реактивні оновлення проходять через Promise.then() з тієї самої причини
  • Node.js streams: між порціями даних використовуються макрозадачі щоб backpressure не блокував цикл подій
  • Intersection Observer: колбеки виконуються як макрозадачі, тому браузер встигає малювати між спостереженнями
  • Express: async-middleware виконується в черзі мікрозадач у рамках одного запиту

На практиці баг зі starvation зустрічається в непомітній формі: пишеш await somePromise в циклі й думаєш що поступаєшся браузеру, але якщо Promise вже resolved, це мікрозадача. Рендер так і не відбудеться.

Питання на співбесіді

Q: Чому мікрозадачі виконуються раніше макрозадач?
A: Мікрозадачі є роботою поточного контексту виконання (Promise, що вирішується після синхронного коду). Макрозадачі являють нову роботу ззовні (таймер спрацював, прийшла відповідь мережі). Вичерпати мікрозадачі першими означає завершити весь поточний async-код до того як братися за щось нове.

Q: Що відбувається якщо мікрозадача ставить у чергу іншу мікрозадачу?
A: Вона виконується в тому ж циклі. Event loop не переходить до макрозадач поки мікрочерга не спустіє повністю. Саме так і виникає starvation.

Q: Як async/await вписується в цю картину?
A: async/await є синтаксисом над Promise. Коли ти пишеш await, функція зупиняється і її залишок ставиться в мікрочергу. Вона відновлюється як мікрозадача, не як макрозадача.

Q: У чому різниця між queueMicrotask() і Promise.resolve().then()?
A: Обидва ставлять колбек у ту ж мікрочергу. queueMicrotask() не створює об'єкт Promise, тому трохи дешевший. Використовуй його коли не потрібні chaining або обробка помилок.

Q: (Senior) React-застосунок пропускає кадри під час великого оновлення даних. Підозра на накопичення мікрозадач. Що робиш?
A: Переносиш дорогу роботу в макрозадачі через setTimeout(..., 0), браузер встигає рендерити між порціями. У React 18+ обгортаєш несрочні оновлення в startTransition(), що дозволяє React переривати роботу і поступатися браузеру. Для точного контролю requestIdleCallback() запускає роботу тільки коли браузер вільний, а requestAnimationFrame() прив'язує роботу до циклу відмальовування.

Приклади

Базовий: порядок виконання

javascript
console.log('start'); setTimeout(() => console.log('timeout'), 0); Promise.resolve() .then(() => console.log('promise 1')) .then(() => console.log('promise 2')); console.log('end'); // Вивід: // start // end // promise 1 // promise 2 // timeout

Обидва .then() виконуються до setTimeout тому що кожен наступний .then() в ланцюжку ставить нову мікрозадачу одразу після вирішення попередньої. Весь ланцюжок вичерпується до того як макрозадача отримує чергу.

Middle: батчинг стану в React

javascript
function handleClick() { setCount(c => c + 1); // ставиться в мікрочергу setCount(c => c + 1); // ставиться в мікрочергу console.log('обробник виконано'); // стан ще не оновився в цей момент } // React збирає обидва оновлення в мікрочерзі // і застосовує їх разом після завершення обробника. // Результат: один ре-рендер замість двох.

React використовує Promise.resolve().then() всередині щоб зібрати всі зміни стану з одного обробника події і застосувати їх за один прохід рендеру. Без цього кожен setCount викликав би окремий рендер.

Senior: starvation мікрозадач і правильне yielding

javascript
// Погано: повністю заморожує браузер function starve() { function tick() { Promise.resolve().then(tick); } tick(); // Кожна мікрозадача ставить наступну. // Рендер ніколи не відбувається. } // Краще: поступається браузеру між ітераціями function safeYield() { function tick() { // якась робота setTimeout(tick, 0); // браузер може перемалювати між викликами } tick(); } // React 18+: startTransition для несрочних оновлень import { startTransition } from 'react'; startTransition(() => { setItems(buildLargeList()); // React може призупинити і намалювати проміжний стан });

startTransition позначає оновлення як несрочне. React може перервати роботу посередині, намалювати те що є, і продовжити. Сторінка залишається відзивчивою замість зависання на весь час обчислення.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?