Всередині циклу подій Node.js: глибоке занурення

pic

Дослідження моделі однониткового виконання в Node.js

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

Стратегії високої конкуренції

Загалом рішення для високої конкуренції полягає в наданні багатопотокової моделі. Сервер призначає один потік для кожного запиту клієнта і використовує синхронне введення/виведення. Система компенсує витрати часу на синхронні виклики вводу/виводу через перемикання між потоками. Наприклад, Apache використовує цю стратегію. Оскільки операції вводу/виводу зазвичай є витратними за часом, досягнення високої продуктивності за допомогою цього підходу важко. Однак, він дуже простий і може реалізовувати складну логіку взаємодії.

Насправді, більшість веб-серверів не виконують багато обчислень. Після отримання запитів вони передають ці запити іншим службам (наприклад, для читання з бази даних), чекають на повернення результатів і, нарешті, відправляють результати клієнтам. Тому Node.js використовує модель однониткового виконання для вирішення цієї ситуації. Замість того, щоб призначати потік для кожного запиту, він використовує основний потік для обробки всіх запитів, а потім обробляє операції вводу/виводу асинхронно, уникаючи накладних витрат та складності створення, знищення потоків і перемикання між ними.

Цикл подій

Node.js підтримує чергу подій в основному потоці. Коли отримано запит, він додається до цієї черги як подія, і потім Node.js продовжує отримувати інші запити. Коли основний потік не має запитів (тобто він "вільний"), він починає обробляти події в черзі, щоб перевірити, чи є події для обробки. Є два випадки: для не-I/O завдань основний потік обробляє їх безпосередньо та передає результат на вищий рівень через функцію зворотного виклику; для завдань вводу/виводу він бере потік з пулу потоків для обробки події, задає функцію зворотного виклику і продовжує обробляти інші події в черзі.

Коли завдання вводу/виводу в потоці завершене, виконується вказана функція зворотного виклику, а завершену подію ставлять в кінець черги подій, чекаючи на цикл подій. Коли основний потік знову досягне цієї події, він безпосередньо обробляє її і передає результат. Цей процес називається Циклом Подій (Event Loop), і його принцип роботи показано на малюнку нижче:

pic

Цей малюнок показує загальний принцип роботи Node.js. Зліва направо і згорі вниз, Node.js поділений на чотири шари: шар додатка, шар V8-двигуна, шар API Node, і шар LIBUV.

  • Шар додатка: це шар взаємодії JavaScript. Загальні приклади - це модулі Node.js, такі як http і fs.
  • Шар V8-двигуна: він використовує двигун V8 для аналізу синтаксису JavaScript і взаємодії з API нижчого рівня.
  • Шар API Node: надає системні виклики для верхнього шару модулів, зазвичай реалізованих на C, і взаємодіє з операційною системою.
  • Шар LIBUV: це кросплатформене підґрунтя для реалізації циклів подій, операцій з файлами тощо і є основою Node.js для досягнення асинхронії.

Будь то платформа Linux чи Windows, Node.js внутрішньо використовує пул потоків для виконання асинхронних операцій вводу/виводу, а LIBUV уніфікує виклики для різних відмінностей між платформами. Отже, один потік у Node.js означає, що JavaScript виконується в одному потоці, але це не означає, що весь Node.js є однонитковим.

Принцип роботи

Суть досягнення асинхронії в Node.js полягає в подіях. Тобто кожне завдання розглядається як подія, і асинхронний ефект симулюється через Цикл Подій (Event Loop).
Щоб краще зрозуміти та чітко прийняти цей факт, ми використаємо псевдокод для опису принципу роботи нижче.

1. Визначення черги подій

Оскільки це черга, вона є структурою даних типу перший зайшов — перший вийшов (FIFO). Ми використовуємо масив JS для її опису наступним чином:

/**  
 * Визначення черги подій  
 * Додавання в чергу: push()  
 * Видалення з черги: shift()  
 * Порожня черга: length === 0  
 */  
let globalEventQueue = [];

Ми використовуємо масив для симуляції структури черги: перший елемент масиву — це голова черги, а останній елемент — хвіст. push() додає елемент в кінець черги, а shift() видаляє елемент з голови черги. Таким чином, ми досягаємо простого механізму черги подій.

2. Визначення точки прийому запитів

Кожен запит буде перехоплений і потрапить у функцію обробки, як показано нижче:

/**  
 * Прийом запитів користувача  
 * Кожен запит потрапляє в цю функцію  
 * Передаємо параметри запиту і відповіді  
 */  
function processHttpRequest(request, response) {  
 // Визначаємо об'єкт події  
 let event = createEvent({  
 params: request.params, // Передаємо параметри запиту  
 result: null, // Зберігаємо результат запиту  
 callback: function() {} // Вказуємо функцію зворотного виклику  
 });  
 // Додаємо подію в кінець черги  
 globalEventQueue.push(event);  
}

Ця функція просто пакує запит користувача як подію та додає її в чергу, потім продовжує отримувати інші запити.

3. Визначення циклу подій

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

/**  
 * Основне тіло циклу подій, яке виконується основним потоком при необхідності  
 * Обробляємо чергу подій  
 * Обробляємо не-I/O завдання  
 * Обробляємо I/O завдання  
 * Виконуємо функції зворотного виклику і повертаємо результат  
 */  
