Цикл подій: як виконується асинхронний JavaScript-код в Node.js

  • 28 февраля, 17:05
  • 3867
  • 0

Ви напевно чули про знаменитий цикл подій Node.js і про те, як йому вдається забезпечувати неймовірну швидкість без блокувань, маючи в своєму розпорядженні всього лише один поток виконання коду. Введення-виведення в Node.js подієво кероване, і всі дії виконуються у формі коллбеків - функцій зворотного виклику. Для організації додатку важливо розуміти, в якому порядку цикл подій ці коллбекі запускає. Давайте розбиратися.

Цикл подій: як виконується асинхронний JavaScript-код в Node.js

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

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

Схематичне зображення циклу подій в Node.js

Існує помилкове уявлення, що в Node є тільки одна глобальна черга коллбеків. Насправді, кожна фаза має власну чергу.

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

  1. Таймери

Функції зворотного виклику таймерів (setTimeout, setInterval) зберігаються в купі до того моменту, поки не закінчиться їхній термін дії. Якщо в черзі є кілька таких «прострочених» коллбеків, цикл подій починає викликати їх в порядку зростання затримки, поки вони не закінчаться.

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

  1. I / O коллбекі

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

Наприклад, ви пишете Node-сервер. Порт, на якому ви хочете запустити процес, вже використовується іншим процесом. Node видасть помилку ECONNREFUSED. Деякі * nix-системи можуть очікувати отримання повідомлення про помилку. Такі виклики поміщаються в чергу цієї фази циклу подій.

  1. Очікування / Підготовка

На цій фазі для нас не відбувається нічого цікавого.

  1. Опитування

Цикл подій перевіряє, чи з'явилися в черзі нові асинхронні коллбекі. Тут виконуються майже всі наші функції зворотного виклику крім setTimeout, setInterval, setImmediate і close-функцій.

Якщо на початку цієї фази в черзі вже є зворотні виклики, всі вони будуть виконані по порядку (за раз виконується не більше певної кількості коллбеков).

Якщо черга порожня, можливо кілька варіантів:

  • Якщо в черзі setImmediate щось є, то цикл подій відразу завершить фазу опитування і перейде до фази перевірки. Тут він послідовно виконає всі setImmediate-коллбекі.
  • Якщо черга setImmediate порожня, то буде перевірена черга таймерів. Якщо якийсь таймер вже завершився, то цикл перейде в першу фазу (timers) для виконання функцій зворотного виклику.
  • Якщо ж немає ні подій setImmediate, ні готових таймерів, цикл подій залишиться в poll-стані і чекатиме додавання в чергу нових коллбеків.
  1. Перевірка / setImmediate

До цієї фази цикл подій приходить, коли в фазі опитування не залишилося ніяких зворотних викликів. Тут виконуються коллбекі функції setImmediate.

  1. Close-коллбекі

Останніми в циклі виконуються функції зворотного виклику, пов'язані з раптовими close-подіями, наприклад, socket.on('close', fn) або process.exit().

  1. Мікротаски

Крім того, в Node є ще одна черга мікрозадач. У неї поміщаються коллбекі методу process.nextTick(), мають максимальний пріоритет незалежно від фази циклу подій.

Приклади

setTimeout vs setImmediate

Почнемо з простих таймерів (насправді, вони тільки здаються простими):      

function main() {
  setTimeout(() => console.log('1'), 0);
  setImmediate(() => console.log('2'));
}

main();

Очікуваний вивід цього фрагмента:     

1
2

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

Ви можете справедливо обуритися, що виклик setTimeout з затримкою в 0 секунд не виконується відразу ж. А значить перший коллбек не виконається в фазі таймерів.

Може наш код буде виводити такий результат?  

2
1

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

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

Існує неправильне уявлення, що setTimeout(fn, 0) завжди виконується до setImmediate. Як ми тільки що бачили, це далеко не завжди так. setTimeout завжди має невелику затримку (4-20мс). Якщо вона встигне закінчитися до настання фази таймерів (після реєстрації коллбеків), то виклик буде виконаний. Інакше спочатку викличеться функція, пов'язана з setImmediate. Цю поведінку неможливо передбачити - вона залежить від кількості коллбеків, фази циклу та ін.

I / O-коллбекі і зрушення циклу

Однак якщо ми трохи перепишемо код:

const fs = require('fs');

function main() {
  fs.readFile('./xyz.txt', () => {
    setTimeout(() => console.log('1'), 0);
    setImmediate(() => console.log('2'));
  });
}

main();

Висновок завжди буде наступним:      

2
1

