Перейти до основного вмісту

Event Loop в Node.js

Event Loop в Node.js: архітектура, фази, черги

Event Loop — це серце асинхронності в Node.js. Якщо ви знайомі з Java, то знаєте про багатопотоковість та Thread Pool. Node.js працює інакше: замість створення окремих потоків для кожного запиту, він використовує єдиний потік і Event Loop для обробки всіх операцій.

Порівняння з Java/Spring Boot

Java/Spring Boot (традиційна модель):

  • Кожен HTTP-запит обробляється окремим потоком з Thread Pool
  • Якщо потік блокується на I/O операції (база даних, файл), він чекає
  • Обмежена кількість потоків (наприклад, 200) = максимум 200 одночасних запитів

Node.js (асинхронна модель):

  • Один основний потік (Event Loop)
  • I/O операції делегуються системі (libuv), потік не блокується
  • Може обробляти тисячі одночасних підключень на одному потоці
// Java - блокуючий виклик
String data = readFile("file.txt"); // потік чекає
System.out.println(data);

// Node.js - неблокуючий виклик
fs.readFile('file.txt', (err, data) => {
  console.log(data); // виконається пізніше
});
console.log('Продовжую роботу'); // виконається одразу

Архітектура Event Loop

Event Loop в Node.js базується на бібліотеці libuv, яка надає асинхронні I/O операції для різних операційних систем.

Основні компоненти:

  • Call Stack — стек викликів JavaScript (як у Java)
  • Node APIs — нативні асинхронні API (fs, http, timers)
  • Event Loop — механізм, що координує виконання коду
  • Callback Queue — черга для callback'ів
  • Microtask Queue — пріоритетна черга для Promise

Фази Event Loop

Event Loop працює циклічно, проходячи через шість фаз у порядку:

   ┌───────────────────────────┐
┌─>│           timers          │ <- setTimeout, setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │ <- I/O callbacks (TCP errors)
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │ <- внутрішні операції
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │ <- нові I/O події, виконання I/O callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │ <- setImmediate callbacks
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │ <- socket.on('close', ...)
   └───────────────────────────┘

1. Фаза Timers

Виконує callback'и, заплановані через setTimeout() та setInterval().

console.log('1. Старт');

setTimeout(() => {
  console.log('3. Timeout виконано');
}, 0);

console.log('2. Синхронний код');

// Вивід:
// 1. Старт
// 2. Синхронний код
// 3. Timeout виконано

Аналогія з Java: схоже на ScheduledExecutorService.schedule(), але без окремого потоку.

2. Фаза Poll

Найважливіша фаза. Тут Event Loop:

  • Отримує нові I/O події
  • Виконує I/O-related callbacks (крім close, timers, setImmediate)
  • Може блокуватися в очікуванні нових подій
const fs = require('fs');

fs.readFile('data.txt', (err, data) => {
  console.log('Файл прочитано'); // Poll phase
});

3. Фаза Check

Виконує callback'и setImmediate(). Ця функція унікальна для Node.js.

setImmediate(() => {
  console.log('Immediate');
});

setTimeout(() => {
  console.log('Timeout');
}, 0);

// Порядок може відрізнятися!
// У I/O циклі setImmediate завжди виконується раніше

Microtasks vs Macrotasks

Це ключова концепція для розуміння пріоритетів виконання.

Macrotasks (Task Queue):

  • setTimeout / setInterval
  • setImmediate
  • I/O операції

Microtasks (Microtask Queue):

  • Promise.then / catch / finally
  • process.nextTick (найвищий пріоритет!)
  • queueMicrotask

Правило виконання: після кожної macrotask Event Loop виконує ВСІ microtasks перед наступною macrotask.

console.log('1. Синхронний');

setTimeout(() => {
  console.log('5. setTimeout (macrotask)');
}, 0);

Promise.resolve().then(() => {
  console.log('3. Promise (microtask)');
});

process.nextTick(() => {
  console.log('2. nextTick (найвищий пріоритет)');
});

setImmediate(() => {
  console.log('6. setImmediate (macrotask)');
});

Promise.resolve().then(() => {
  console.log('4. Promise 2 (microtask)');
});

// Вивід:
// 1. Синхронний
// 2. nextTick (найвищий пріоритет)
// 3. Promise (microtask)
// 4. Promise 2 (microtask)
// 5. setTimeout (macrotask)
// 6. setImmediate (macrotask)