function eventLoop() {  
 // Якщо черга не порожня, продовжуємо обробляти  
 while (this.globalEventQueue.length > 0) {  
 // Беремо подію з голови черги  
 let event = this.globalEventQueue.shift();  
 // Якщо це ресурсоємне завдання  
 if (isIOTask(event)) {  
 // Беремо потік з пулу потоків  
 let thread = getThreadFromThreadPool();  
 // Передаємо завдання потоку для обробки  
 thread.handleIOTask(event);  
 } else {  
 // Після обробки не-ресурсоємних завдань, безпосередньо повертаємо результат  
 let result = handleEvent(event);  
 // Нарешті, повертаємо результат через функцію зворотного виклику, і потім V8 повертає до додатку  
 event.callback.call(null, result);  
 }  
 }  
}

Основний потік постійно моніторить чергу подій. Для I/O завдань він передає їх у пул потоків для обробки, а для не-I/O завдань обробляє їх самостійно і повертає результат.

4. Обробка I/O завдань

Після того як пул потоків отримує завдання, він безпосередньо обробляє операцію вводу/виводу, наприклад, зчитування з бази даних:

/**  
 * Обробка I/O завдань  
 * Після виконання додаємо подію в кінець черги  
 * Вивільняємо потік  
 */  
function handleIOTask(event) {  
 // Поточний потік  
 let curThread = this;  
 // Операція з базою даних  
 let optDatabase = function (params, callback) {  
 let result = readDataFromDb(params);  
 callback.call(null, result);  
 };  
 // Виконуємо I/O завдання  
 optDatabase(event.params, function (result) {  
 // Зберігаємо результат у об'єкті події  
 event.result = result;  
 // Після виконання I/O це вже не ресурсоємне завдання  
 event.isIOTask = false;  
 // Знову додаємо цю подію в кінець черги  
 this.globalEventQueue.push(event);  
 // Вивільняємо потік  
 releaseThread(curThread);  
 });  
}

Коли завдання I/O завершене, виконується функція зворотного виклику, результат запиту зберігається в події, і подія знову потрапляє в чергу, чекаючи на цикл. Нарешті, потік звільняється.
Коли основний потік знову обробляє цю подію, він безпосередньо її обробляє.

Підсумовуючи вищезазначений процес, ми можемо сказати, що Node.js використовує лише один основний потік для прийому запитів. Після отримання запитів він не обробляє їх безпосередньо, а додає в чергу подій і продовжує приймати інші запити. Коли потік вільний, він обробляє ці події через Цикл Подій, досягаючи асинхронного ефекту. Звісно, для I/O завдань все ще потрібно використовувати пул потоків на рівні системи для обробки.

Отже, ми можемо просто зрозуміти, що Node.js сам по собі є багатопотоковою платформою, але він обробляє завдання на рівні JavaScript в одному потоці.

CPU-інтенсивні завдання як недолік

Тепер ми маємо просте і чітке розуміння одно-потокової моделі Node.js. Він досягає високої конкуренції та асинхронного I/O через подієву модель. Однак є й речі, з якими Node.js не справляється.

Як згадувалося раніше, для I/O завдань Node.js передає їх в пул потоків для асинхронної обробки, що є ефективним і простим. Тому Node.js підходить для обробки I/O-інтенсивних завдань. Але не всі завдання є I/O-інтенсивними. Коли Node.js стикається з CPU-інтенсивними завданнями, тобто операціями, які покладаються лише на обчислення CPU, такими як шифрування і дешифрування даних (node.bcrypt.js), стиснення і розпакування даних (node-tar), Node.js буде обробляти їх по черзі. Якщо попереднє завдання не завершено, наступні завдання повинні чекати. Як показано на малюнку нижче:


pic

У черзі подій, якщо попередні завдання з обчислення CPU не завершені, наступні завдання блокуються, що призводить до повільної реакції. Якщо операційна система має один процесор, це може бути терпимим. Але зараз більшість серверів мають багатоядерні або багатопроцесорні системи, а Node.js має лише один EventLoop, що означає, що він використовує лише один CPU-ядро. Коли Node.js займає CPU-інтенсивні завдання, змушуючи інші завдання бути заблокованими, інші ядра залишаються невикористаними, що призводить до марнотратства ресурсів.

Отже, Node.js не підходить для CPU-інтенсивних завдань.

Сценарії використання

  • RESTful API: Запити та відповіді вимагають лише невеликої кількості тексту і не потребують складної логіки обробки. Тому можна обробляти десятки тисяч підключень одночасно.
  • Чат-сервіс: Легкий, з високим трафіком і без складної обчислювальної логіки.

Leapcell: Наступне покоління безсерверної платформи для веб-хостингу, асинхронних завдань та Redis


pic

Нарешті, познайомлю вас з платформою, яка найбільше підходить для розгортання Node.js сервісів: Leapcell.

1. Підтримка кількох мов

  • Розробка на JavaScript, Python, Go або Rust.

2. Безкоштовне розгортання необмежених проєктів

  • Платіть лише за використання — без запитів немає оплат.

3. Неймовірна економія витрат

  • Оплата за фактичне використання без витрат на простої.
  • Приклад: $25 підтримує 6,94 мільйона запитів з середнім часом відповіді 60 мс.

4. Спрощений досвід для розробників

  • Інтуїтивно зрозумілий інтерфейс для легкого налаштування.
  • Повністю автоматизовані CI/CD пайплайни та інтеграція з GitOps.
  • Реальний моніторинг та логування для дійсних висновків.

5. Легка масштабованість та висока продуктивність

  • Автоматичне масштабування для безперешкодної обробки високої конкуренції.
  • Нульові витрати на обслуговування — просто зосередьтесь на створенні.


pic

Дізнайтеся більше в документації!

Leapcell Twitter: https://x.com/LeapcellHQ

Перекладено з: Inside the Node.js Event Loop: A Deep Dive

Leave a Reply

Your email address will not be published. Required fields are marked *