Як працює Event Loop в NodeJS: внутрішня будова, фази та приклади

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Мене звати Олександр Ветошкін, я — розробник у Beetroot. Більшість моєї роботи пов’язана з JavaScript, включаючи NodeJS.

Реалізація Event Loop (циклу подій) є ключовою особливістю платформи NodeJS, яка забезпечує його асинхронну, неблокуючу поведінку. Кожному розробнику, який використовує цю платформу, важливо розуміти як працює Event Loop. Під час більшості співбесід на позицію, що передбачає роботу з NodeJS, обов’язково будуть питання про Event Loop. Тому: як він працює?

Внутрішня будова NodeJS

Реалізація Event Loop знаходиться в бібліотеці libuv, яка разом з V8 є основою NodeJS. Node не працює за принципом виділення окремого потоку для кожного запиту, натомість, весь JavaScript код виконується в єдиному потоці, в якому опрацьовується цикл подій.

Операційні системи надають інтерфейси для роботи з I/O операціями асинхронно, які libuv за можливості використовує замість окремих потоків, так само виконується робота з базами даних та іншими системами. Бібліотека libuv створює пул потоків для виконання асинхронних операцій, які використовуються тільки чотирма вбудованими частинами NodeJS, а саме модулями fs, crypto, zlib та для DNS-пошуку. Всі інші операції використовують основний потік, в якому опрацьовується цикл подій.

При старті, Node ініціалізовує Event Loop, виконує початковий код, що запускає таймери, робить запити і т.д., а тоді переходить до опрацювання циклу подій.

Фази Event Loop

Щоб розуміти Event Loop потрібно розуміти які є фази та що відбувається на кожній з них. В libuv є 7 фаз:

  1. timers
  2. pending callback
  3. idle
  4. prepare
  5. poll
  6. check
  7. close callbacks

Дві фази не використовуються в NodeJS, а саме idle та prepare.

Кожна з фаз представляє собою FIFO-чергу колбеків, коли Event Loop переходить в конкретну фазу, він виконує операції пов’язані з нею та колбеки в черзі, поки вони не закінчаться або поки максимальна їх кількість не виконається, після цього він переходить до наступної фази.

Чим є кожна з фаз?

timers

На цій фазі запускається код, запланований за допомогою setTimeout() та setInterval(). Це відбудеться якомога швидше після заданого проміжку часу, однак операційна система або інші колбеки можуть його затримати. NodeJS тільки гарантує, що він виконається не раніше заданого таймауту. Тобто запис setTimeout(() => console.log('log'), 1000) означає, що колбек виконається після закінчення 1000 мілісекунд, це може бути 1004мс, 1100мс або навіть більше.

pending callbacks

На цій фазі виконуються I/O колбеки, відкладені з попередньої ітерації циклу. Наприклад, помилки, колбеки яких не були виконані раніше через спроби системи їх виправити. Так, помилки ECONNREFUSED повідомлення TCP сокету на деяких *nix системах можуть будуть опрацьовані на цій фазі.

idle, prepare

В Node не використовуються. На цій фазі виконуються внутрішні операції libuv та підготовка до poll фази.

poll

Фаза poll розраховує на скільки вона повинна заблокувати Event Loop і очікувати події та, власне, опрацьовує ці події.

Якщо Event Loop перейшов в poll фазу і немає коду, запланованого за допомогою setImmediate(), то буде виконано її колбеки, поки вони не закінчаться або поки не досягнеться їх ліміт. Якщо черга пуста, Event Loop буде очікувати на нові події і після цього одразу їх опрацює.

Якщо Event Loop перейшов в poll фазу і її черга пуста, але раніше викликався setImmediate(), то poll фаза буде пропущена і цикл одразу перейде до фази check.

check

На цій фазі виконується код, запланований за допомогою setImmediate(). Це дозволяє виконати певний код одразу після poll фази. Коли фаза poll стає неактивною і є код, поставлений в чергу за допомогою setImmediate(), Event Loop перейде в check фазу, замість очікування нових I/O подій.

close callbacks