process.nextTick — особливий випадок

process.nextTick() має найвищий пріоритет і виконується ДО microtasks.

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

// Вивід:
// nextTick
// Promise

Увага: надмірне використання process.nextTick() може заблокувати Event Loop!

// ПОГАНО - блокує Event Loop
function recursiveNextTick() {
  process.nextTick(recursiveNextTick);
}
recursiveNextTick(); // Event Loop ніколи не перейде до наступної фази

Практичний приклад з HTTP сервером

const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  console.log('1. Запит отримано (Poll phase)');
  
  // Microtask
  Promise.resolve().then(() => {
    console.log('2. Promise - microtask');
  });
  
  // Macrotask
  setTimeout(() => {
    console.log('4. setTimeout - macrotask');
  }, 0);
  
  // process.nextTick
  process.nextTick(() => {
    console.log('3. nextTick - найвищий пріоритет');
  });
  
  // Асинхронна I/O операція
  fs.readFile('data.txt', (err, data) => {
    console.log('5. Файл прочитано (Poll phase, наступна ітерація)');
    res.end(data);
  });
});

server.listen(3000);

Порівняння з Spring Boot @Async

У Spring Boot для асинхронності використовується анотація @Async:

// Spring Boot - окремий потік з ThreadPoolTaskExecutor
@Async
public CompletableFuture<String> processAsync() {
    // Виконується в окремому потоці
    return CompletableFuture.completedFuture("Done");
}

// Node.js - той самий потік, але неблокуюча операція
async function processAsync() {
    // Event Loop делегує I/O, продовжує інші задачі
    const result = await someAsyncOperation();
    return result;
}

Візуалізація черг

┌─────────────────────────────────────────┐
│          Call Stack (основний потік)    │
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│   process.nextTick Queue (пріоритет 1)  │ ← виконується завжди першою
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│   Microtask Queue (пріоритет 2)         │ ← Promise.then/catch
└─────────────────────────────────────────┘
                    ↓
┌─────────────────────────────────────────┐
│   Macrotask Queue (пріоритет 3)         │ ← setTimeout, setImmediate, I/O
└─────────────────────────────────────────┘

Поширені помилки

1. Блокування Event Loop синхронним кодом:

// ПОГАНО - блокує Event Loop
const data = fs.readFileSync('large-file.txt'); // синхронна операція

// ДОБРЕ - не блокує Event Loop
fs.readFile('large-file.txt', (err, data) => {
  // асинхронна операція
});

2. Нерозуміння порядку виконання:

// Код
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));

// Багато хто думає: timeout, promise
// Насправді: promise, timeout (microtask має пріоритет!)

Коли використовувати що

Механізм Використання
setTimeout / setInterval Затримка виконання, періодичні задачі
setImmediate Виконання після завершення поточної фази Poll
process.nextTick Критично важливі операції, що мають виконатися негайно (використовуйте обережно!)
Promise Асинхронні операції, що повертають результат

Практичні рекомендації

  • Уникайте синхронних операцій у продакшені (readFileSync, execSync)
  • Використовуйте Promise/async-await замість callback'ів
  • Обмежуйте process.nextTick — він може заблокувати Event Loop
  • Для CPU-intensive задач використовуйте Worker Threads або винесіть в окремий сервіс
  • Моніторте Event Loop lag за допомогою бібліотек як blocked або event-loop-lag

Висновки

Event Loop — це те, що робить Node.js ефективним для I/O-intensive додатків. Розуміння його роботи критично важливе для написання продуктивного коду.

Ключові відмінності від Java:

  • Node.js: один потік + асинхронність = висока пропускна здатність для I/O
  • Java: багато потоків + блокування = простіша модель, але менша ефективність для I/O
  • Node.js погано підходить для CPU-intensive задач (криптографія, обробка зображень)
  • Java/Spring Boot краще для складної бізнес-логіки з багатопотоковістю

Коментарі

Популярні публікації

Шпаргалка по базових командах PostgreSQL

