Як працюють таймери та планування в Node.js?
Таймери в Node.js - функції для планування коду у прив'язці до конкретних фаз циклу подій (event loop), а не до точного астрономічного часу.
Теорія
TL;DR
- Чотири функції планування:
setTimeout,setInterval,setImmediate,process.nextTick process.nextTickі виконані Promise є мікрозавданнями - вони запускаються до будь-яких I/O колбеківsetImmediateспрацьовує у фазі перевірки, після I/O poll;setTimeout(fn, 0)- у фазі таймерів- Таймери гарантують мінімальну затримку, а не точний час
- Поза I/O колбеком порядок
setTimeout(fn, 0)іsetImmediateє недетермінованим
Швидкий приклад
console.log('start');
process.nextTick(() => console.log('nextTick')); // мікрозавдання, перший
Promise.resolve().then(() => console.log('Promise')); // мікрозавдання, другий
setTimeout(() => console.log('setTimeout'), 0); // фаза таймерів
setImmediate(() => console.log('setImmediate')); // фаза перевірки
console.log('end');
// Вивід:
// start
// end
// nextTick
// Promise
// setTimeout (може помінятися з setImmediate поза I/O)
// setImmediateЧерга мікрозавдань очищується повністю до того, як цикл подій переходить до наступної фази. Це правило не змінюється.
Як цикл подій планує кожен таймер
Node.js виконує цикл подій за впорядкованими фазами. Кожна функція таймера підключається до своєї.
process.nextTick технічно не є частиною циклу подій. Node очищає чергу nextTick після кожної окремої операції ще до того, як передає керування libuv. Тому рекурсивні виклики nextTick повністю блокують I/O.
Promise.then колбеки потрапляють у чергу мікрозавдань і виконуються одразу після nextTick. З Node.js 11+ мікрозавдання очищаються між кожним окремим колбеком циклу, а не тільки між фазами.
setTimeout(fn, delay) реєструє колбек у фазі таймерів. З delay = 0 реальний мінімум становить приблизно 1мс. Точний момент спрацювання залежить від того, скільки часу займає поточна фаза і якою є роздільна здатність таймера ОС.
setImmediate спрацьовує у фазі перевірки, одразу після завершення poll-фази. Всередині I/O колбека setImmediate завжди спрацьовує раніше setTimeout(fn, 0). Поза ним покладатися на порядок не варто.
setInterval поводиться як повторні виклики setTimeout, але не враховує час виконання колбека. Під навантаженням інтервали зміщуються, бо наступний таймер відраховується від моменту планування, а не від завершення попереднього колбека.
Точність таймерів
Таймери позначають мінімальний поріг, а не відлік годинника:
const start = Date.now();
setTimeout(() => {
console.log(`Затримка: ${Date.now() - start}мс`);
// Запит: 100мс. Факт: 101-115мс, іноді більше під навантаженням
}, 100);Якщо цикл подій зайнятий синхронним завданням, таймер спрацьовує пізніше. Переривати виконуваний синхронний блок неможливо. Це зазвичай дивує команди, які планують важливі задачі через setTimeout і виявляють дрейф лише після першого навантаження.
Промісовані таймери (Node.js 15+)
timers/promises дає awaitable-версії всіх трьох функцій:
const { setTimeout: sleep, setImmediate: immediate } = require('timers/promises');
async function example() {
await sleep(1000); // чекає 1 секунду
await immediate(); // передає керування фазі перевірки
console.log('done');
}Вони також приймають AbortSignal, що робить скасування зрозумілим:
const { setTimeout: sleep } = require('timers/promises');
const ac = new AbortController();
setTimeout(() => ac.abort(), 500);
await sleep(2000, undefined, { signal: ac.signal }); // AbortError через 500мсНе потрібно відстежувати ID таймерів вручну або розкидати clearTimeout по блоках try/catch.
Типові помилки
1. Розрахунок на точний час у виробничих задачах
// Ненадійно
setTimeout(() => sendDailyReport(), 24 * 60 * 60 * 1000);Процес може перезапуститися, цикл подій може бути заблокований, системний годинник може зміститися. Для важливих планових задач використовуй node-cron або чергу повідомлень.
2. Блокування I/O рекурсивним nextTick
function loop() {
process.nextTick(loop); // I/O ніколи не пройде
}
loop();Черга nextTick очищується повністю до I/O. Мережеві операції та читання файлів ніколи не виконаються. Для простого відкладення використовуй setImmediate.
3. Дрейф setInterval під навантаженням
// Колбек виконується 200мс, інтервал 1000мс
// Реальний проміжок: 800мс між кінцем колбека і наступним стартом
setInterval(async () => {
await processQueue(); // виконується змінний час
}, 1000);Коли час виконання колбека важливий, замість setInterval використовуй рекурсивний setTimeout. Запускай наступний таймер після завершення поточного колбека.
4. Припущення що setTimeout(0) завжди перший
setTimeout(() => console.log('A'), 0);
setImmediate(() => console.log('B'));
// Вивід: A B або B A - залежить від роздільної здатності таймера ОСВсередині I/O колбека завжди спочатку B, потім A. Поза ним порядок не гарантований специфікацією.
Де зустрічається в реальному коді
- Debounce:
clearTimeout+setTimeoutпри кожному виклику, для пошукових полів і обробників resize - Exponential backoff: рекурсивний
setTimeoutз затримкоюMath.pow(2, attempt) * 1000між повторами запитів - Розбиття CPU-роботи:
setImmediateміж ітераціями для передачі керування I/O без блокування циклу - Таймаут запиту:
AbortController+setTimeout+clearTimeoutпісля отримання відповіді - Graceful shutdown:
setTimeout(process.exit, 5000)як примусова зупинка після SIGTERM
Питання на співбесіді
Q: Яка різниця між process.nextTick і setImmediate?
A: process.nextTick запускається до переходу циклу подій до будь-якої наступної фази, повністю очищаючи свою чергу. setImmediate спрацьовує у фазі перевірки, після I/O poll. Назви вводять в оману: setImmediate насправді означає "на наступній ітерації циклу".
Q: Чому порядок setTimeout(fn, 0) і setImmediate є недетермінованим поза I/O?
A: Тому що setTimeout з delay 0 встановлює мінімум близько 1мс. Якщо ця 1мс вже минула до перевірки фази таймерів, setTimeout спрацьовує першим. Якщо ні, цикл переходить до фази перевірки і першим спрацьовує setImmediate. Все залежить від роздільної здатності таймера ОС у цей момент.
Q: Чи може process.nextTick заблокувати сервер?
A: Переповнення стека не буде, але I/O заблокується. Якщо продовжувати додавати nextTick колбеки зсередини nextTick, мережеві події та читання файлів ніколи не будуть оброблені. setImmediate безпечніший для простого відкладення виконання.
Q: Як Node.js поводиться між таймерами, коли більше нічого не відбувається?
A: libuv обчислює час до найближчого таймера і блокує poll-фазу рівно на цей час на рівні ОС. Тобто setTimeout(fn, 1000) без іншої роботи не навантажує CPU. Node спить до спрацювання таймера.
Q: Коли варто використовувати timers/promises замість callback API?
A: Коли ти всередині async-функції і потрібне чисте скасування через AbortSignal. Callback API не підтримує AbortSignal і змушує відстежувати ID таймерів вручну по всьому коду.
Приклади
Порядок виконання всіх чотирьох функцій
// Запусти в Node.js щоб перевірити свою ментальну модель
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout(0)'), 0);
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise.then'));
// Гарантовано:
// 1. nextTick
// 2. Promise.then
// 3. setTimeout(0) або setImmediate (недетерміновано поза I/O)
// 4. setImmediate або setTimeout(0)nextTick і Promise.then завжди першими. Між setTimeout(0) і setImmediate покладатися на порядок не варто.
Повтор запиту з експоненційною затримкою
async function fetchWithRetry(url, maxAttempts = 4) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (attempt === maxAttempts - 1) throw err;
const delay = Math.pow(2, attempt) * 1000; // 1с, 2с, 4с
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}Кожна невдала спроба чекає 1с, потім 2с, потім 4с перед наступною. Остання помилка пробрасується вище без змін.
Розбиття CPU-роботи через setImmediate
function processLargeArray(items, callback) {
let index = 0;
function processChunk() {
const end = Math.min(index + 100, items.length);
while (index < end) {
callback(items[index++]);
}
if (index < items.length) {
setImmediate(processChunk); // передаємо керування I/O між чанками
}
}
processChunk();
}Без setImmediate обробка 100 000 елементів блокує цикл подій повністю на весь цей час. З ним HTTP-запити можуть оброблятися між чанками. Загальний час обробки трохи зростає, але сервер залишається чуйним.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.