Що таке цей самий Node.js?

Що ж таке Node.js? Якщо ви коли-небудь задумувались, як JavaScript зміг вийти з браузера і почати обробляти все — від чат-додатків до бекендів для електронної комерції, ви потрапили за адресою.

У цій статті ми розберемо, чим саме Node.js такий особливий, розкриємо, як він використовує V8, дослідимо його цикл подій, а також зазирнемо за лаштунки асинхронних операцій. Готові глибше зануритись у чудеса Node.js? Тоді почнемо!

pic

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

В основі Node.js лежить легке, відкрито-джерельне середовище виконання, яке запускає JavaScript поза межами браузера, використовуючи движок V8 від Google.
Що відрізняє Node.js, так це його архітектура на основі подій і без блокувань, яка дозволяє ефективно обробляти тисячі одночасних з’єднань. Це робить його ідеальним для реальних додатків, API та будь-яких завдань, що вимагають високої паралельності.

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

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

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

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

Що таке двигун V8?

V8, JavaScript-двигун від Google, написаний на C++, відповідає за компіляцію та виконання JavaScript-коду в віртуальній машині. Кожного разу, коли ви бачите веб-сторінку в Google Chrome, на якій динамічно оновлюється вміст, наприклад, оновлюється новинний потік або змінюється список — це JavaScript в дії, який працює завдяки V8.
Хоча Node.js ефективно обробляє операції вводу/виводу, він покладається на середовище виконання V8 через об'єкт process. Коли ваш додаток масштабуватиметься, важливо розуміти, як налаштувати та оптимізувати середовище V8. Виконавши команду node -h в консолі, буде виведено наступне:

pic

Частина виводу команди node -h

Ми можемо побачити, що список опцій V8 доступний через прапор --v8-options.

Версію V8, яку використовує ваша інсталяція Node, можна переглянути, ввівши:

node -e "console.log(process.versions.v8)"

Однією з особливо потужних опцій конфігурації V8, яку інтегрує Node.js, є --max-stack-size.

Щоб краще зрозуміти, яку гнучкість мають розробники Node.js у налаштуванні середовища виконання JavaScript, давайте розглянемо приклад, який виводить систему на межу її можливостей. Тестування критичних точок системи може допомогти виявити її обмеження та поведінку.
Ось програма, створена для того, щоб спричинити крах V8:

var count = 0;  
(function curse() {  
 console.log(++count);  
 curse();  
})()

Ця самовиконувана функція рекурсивно викликає саму себе безкінечно, збільшуючи розмір стеку викликів з кожною ітерацією. З часом неконтрольоване зростання призведе до краху середовища виконання JavaScript, виводячи помилку RangeError: Maximum call stack size exceeded.

Тут стає очевидним призначення опції --max-stack-size. Її еквівалент у V8, --stack_size, приймає значення в кілобайтах (KB), щоб налаштувати максимальний розмір стеку викликів і запобігти таким крахам.

За замовчуванням V8 виділяє 700 МБ пам'яті на 32-бітних системах і 1400 МБ на 64-бітних системах. У новіших версіях V8 обмеження пам'яті для 64-бітних систем більше не накладаються безпосередньо самим V8, теоретично усуваючи будь-які жорсткі обмеження.
Однак, фактичний ліміт залежить від операційної системи, оскільки саме вона контролює, скільки пам'яті може використовувати процес, що робить точну межу залежною від системи.

JavaScript еволюціонує від однопотокової природи до мови, здатної до багатопоточності. Наприклад, об'єкт Atomics вводить інструменти для координації взаємодії між потоками, а екземпляри SharedArrayBuffer дозволяють читати та записувати дані між потоками. Однак, на даний момент, багатопоточний JavaScript не отримав широкого впровадження в спільноті. Хоча JavaScript сьогодні підтримує багатопоточність, сама мова та її екосистема досі в основному орієнтовані на однопотокову парадигму.

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

Що таке Неблокуючий I/O?

