Опубліковано
6 хв читання

Цикл подій Node.js — таймери, Promise та черги

Автори
  • Learna
    Ім’я
    Learna

Node.js

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

Node.js

libuv

libuv — бібліотека з відкритим кодом, написана мовою C. Вона використовує механізми операційної системи для введення-виведення й організації циклу подій. Завдяки цьому Node.js може почати I/O-операцію, не блокуючи потік виконання JavaScript до її завершення.

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

Пул потоків libuv

Навіщо він потрібен? На відміну від звичайного мережевого I/O, де libuv переважно спирається на подієві API системи, деякі блокувальні операції потрібно перенести до пулу потоків. Його використовують, зокрема:

  • асинхронні файлові операції;
  • dns.lookup();
  • асинхронні варіанти crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair();
  • асинхронні API zlib.

За замовчуванням пул має 4 потоки. Його розмір можна налаштувати під час запуску через UV_THREADPOOL_SIZE; верхня межа libuv — 1024.

V8

V8 — рушій JavaScript із відкритим кодом від Google, написаний переважно мовою C++. Він поєднує інтерпретацію та кілька рівнів JIT-компіляції, щоб виконувати й оптимізувати JavaScript. Твердження, що V8 узагалі не має інтерпретатора, було б неправильним.

Цикл подій

Цикл подій (event loop) організований у libuv. Він дає Node.js змогу обробляти неблокувальне I/O, використовуючи механізми ядра системи там, де це можливо. Цикл переходить між фазами й виконує пов’язані з ними колбеки.
У фаз є власні правила й черги обробки. Node.js також перевіряє, чи залишилися активні операції та інші ресурси, що утримують процес живим. Якщо такої роботи немає, процес може завершитися.

Фази циклу подій

timers: колбеки setTimeout() і setInterval() після досягнення порогу часу.
pending callbacks: деякі I/O-колбеки, відкладені до наступної ітерації, зокрема окремі системні помилки.
idle, prepare: внутрішні фази libuv.
poll: отримання нових I/O-подій і виконання відповідних колбеків; за певних умов тут відбувається очікування подій.
check: колбеки setImmediate().
close callbacks: колбеки закриття ресурсів, наприклад socket.on('close', ...).

Схема нижче ілюструє історичну модель фаз. Починаючи з libuv 1.45 / Node.js 20, таймери обробляються після poll, із додатковою перевіркою перед входом у цикл для зворотної сумісності. Не використовуйте стару схему як гарантію точного порядку таймерів. Докладніше — в офіційному описі Node.js.

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Фази докладніше

Для багатьох фаз колбеки виконуються в порядку додавання до черги — FIFO. Цикл обробляє чергу до її спорожнення або до досягнення встановленого ліміту, а тоді переходить далі. Деталі залежать від фази.

Для системного I/O загальна послідовність така: операцію передано системі -> дані готові або операція завершилася -> libuv отримує повідомлення -> виконується відповідний колбек.

timers

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

Черга poll

  1. Якщо в poll уже є колбеки, вони виконуються синхронно до спорожнення черги або системного ліміту.
  2. Якщо черга порожня, цикл може очікувати нові I/O-події, але не довше, ніж дозволяють таймери та інша запланована робота.
  3. Якщо заплановано setImmediate() і черга poll спорожніла, цикл переходить до check замість подальшого очікування I/O. Наявність immediate не означає, що вже готові poll-колбеки просто пропускаються.

check

Саме тут, а не у фазі таймерів, виконуються колбеки setImmediate().

setImmediate()

Якщо setImmediate() і setTimeout(..., 0) заплановані з I/O-колбека, immediate виконається раніше за цей таймер. Натомість порядок цих двох викликів на верхньому рівні скрипту не слід вважати гарантованим.

/**
 * The order of this block depends on the system workload, which could be different for every execution
 */
setImmediate(() => console.log('Immediate'))
setTimeout(() => console.log('Timeout'))

Порядок setImmediate і setTimeout

/**
 * In the below examples setImmediate always gonna be executed before setTimeout
 */

// Example #1
setTimeout(() => {
  // setTimeout inside I/O cycle always gonna be executed after setImmediate
  setTimeout(() => console.log('timeout #1'));
  // setImmediate inside callbacks always gonna be executed before setTimeout
  setImmediate(() => console.log('immediate #1'));
});

// Example #2
fs.readFile('./big.db', () => {
  setTimeout(() => console.log('timeout #2'));
  // Gonna be executed before setTimeout always!!!
  setImmediate(() => console.log('immediate #2'));
});

Виклики таймера та immediate з колбеків

process.nextTick()