Що тут відбувається?

  • Коли ми викликаємо функцію main (), цикл подій спочатку виконується без фактичного виклику коллбеків. Він бачить функцію fs.readFile і реєструє її зворотний виклик в черзі I / O callbacks. Після цього цикл переходить до реального виконання коду.
  • Він починає з фази таймерів і нічого там не знаходить.
  • У фазі I / O коллбеків теж немає виконаних викликів.
  • Коли операція читання файлу буде завершена, цикл подій виконає її коллбек (в I / O фазі).
  • Потім він перейде в фазу перевірки (setImmediate) і лише потім - на нову ітерацію, в фазу таймерів. Таким чином в I / O-коллбеках setImmediate завжди виконується раніше, ніж setTimeout(fn, 0).

Мікротаски

function main() {
  setTimeout(() => console.log('1'), 50);
  process.nextTick(() => console.log('2'));
  setImmediate(() => console.log('3'));
  process.nextTick(() => console.log('4'));
}

main();  

Коллбекі методу process.nextTick() - це мікрозадачі, які мають пріоритет над усіма фазами. Вони виконуються відразу ж після того, як цикл подій завершить поточну операцію. Тобто після кожної дії цикл перевіряє чергу мікротасків, і якщо в ній щось є - виконує відразу все.

Отже, як працює цей код:

  • Реєструє коллбекі за відповідними чергами.
  • Виконує два мікротаска.
  • Переходить в фазу таймерів, але виклик setTimeout з затримкою в 50 мс ще не готовий.
  • Виконує коллбек setImmediate в фазі перевірки.
  • Зворотній виклик setTimeout виконується вже на наступній ітерації.

2
4
3
1

Асинхронні мікротаски

А що станеться, якщо в метод process.nextTick передати асинхронну функцію?

function main() {
  setTimeout(() => console.log('1'), 50);
  process.nextTick(() => console.log('2'));
  setImmediate(() => console.log('3'));
  process.nextTick(() => setTimeout(() => {
    console.log('4');
  }, 1000));
}

main();        

Результат буде таким:          

2
3
1
4

Розберемося, звідки що взялось:

  • Коллбекі реєструються у відповідних чергах.
  • Виконується перший синхронний мікротаск - в консоль виводиться 2.
  • Починає виконуватися другий мікротаск. Коллбек setTimeout з нього поміщається в чергу фази таймерів.
  • Після цього цикл подій починає працювати в звичайному режимі, починаючи з фази таймерів.
  • Час першого setTimeout (50 мс) ще не минув, рухаємося далі.
  • Виконується коллбек setImmediate в фазі перевірки - в консоль виводиться 3.
  • Починається нова ітерація, цикл повертається в фазу таймерів. Тут залишилися дві функції зворотного виклику setTimeout, які і виконалися по черзі, вивівши в консоль 1 і 4.

Все разом

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

   const fs = require('fs');

   function main() {
    setTimeout(() => console.log('1'), 0);
    setImmediate(() => console.log('2'));

    fs.readFile('./xyz.txt', (err, buff) => {
     setTimeout(() => {
      console.log('3');
     }, 1000);

     process.nextTick(() => {
      console.log('process.nextTick');
     });

     setImmediate(() => console.log('4'));
    });


    setImmediate(() => console.log('5'));

    setTimeout(() => {
     process.on('exit', (code) => {
      console.log(`close callback`);
     });
    }, 1100);
   }

   main();

На зображенні представлена схема роботи циклу подій для цього прикладу:

Номери черг на зображенні - це номери рядків зворотних викликів в коді

Цей фрагмент коду виведе наступний результат:   

1
2
5
process.nextTick
4
3
close callback

Або ось такий - згадуємо перший приклад:      

2
5
1
process.nextTick
4
3
close callback    

Визначення

Мікрозадачі

У Node.js (а точніше в движку V8) існує поняття мікрозадач. Вони є саме частиною движка, а не частиною циклу подій. Крім process.nextTick() до них відноситься, наприклад, метод Promise.resolve().

Мікрозадачі мають пріоритет перед усіма іншими завданнями. Як тільки щось потрапляє в чергу мікрозадач - воно відразу ж виконується (після завершення поточної операції). Але якщо ви помістите багато коллбеків в цю чергу, то можете викликати "голодування" циклу подій.

Макрозадачі

Такі завдання, як setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, рендеринг призначеного для користувача інтерфейсу або інші функції зворотного виклику відносяться до макрозадач. У них немає ніяких пріоритетів, а виконання визначається фазою циклу подій.

Ітерація (tick) циклу подій

Один tick циклу відповідає проходу по всіх фазах і повернення до початку. Він характеризується частотою (кількість ітерацій в одиницю часу) і тривалістю (час витрачений на одну ітерацію).

Джерело переладу 


0 комментариев
Сортировка:
Добавить комментарий