Операції вводу/виводу, такі як доступ до диска та мережі, зазвичай є досить повільними, тому важливо, щоб середовище виконання не блокувало бізнес-логіку під час очікування на читання файлів або відправку повідомлень через мережу. Для цього Node використовує три основні стратегії: події, асинхронні API та неблокуючий I/O. З точки зору розробника Node, неблокуючий I/O гарантує, що ваша програма може запитати мережевий ресурс і продовжувати виконувати інші завдання.
Якщо мережеву операцію завершено, викликається зворотний виклик (callback), щоб обробити результат.

pic

Асинхронні та неблокуючі компоненти в додатку Node

Уявімо собі типове веб-застосування Node.js, що використовує фреймворк Express для управління робочим процесом замовлення в онлайн-магазині. У цьому сценарії браузери надсилають запити на покупку товару, і застосунок виконує кілька завдань: перевіряє наявність товару на складі, реєструє користувача, надсилає електронний лист з чеком і відправляє JSON-відповідь. Одночасно відбуваються й інші процеси, наприклад, надсилання електронного листа з чеком і оновлення бази даних з інформацією про користувача та замовлення. Хоча код виглядає просто як імперативний JavaScript, середовище виконання працює паралельно завдяки використанню неблокуючого I/O.

Наприклад, доступ до бази даних через мережу є неблокуючою операцією в Node.js.
Це стає можливим завдяки бібліотеці під назвою libuv, яка надає доступ до неблокуючих мережевих викликів операційної системи. Libuv обробляє це по-різному в Linux, macOS і Windows, але як розробник, ви взаємодієте лише з вашою JavaScript бібліотекою для бази даних. Наприклад, коли ви пишете код на кшталт db.insert(query, err => {}), Node виконує високооптимізовані, неблокуючі мережеві операції за лаштунками.

Доступ до диска в Node.js працює трохи по-іншому, ніж доступ до мережі. Наприклад, коли генерується електронний чек і зчитується шаблон листа з диска, libuv використовує пул потоків для імітації поведінки неблокуючого виклику.
Управління пулом потоків може бути складним і виснажливим, але для розробників це спрощено до більш інтуїтивно зрозумілих операцій, таких як написання коду email.send('template.ejs', (err, html) => {}).

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

Що таке цикл подій (Event Loop)?

pic

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

Цикл подій (Event Loop) відповідає за виконання зворотних викликів, коли виникають певні події вводу/виводу (I/O), такі як отримання повідомлення на сокеті, виявлення зміни файлу на диску або готовність виконати зворотний виклик для setTimeout(). На більш глибокому рівні операційна система сповіщає програму про те, що подія відбулася. Це викликає бібліотеку libuv в Node.js для обробки події та визначення наступних кроків. Якщо необхідно, подія проходить через API Node.js, зрештою викликаючи зворотний виклик у коді додатку.
Фактично, цикл подій (Event Loop) діє як міст, що дозволяє подіям на низькому рівні в C++ викликати виконання JavaScript коду.

┌───────────────────────────┐  
┌─>│ таймери │  
│ └─────────────┬─────────────┘  
│ ┌─────────────┴─────────────┐  
│ │ очікуючі зворотні виклики │  
│ └─────────────┬─────────────┘  
│ ┌─────────────┴─────────────┐  
│ │ простої, підготовка │  
│ └─────────────┬─────────────┘ ┌───────────────┐  
│ ┌─────────────┴─────────────┐ │ вхідні: │  
│ │ опитування │<─────┤ з'єднання, │  
│ └─────────────┬─────────────┘ │ дані тощо │  
│ ┌─────────────┴─────────────┐ └───────────────┘  
│ │ перевірка │  
│ └─────────────┬─────────────┘  
│ ┌─────────────┴─────────────┐  
└──┤ закриття зворотних викликів │  
 └───────────────────────────┘