1. Підключення до PostgreSQL через командний рядок: psql -h <host> -p <port> -U <username> -d <database> 2. Підключення до бази без параметрів (якщо користувач і база мають однакове ім’я): psql 3. Показати список усіх баз даних: \l 4. Підключитися до іншої бази даних: \c <database_name> 5. Показати список таблиць у поточній базі: \dt 6. Показати всі об'єкти (таблиці, індекси, секвенції): \d 7. Показати таблиці з усіх схем: \dt *.* 8. Переглянути структуру конкретної таблиці: \d <table_name> 9. Виконати SQL-запит (приклад): SELECT * FROM users; 10. Вийти з psql: \q 11. Створити нову базу даних: CREATE DATABASE mydb; 12. Створити нову таблицю: CREATE TABLE users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE ); 13. Додати новий запис: INSERT INTO users (name, email) VALUES ('Іван', 'ivan@example.com'); 14. Оновити дані в таблиці: UPDATE users SET name = 'Петро' WH...

Створення нового Elixir-проєкту

Для створення новго Elixir-проєкту можна використати команду mix new first_project --sup Зрозуміло, що Elixir має бути встановлений раніше. Пояснення команди: mix — це вбудований інструмент для управління проєктами в Elixir (аналог maven у Java чи npm у JavaScript ). new — підкоманда mix, яка створює новий проєкт. first_project — назва твого нового проєкту. Папка з цією назвою буде створена у поточному каталозі. --sup — опціональний прапорець, який додає шаблон структури з Supervision Tree. Це означає, що створений проєкт одразу буде мати структуру, яка підтримує супервізор (супервізор керує життєвим циклом процесів у системі, перезапускаючи їх при падінні). Щоб створити файл з тестом, можна запустити команду із директорії проєкту mix test Приблизний вигляд структури проєкту:

Docker-compose для створення Postgresql бази даних

Docker Compose — це інструмент, який дозволяє визначати та запускати багатоконтейнерні Docker-застосунки. Замість того, щоб вручну запускати кожен контейнер із довгими командами docker run, docker-compose.yml надає простий спосіб описати всю архітектуру додатка у вигляді YAML-файлу. Це дозволяє легко створювати, запускати, зупиняти та масштабувати сервіси за допомогою однієї команди, що значно спрощує розробку, тестування та розгортання застосунків. Основні можливості Docker Compose включають: запуск кількох контейнерів одночасно, визначення мережі та спільних томів між контейнерами, налаштування змінних середовища та автоматичне підключення сервісів один до одного через імена сервісів. Він особливо корисний для локального середовища розробки, CI/CD-процесів і навіть невеликих продакшен-рішень, де потрібно швидко відтворити середовище для тестування або демонстрації. Мінімальний docker-compose.yml для локального використання PostgreSQL без збереження даних після видалення контейне...

Шпаргалка по запуску та збірці Spring Boot-проєктів

Maven + Spring Boot 1. Збірка проєкту (із завантаженням залежностей, компіляцією, запуском тестів та створенням артефакту) mvn clean install 2. Збірка артефакту без встановлення у локальний репозиторій mvn package 3. Збірка без тестів mvn clean package -DskipTests 4. Запуск Spring Boot-проєкту mvn spring-boot:run 5. Запуск із активним профілем Spring Boot mvn spring-boot:run -Dspring-boot.run.profiles=dev 6. Запуск із параметрами mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8081 --spring.profiles.active=prod" 7. Запуск з jar-файлу java -jar target/your-app-name.jar 8. Запуск тестів mvn test 9. Запуск, якщо pom.xml у підкаталозі mvn -f шлях/до/pom.xml spring-boot:run 10. Запуск із Maven-профілем (не плутати з Spring Boot профілем) mvn clean install -P dev Gradle + Spring Boot 1. Збірка проєкту (з компіляцією, тестами та створенням jar) ...

Прості типи даних в Elixir

Мова Elixir має низку простих (примітивних) типів даних, які часто використовуються в повсякденному програмуванні. Числа Elixir підтримує цілі (integer) та дійсні числа (float). # Цілі числа a = 42 b = -7 # Дійсні числа c = 3.14 d = -0.001 Булеві значення Elixir має два булевих значення: true та false . x = true y = false z = x and y # false Атоми Атоми — це константи з іменем, що починається з двокрапки. Вони широко використовуються, наприклад, для імен параметрів або станів. :ok :error :running :elixir_is_fun Рядки Рядки в Elixir — це двійкові дані з кодуванням UTF-8, оголошуються в подвійних лапках. name = "Pavlo" greeting = "Привіт, #{name}!" Nil Nil — це спеціальне значення, що позначає "відсутність значення". value = nil is_nil(value) # true Бінарні дані та байти Бінарні дані оголошуються в подвійних лапках або як бінарні літерали. string = "Привіт" # це рядок, але також бінарні дані binary = ...