Event Loop у JavaScript — чому setTimeout(fn, 0) не спрацьовує одразу
Черга людей у кав'ярні як метафора черги задач у Event Loop JavaScript
Почнемо з задачі, яку дають чи не на кожній співбесіді. Що виведе цей код?
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Якщо твоя відповідь — 1, 2, 3, 4, ця стаття для тебе. Якщо 1, 4, 3, 2 — теж, бо знати правильну відповідь і розуміти, чому вона правильна — різні речі. А різницю між "знаю" і "розумію" на співбесіді видно за 30 секунд.
Розберемо механізм, який керує всім асинхронним кодом у JavaScript — Event Loop. Без магії, крок за кроком.
JavaScript — однопотоковий. І це не баг
У JavaScript один потік виконання. Це означає: в кожен момент часу виконується рівно один шматок коду. Немає двох функцій, які працюють паралельно. Немає "фонових" обчислень у самій мові.
Порівняй: у Java чи C# можна створити 10 потоків, і вони справді працюватимуть одночасно. У JavaScript — ні. Brendan Eich створював мову за 10 днів для простих сценаріїв у браузері, і один потік був свідомим спрощенням: не треба думати про блокування, гонки потоків та інші жахи багатопотоковості.
Але тоді питання: як однопотокова мова обробляє клік користувача, поки чекає відповідь від сервера? Відповідь — Event Loop. Це не частина мови JavaScript (у специфікації ECMAScript його немає!) — це механізм середовища: браузера або Node.js.
Call Stack — стос тарілок
Перший елемент пазла — call stack (стек викликів). Це структура, куди потрапляє кожна функція, яку ти викликаєш. Працює як стос тарілок: нову кладуть зверху, забирають теж зверху.
function washDishes() {
console.log("мию посуд");
}
function makeDinner() {
washDishes();
console.log("готую вечерю");
}
makeDinner();
Ось що відбувається зі стеком:
1. makeDinner() → [makeDinner]
2. викликає washDishes() → [makeDinner, washDishes]
3. викликає console.log → [makeDinner, washDishes, console.log]
4. log завершився → [makeDinner, washDishes]
5. washDishes завершився → [makeDinner]
6. makeDinner завершився → [] (стек порожній)
Ключове правило: поки стек не порожній, більше нічого статися не може. Ні клік, ні таймер, ні відповідь сервера — все чекає. Саме тому "важкий" синхронний цикл підвішує сторінку: стек зайнятий, браузер не може навіть перемалювати екран.
Web APIs — де насправді "цокає" таймер
Тепер головний фокус. Коли ти пишеш setTimeout, сам відлік часу відбувається не в JavaScript.
setTimeout(() => console.log("готово"), 2000);
setTimeout — це не функція мови, це API браузера (у Node.js — свій аналог). Браузер написаний на C++ і цілком собі багатопотоковий. Відбувається таке:
- JavaScript викликає
setTimeout→ передає браузеру колбек і час setTimeoutмиттєво завершується, зникає зі стеку- Браузер (не JS!) відраховує 2 секунди у своєму потоці
- Коли час вийшов — браузер кладе колбек у чергу задач
- JavaScript тим часом виконує далі свій код
Те саме з fetch (мережевий запит робить браузер), обробниками подій (за кліками стежить браузер) і так далі. JavaScript однопотоковий — але він працює в багатопотоковому середовищі, якому делегує всю "довгу" роботу.
Черга задач і сам Event Loop
Отже, браузер відрахував таймер і має колбек. Але він не може просто "вставити" його у виконання — стек, можливо, зайнятий. Тому колбек потрапляє в чергу задач (task queue, ще кажуть macrotask queue).
А далі працює Event Loop — нескінченний цикл із простим правилом:
Якщо call stack порожній — взяти першу задачу з черги і виконати її. Повторювати вічно.
Це буквально все. Легендарний виступ Philip Roberts на JSConf EU 2014 — один з найпопулярніших виступів про JavaScript в історії — пояснює саме цей цикл. Roberts тоді зробив візуалізатор Loupe, де можна покроково побачити, як код проходить через стек і чергу. Дуже раджу поклацати.
Чому setTimeout(fn, 0) — це не "одразу"
Тепер ти бачиш підступ: setTimeout(fn, 0) означає не "виконай негайно", а "поклади в чергу якнайшвидше". А черга дійде до колбека тільки коли:
- Весь поточний синхронний код завершиться (стек спорожніє)
- Всі задачі, що стояли в черзі раніше, виконаються
setTimeout(() => console.log("я з таймера 0мс"), 0);
// синхронний код на ~3 секунди
const start = Date.now();
while (Date.now() - start < 3000) {}
console.log("синхронний код завершився");
// Вивід:
// "синхронний код завершився" (через 3 сек)
// "я з таймера 0мс" (одразу після)
Таймер на 0 мілісекунд спрацював через три секунди. Тепер ти знаєш, чому.
За специфікацією HTML, коли setTimeout вкладені один в одного більш ніж на 5 рівнів, браузер примусово збільшує затримку до мінімум 4 мс. А в неактивних вкладках таймери сповільнюються до 1 разу на секунду і рідше — так браузери економлять батарею. Тому ніколи не покладайся на точність setTimeout.
Мікрозадачі — VIP-черга для Promise
Якби була тільки одна черга, історія була б простою. Але у 2015 році в мову прийшли Promise — і разом з ними друга черга: microtask queue (черга мікрозадач).
Туди потрапляють:
- колбеки
.then(),.catch(),.finally() - код після
awaitв async-функціях - явні виклики
queueMicrotask()
І в цієї черги — абсолютний пріоритет. Правило Event Loop насправді таке:
Після завершення кожної задачі (і взагалі щоразу, коли стек порожніє) — виконати ВСІ мікрозадачі до останньої. І тільки потім брати наступну макрозадачу.
Різниця критична: з макрочерги береться одна задача за раз, а мікрочерга щоразу спорожнюється повністю — навіть якщо мікрозадачі додають нові мікрозадачі.
Розбираємо задачу зі співбесіди покроково
Повернімося до коду з початку:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Крок за кроком:
| Крок | Що відбувається | Стек | Макрочерга | Мікрочерга | Вивід |
|---|---|---|---|---|---|
| 1 | console.log("1") | виконується | — | — | 1 |
| 2 | setTimeout(...) → колбек їде в браузер, миттєво в макрочергу (0 мс) | — | log("2") | — | |
| 3 | Promise.then(...) → колбек у мікрочергу | — | log("2") | log("3") | |
| 4 | console.log("4") | виконується | log("2") | log("3") | 4 |
| 5 | Синхронний код скінчився, стек порожній → спочатку вся мікрочерга | log("3") | log("2") | — | 3 |
| 6 | Мікрочерга порожня → беремо макрозадачу | log("2") | — | — | 2 |
Відповідь: 1, 4, 3, 2. Promise "обганяє" setTimeout не тому, що він швидший — а тому, що його черга пріоритетніша.
Перевір себе на складнішому варіанті:
setTimeout(() => console.log("A"), 0);
Promise.resolve()
.then(() => console.log("B"))
.then(() => console.log("C"));
console.log("D");
Спробуй розписати самостійно, перш ніж читати далі... Відповідь: D, B, C, A. Обидва .then — мікрозадачі, і другий стає в мікрочергу одразу після виконання першого. Обидва виконаються до таймера, бо мікрочерга спорожнюється повністю.
Темний бік мікрозадач: голодування
Пріоритет мікрозадач має ціну. Якщо мікрозадачі нескінченно породжують нові мікрозадачі — макрочерга ніколи не отримає шансу. Це називається starvation (голодування):
// НЕ запускай це на важливій вкладці :)
function loop() {
Promise.resolve().then(loop); // мікрозадача створює мікрозадачу
}
loop();
setTimeout(() => console.log("я ніколи не виконаюсь"), 0);
Сторінка зависне намертво — так само, як від нескінченного while. Між макрозадачами браузер встигає перемалювати екран (рендеринг вбудований у цикл саме там), а от мікрочергу він мусить допити до дна. Нескінченні мікрозадачі = жодного кадру рендеру.
Це, до речі, відповідь на питання "яка різниця між setTimeout(fn, 0) і queueMicrotask(fn)": перший дає браузеру перемалювати екран перед виконанням, другий — ні.
А в Node.js так само?
Майже. Node.js використовує бібліотеку libuv, і його Event Loop складніший — макрозадачі розкладені по фазах (таймери, I/O, check тощо), які цикл проходить по колу. З цікавого:
setImmediate(fn)— виконується у фазі check, після I/Oprocess.nextTick(fn)— черга з пріоритетом навіть вищим за мікрозадачі- Promise-мікрозадачі працюють так само, як у браузері — між фазами
Для співбесіди на фронтенд достатньо знати: браузерна модель "стек → мікрозадачі → макрозадача → рендер" у Node зберігається, тільки макрозадачі там сортуються за типами.
Що з цього треба запам'ятати
- JavaScript однопотоковий, вся "паралельність" — робота середовища (браузера/Node)
setTimeout(fn, 0)= "поклади в чергу", а не "виконай зараз"- Мікрозадачі (Promise, await) завжди виконуються раніше макрозадач (setTimeout, події)
- Мікрочерга спорожнюється повністю, макрочерга — по одній задачі
- Між макрозадачами браузер рендерить; довгий синхронний код і нескінченні мікрозадачі блокують все
Найкращі матеріали для поглиблення: культовий виступ Philip Roberts — What the heck is the event loop anyway? з інтерактивним Loupe, доповідь Jake Archibald — In The Loop про зв'язок циклу з рендерингом та requestAnimationFrame, і стаття Tasks, microtasks, queues and schedules з покроковими анімаціями черг.
Часті питання про Event Loop
Що таке Event Loop у JavaScript простими словами?+
Чому Promise виконується раніше за setTimeout?+
Чи є Event Loop частиною мови JavaScript?+
Що таке макрозадачі та мікрозадачі?+
Чому setTimeout не гарантує точний час?+
Event Loop — з тих тем, які здаються теорією, поки не впіймаєш свій перший баг із "чому воно виконується не в тому порядку". Тепер у тебе є модель, щоб такі баги розбирати за хвилину.
Хочеш закріпити на практиці? Пройди уроки про Promises та Async/Await — а якщо тільки починаєш шлях у JavaScript, ось повна програма курсу від нуля.