Цикл подій в Node.js працює у кількох фазах, кожна з яких виконує певну роль. Хоча деякі фази обробляють внутрішні операції Node.js, інші безпосередньо пов'язані з кодом додатку.
Кожна фаза управляє чергою зворотних викликів, запланованих до виконання, залежно від того, як вони використовуються в додатку. Ось огляд основних фаз:

  • Poll: Ця фаза обробляє зворотні виклики, пов'язані з операціями введення/виведення (I/O), і саме тут виконується більшість коду додатку. Коли основний код починає виконуватися, зазвичай він починається в цій фазі.
  • Check: Зворотні виклики, викликані setImmediate(), виконуються в цій фазі.
  • Close: Ця фаза обробляє зворотні виклики, пов'язані з подіями закриття EventEmitter (Event Listener).
    Наприклад, коли сервер TCP net.Server закривається, він генерує подію close, яка виконує зворотний виклик в цій фазі.
  • Timers: Зворотні виклики, заплановані за допомогою setTimeout() і setInterval(), виконуються в цій фазі.
  • Pending: Ця фаза обробляє спеціальні системні події, наприклад, коли TCP сокет net.Socket отримує помилку ECONNREFUSED.

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

Подія циклу вводить додаткову складність з двома спеціальними чергами мікрозавдань, в які можуть додаватися зворотні виклики під час виконання фази:

  1. Черга мікрозавдань Next Tick: Обробляє зворотні виклики, зареєстровані за допомогою process.nextTick(). Ці зворотні виклики мають найвищий пріоритет і виконуються до всіх інших.
  2. Черга мікрозавдань Promise: Керує зворотними викликами для виконаних або відхилених обіцянок (promises).
    Ці зворотні виклики виконуються після зворотних викликів у черзі наступного такту, але все ж мають пріоритет перед чергою звичайних фаз.

Цикл подій виконує завдання спочатку з черги process.nextTick, потім виконує чергу мікрозавдань обіцянок (Promise), а потім виконує чергу макрозавдань.

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

У фазі poll цикл подій зосереджується на управлінні задачами, пов’язаними з вводу/виводу. Якщо в черзі poll є зворотні виклики, вони виконуються синхронно, поки черга не буде очищена або не буде досягнуто системно визначеного ліміту.
Однак, якщо черга poll є пустою, поведінка залежить від інших запланованих задач:

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

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

const fs = require('fs');  
const EventEmitter = require('events').EventEmitter;  

let pos = 0;  

const messenger = new EventEmitter();  

messenger.on("message", function(msg) {  
 console.log(++pos + " EVENT: " + msg);  
});  

// (A) ПЕРШИЙ КОНСОЛЬ  
console.log(++pos + " FIRST CONSOLE (A)");  

// (B) NEXTTICK  
process.nextTick(function() {  
 console.log(++pos + " NEXTTICK (B)")  
})  

// (C) ШВИДКИЙ ТАЙМЕР  
setTimeout(function() {  
 console.log(++pos + " QUICK TIMER (C)")  
}, 0)  

// (D) ВИРІШЕННЯ ПРОМІСУ  
Promise.resolve().then(() => console.log(++pos + " PROMISE RESOLVE (D)"));  

// (E) ДОВГИЙ ТАЙМЕР  
setTimeout(function() {  
 console.log(++pos + " LONG TIMER (E)")  
}, 10)  

// (F) IMMEDIATE  
setImmediate(function() {  
 console.log(++pos + " IMMEDIATE (F)")  
})  

// (G) ПОВІДОМЛЕННЯ HELLO!  
messenger.emit("message", "MESSAGE (G)");  

// (H) ПЕРШЕ ЗАПИТАННЯ  
fs.stat(__filename, function() {  
 console.log(++pos + " FIRST STAT (H)");  
});  

// ЧИТАННЯ ФАЙЛУ  
fs.readFile(__filename, () => {  
 // (I) КОНСОЛЬ ПРИ ЧИТАННІ ФАЙЛУ  
 console.log(++pos + " READ FILE CONSOLE (I)");  

 // (J) ТАЙМЕР ПРИ ЧИТАННІ ФАЙЛУ  
 setTimeout(() => console.log(++pos + " READ FILE TIMER (J)"), 0);  

 // (K) IMMEDIATE ПРИ ЧИТАННІ ФАЙЛУ  
 setImmediate(() => console.log(++pos + " READ FILE IMMEDIATE (K)"));  

 // (L) NEXTTICK ПРИ ЧИТАННІ ФАЙЛУ  
 process.nextTick(() => console.log(++pos + " READ FILE NEXTTICK (L)"));  
});  

// (M) ОСТАННЄ ЗАПИТАННЯ  
fs.stat(__filename, function() {  
 console.log(++pos + " LAST STAT (M)");  
});  