Остання фаза, протягом якої виконуються закриваючі колбеки, наприклад, socket.on('close'). Якщо обробник або сокет закрився неочікувано, на цій фазі буде виконано подію close, в іншому випадку вона буде запущена через process.nextTick().

Між кожною ітерацію циклу Node перевіряє чи очікуються нові події — якщо ні, то процес завершується.

setTimeout(), setInterval(), setImmediate(), process.nextTick(), Promise

В NodeJS є п’ять способів безпосередньої роботи з порядком виконання коду: setTimeout(), setInterval(),setImmediate(), process.nextTick()та Promise.

setTimeout(), setInterval()

Код, запланований за допомогою цих двох функцій, виконується після заданого проміжку часу на фазі timers. Різниця між цими двома функціями тільки в тому, що setInterval() після виконання буде заплановано знову. Вони корисні, коли потрібно виконати код через заданий проміжок часу або виконувати його з певним інтервалом.

setImmediate()

Функція setImmediate дозволяє запланувати код, який виконається одразу після фази poll на check фазі. Оскільки setImmediate()та setTimeout()/setInterval() виконуються на різних фазах циклу, їх порядок залежить від контексту виконання. Якщо обидві функції викликаються з головного модуля, їх порядок залежить від продуктивності процесу, на який можуть впливати інші запущені процеси.

Тобто, якщо ми запустимо код, що не знаходиться в I/O циклі, порядок може бути непостійним:

// eventloop.js

setTimeout(() => console.log('setInterval'));
setImmediate(() => console.log('setImmediate'));
$ node eventloop.js
setImmediate
setInterval

$ node eventloop.js
setInterval
setImmediate

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

Якщо ж код буде викликатися в I/O циклі, то колбек setImmediate() завжди буде викликатися першим.

// eventloop.js

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('setInterval'));
  setImmediate(() => console.log('setImmediate'));
});
$ node eventloop.js
setImmediate
setInterval

$ node eventloop.js
setImmediate
setInterval

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

Сама функція setImmediate() буває корисна, якщо потрібно виконати код до опрацювання I/O подій, що знаходяться в черзі, оскільки якщо при переході в poll фазу, черга check непорожня, poll буде пропущена, або коли потрібно виконати код одразу після I/O подій, що вже опрацьовуються.

procces.nextTick()

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

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

В прикладі нижче, колбек події constructed не буде викликано:

// eventloop.js

const { EventEmitter } = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('constructed');
  }
}
  
const emitter = new MyEmitter();
  
emitter.on('constructed', () => {
  console.log('constructed');
});
$ node eventloop.js
$ node eventloop.js
$

Натомість наступний код спрацює:

// eventloop.js

const { EventEmitter } = require('events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    process.nextTick(() => {
        this.emit('created');
    });
  }
}
  
const emitter = new MyEmitter();
 
emitter.on('created', () => {
  console.log('created');
});
$ node eventloop.js
created


$ node eventloop.js
created

В NodeJS до версії 11, черга process.nextTick() опрацьовувалася між фазами Event Loop. Але зараз, у версії 11+, код, запланований за допомогою process.nextTick(), буде виконано між колбеками поточної черги фази циклу подій:

// eventloop.js

setTimeout(() => console.log('timeout1'));
setTimeout(() => {
  console.log('timeout2')
  process.nextTick(() => console.log('process.nextTick()')); 
});
setTimeout(() => console.log('timeout3'));
$ node eventloop.js
timeout1
timeout2
process.nextTick()
timeout3

Раніше, цей код вивів б в консоль наступне:

$ node eventloop.js
timeout1
timeout2
timeout3
process.nextTick()

Promise

В NodeJS також є черга мікротасок, її колбеки запускаються одразу після колбеків process.nextTick(), саме в цю чергу і попадають колбеки промісів:

// eventloop.js

setTimeout(() => console.log('timeout1'));
setTimeout(() => {
  console.log('timeout2')
  Promise.resolve()
    .then(() => console.log('promise'));
  process.nextTick(() => console.log('process.nextTick()')); 
});
setTimeout(() => console.log('timeout3'));
$ node eventloop.js
timeout1
timeout2
process.nextTick()
promise
timeout3