Повний обхід циклу часто називають ітерацією або tick. Попри назву, process.nextTick() не чекає наступного повного обходу: його черга обробляється після завершення поточної JavaScript-операції, до продовження циклу подій. Рекурсивне додавання nextTick-колбеків може не давати циклу перейти до I/O.

setImmediate(() => console.log('Immediate'));
setTimeout(() => console.log('Timeout'));
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('process.nextTick'));

Порядок Promise, nextTick, immediate і таймера

Мікрозавдання, макрозавдання та черга process.nextTick

Чим відрізняються setImmediate, setTimeout(cb, 0), process.nextTick() і Promise.then()?

  1. process.nextTick() планує роботу до продовження фаз циклу подій.
  2. setImmediate() планує колбек у фазі check.
  3. setTimeout(cb, 0) задає таймер із мінімальною затримкою, а не негайний виклик. Його порядок відносно immediate залежить від місця планування й стану циклу.
  4. Обробники Promise і queueMicrotask() використовують чергу мікрозавдань V8. Це не те саме, що окрема черга nextTick.

Черги й фази циклу подій

До яких черг належать колбеки?

  • Promise.then() — до черги мікрозавдань.
  • process.nextTick() — до окремої черги nextTick.
  • setTimeout() — до обробки таймерів.
  • setImmediate() — до фази check.

Порядок виконання залежить від контексту.
На верхньому рівні CommonJS Node.js зазвичай спочатку спорожнює nextTick, а потім мікрозавдання. На верхньому рівні ES-модуля сам модуль уже виконується як асинхронна робота, тому Promise-обробники можуть випередити nextTick. Вкладене планування також впливає на порядок. Універсальної послідовності «мікрозавдання → nextTick → check → timers» немає.

Приклади

Нижче збережено приклад оригінальної статті. Його підписи порядку відповідають контексту ES-модуля; у CommonJS process.nextTick #1 випередить Promise. Порядок Immediate і Timeout поза I/O-колбеком теж не є загальною гарантією.

const run = () => {
    console.log("start");                                             // 1

    setTimeout(() => console.log("Timeout"));                         // 6
    setImmediate(() => console.log("Immediate"));                     // 5

    new Promise((resolve) => {
        resolve("Promise");
    }).then((text) => {
      process.nextTick(() => console.log("process.nextTick #0"));     // 4
      console.log(text);                                              // 2
    });

    process.nextTick(() => console.log("process.nextTick #1"));       // 3
};
run();
// start, Promise, process.nextTick #1, process.nextTick #0, Immediate, Timeout

Робота відбувається так:

  1. Синхронно виводиться start.
  2. Плануються таймер і immediate.
  3. Тіло конструктора new Promise() виконується синхронно й викликає resolve.
  4. Обробник .then() потрапляє до черги мікрозавдань, а process.nextTick #1 — до nextTick.
  5. У контексті ES-модуля Promise-обробник виконується до цієї черги nextTick: виводить Promise і додає process.nextTick #0.
  6. Черга nextTick виконує #1, потім #0.
  7. Цикл переходить до роботи immediate і таймерів відповідно до фаз та їхньої готовності.

У CommonJS крок 5 почнеться після process.nextTick #1; це важлива відмінність під час відтворення прикладу.

Починаючи з Node.js 11, nextTick і мікрозавдання обробляються між окремими колбеками таймерів та immediate, навіть якщо у відповідній фазі ще є робота. Подивіться на приклад нижче: next tick з’являється між другим і третім immediate.

setImmediate(() => console.log('timeout1'));            // 1
setImmediate(() => {
    console.log('timeout2')                             // 2
    process.nextTick(() => console.log('next tick'))    // 3
});
setImmediate(() => console.log('timeout3'));            // 4
setImmediate(() => console.log('timeout4'));            // 5

// timeout1, timeout2, next tick, timeout3, timeout4

Останній приклад знову демонструє верхній рівень ES-модуля. Нові Promise-обробники додаються під час спорожнення черги мікрозавдань, а nextTick очікує її завершення. Це не означає, що мікрозавдання завжди мають вищий пріоритет у всіх контекстах Node.js.

process.nextTick(() => console.log(1));                 // 5
Promise.resolve().then(() => console.log(2));           // 1
Promise.resolve()
    .then(() => {
        console.log(3);                                 // 2
        process.nextTick(() => console.log(4));         // 6
        Promise.resolve().then(() => console.log(5));   // 3
    })
    .then(() => {
        console.log(6);                                 // 4
    });

// 2, 3, 5, 6, 1, 4

Дякую за читання! Сподіваюся, ця стаття допомогла розібратися в циклі подій.