// (N) ОСТАННІЙ КОНСОЛЬ  
console.log(++pos + " LAST CONSOLE (N)");

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

  1. Синхронна фаза: JavaScript починає виконувати весь синхронний код у порядку його появи. Це включає:
  • (A) Перше повідомлення в консолі.
  • (G) Синхронне emit прослуховувача подій (Event Listener) від EventEmitter.
  • (N) Останнє повідомлення в консолі.

2. Мікрозадачі: Мікрозадачі мають вищий пріоритет за інші фази.

  • (B) process.nextTick виконується першим, оскільки має найвищий пріоритет серед мікрозадач.
  • (D) Розв'язані обіцянки виконуються після process.nextTick, гарантуючи, що PROMISE RESOLVE буде виконано наступним.

3. Фаза таймерів

  • (C) setTimeout з нульовою затримкою виконується після мікрозадач, додаючи та виконуючи QUICK TIMER.

4. Фаза опитування (I/O)

  • (H) та (M) відповідають викликам fs.stat, обробленим у фазі опитування як I/O зворотні виклики.

5. Фаза перевірки

  • (F) setImmediate отримує своє чергу, виконуючись після I/O зворотних викликів з фази опитування.

6. Вкладені зворотні виклики
Після завершення fs.readFile виконуються його вкладені зворотні виклики.
Зверніть увагу, як ці правила зберігають однакову логіку циклу подій:

  • (I) Виконується синхронно після завершення readFile.
  • (L) Вкладений зворотний виклик process.nextTick виконується негайно.
  • (K) setImmediate виконується після, оскільки він знаходиться в фазі перевірки. (setImmediate() завжди буде виконано перед будь-якими таймерами, якщо він запланований в середині циклу I/O, незалежно від кількості таймерів).
  • (J) Вкладений setTimeout виконується після цього.

7.
Фаза Таймерів (Затримка)

  • (E) Затриманий setTimeout(10) нарешті обробляється після всього іншого.

Вивід цієї програми:

1 FIRST CONSOLE (A)  
2 EVENT: MESSAGE (G)  
3 LAST CONSOLE (N)  
4 NEXTTICK (B)  
5 PROMISE RESOLVE (D)  
6 QUICK TIMER (C)  
7 FIRST STAT (H)  
8 LAST STAT (M)  
9 IMMEDIATE (F)  
10 READ FILE CONSOLE (I)  
11 READ FILE NEXTTICK (L)  
12 READ FILE IMMEDIATE (K)  
13 READ FILE TIMER (J)  
14 LONG TIMER (E)

Висновок

Отже, що ми дізналися сьогодні? Окрім того, що Node.js може обробляти більше одночасних з'єднань, ніж ваш улюблений стрімінговий сервіс відео з котиками, ми з'ясували, що він працює на швидкому двигуні V8, який фактично перетворює JavaScript на спортивну машину на гоночній трасі.
Звісно, як і будь-яка високо продуктивна машина, Node.js має кілька корисних прапорців (звертаємо увагу на вас, --max-stack-size), щоб запобігти вибуху вашого стека викликів у чудовому полум'ї рекурсії.

Але справжнє диво полягає в тому, як Node.js використовує свій цикл подій та libuv, щоб керувати задачами вводу/виводу — одночасно обробляючи все від обслуговування даних до надсилання рахунків, все це в однопоточному середовищі, яке ніколи не виглядає виснаженим. Це як мати одного супер-співробітника, який може тримати тисячу завдань у повітрі, не падаючи жодним.

А це лише початок. У нашій наступній статті ми заглибимося в Streams Node.js і дослідимо, як Node може працювати з даними, як професійний жонглер.
Ми також розглянемо протоколи HTTP, WebSockets та gRPC — розкриваючи, як кожна з цих технологій може зробити реальний час обробки даних і комунікації ще більш захоплюючим.

Готові підняти свої навички Node.js на новий рівень? Залишайтеся з нами — попереду нас чекає захоплююча подорож, і ми маємо ще багато цікавих фішок у нашому Node-рукаві!

Перекладено з: What is this thing called Node.js?

Leave a Reply

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