Загальний приклад

// eventloop.js

const fs = require('fs');

fs.readFile(__filename, () => {
  console.log('readFile');

  setTimeout(() => console.log('timeout1'));
  setImmediate(() => console.log('immediate1'));
  Promise.resolve()
    .then(() => console.log('Promise.resolve1'));
  process.nextTick(() => console.log('process.nextTick1'));
});

setTimeout(() => console.log('timeout2'));
setImmediate(() => console.log('immediate2'));
Promise.resolve()
  .then(() => console.log('Promise.resolve2'));
process.nextTick(() => console.log('process.nextTick2'));

console.log('sync code');
$ node eventloop.js
sync code
process.nextTick2
Promise.resolve2
timeout2
immediate2
readFile
process.nextTick1
Promise.resolve1
immediate1
timeout1

Чому порядок саме такий?

На початку, NodeJS виконує весь синхронний код, тому sync code повідомлення вивелось в консоль першим, перед цим у відповідні черги попали попередні колбеки. Після виконання синхронного коду NodeJS почав опрацьовувати Event Loop, оскільки в черзі process.nextTick() та черзі мікротасок були колбеки, вони одразу виконалися. Опісля, цикл подій перейшов у фазу timers та було виведено повідомлення timeout2. Перейшовши в фазу poll колбек читання файлу не буде виконано, бо черга фази check непорожня, тому її буде опрацьовано, пропустивши фазу poll.

На другій ітерації циклу більше немає інших колбеків, окрім readFile, який буде опрацьовано на poll фазі, тут і будуть додані нові колбеки. Після цього, будуть опрацьовані колбеки черги nextTick() та мікротасок, і цикл перейде у фазу check, де буде виведено повідомленн immediate1. На третій ітерації циклу, буде виведено повідомлення timeout1.

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

Ресурси

  1. The Node.js Event Loop, Timers, and process.nextTick()
  2. Don’t Block the Event Loop (or the Worker Pool)
  3. What you should know to really understand the Node.js Event Loop
  4. Знай свой инструмент: Event Loop в libuv
  5. Node.JS Architecture — Tutorial
  6. New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)
👍ПодобаєтьсяСподобалось4
До обраногоВ обраному21
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

В документации написано фаза poll будет пропущена только в том случае если она пуста и есть колбэки в фазе check, если же check тоже пуста то Event Loop в фазе poll будет ждать фиксированное время и потом пойдёт дальше.
Если это так то check не может обойти poll, разве что сейчас что-то изменилось в поведении Event Loop ?
А в демонстрации Загальний приклад, immediate2 был первым потому что ThreadPool ещё не поместил collback в poll и из-за непустой check, Loop сразу перешёл на check.
Мои мысли верны ?

EventLoop не будет ждать на колбек чтения файла, потому что очередь check непустая, именно поэтому он и не будет выполнен на этой фазе цикла

тоисть если есть setImmediate то колбеки будут исполнены на след итерации?

Очень толковая и грамотная статья, большое спасибо !

Клево. Все в одном месте. Могу добавить что event loop обычно тебя не волнует пока не начинаются с ним проблемы. Чтобы отдебажить проблемы с event loop можно использовать функции process._getActiveRequests() и process._getActiveHandles() и еще библиотеки wtfnode, why-is-node-running или active-handles.

К примеру вот так можно посмотреть что еще висит:

for (let handle of process._getActiveHandles()) {
    console.log(handle.constructor.name);
}

Можно еще подключить хром к ремоут дебагу и глянуть асинхронный стек прям в девтулзах :)

Отличная статья, хотелось бы в дополнение работу с async функциями

async функції це просто синтаксичний цукор для промісів
Запис:

setTimeout(async () => {
  console.log('timeout')

  Promise.resolve()
    .then(() => console.log('promise'));

  await asyncFoo();

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

Це те саме що:

setTimeout(() => {
  Promise.resolve()
    .then(() => {
 	  console.log('timeout');
      
      Promise.resolve()
        .then(() => console.log('promise'));

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

Підписатись на коментарі