Головна / Статті / Пояснення конкурентності в Node.js: libuv, цикл подій та пул потоків

Пояснення конкурентності в Node.js: libuv, цикл подій та пул потоків

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

2284 слів

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

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

Механізмом цього є libuv — бібліотека на мові C, створена спеціально для Node.js, яка керує асинхронним, неблокуючим введенням та виведенням.

Повне розуміння того, як libuv виконує завдання поза межами потоку JavaScript, — це не просто теоретичні знання. Саме це пояснює, чому запит до бази даних проявляє іншу продуктивність порівняно з операцією хешування, чому зміна однієї змінної середовища може суттєво вплинути на швидкість (або повільність) відповіді вашого продакшн-API, а також як можна виявити та уникнути прихованих «вузьких місць» у ваших сервісах.

Що таке libuv?

Node.js — це не єдиний монолітичний двигун; це набір кількох взаємодіючих шарів:

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google): Цей двигун компілює та виконує ваш JavaScript. Він має лише одну стек викликів та виконує код послідовно, лише в одному потоці.
  • libuv: багатоплатформенна бібліотека, написана на мові C, яка відповідає за цикл подій, пул робочих потоків, доступ до файлової системи, таймери, створення дочірніх процесів та моніторинг мережевих сокетів.
  • Коли люди описують Node.js як однопотоковий, насправді вони мають на увазі те, що контекст виконання JavaScript працює на одному основному потоці. Однак сама libuv написана на мові C та є багатопотоковою за своєю природою. Вона використовує будь-які низькорівневі засоби, які надає операційна система, для одночасного виконання завдань, не блокуючи при цьому виконання JavaScript.

    Два способи, якими libuv обробляє асинхронну роботу

    Часто припускають, що libuv направляє кожну асинхронну операцію у фоновий поток. Це не зовсім так — насправді libuv розподіляє роботу між двома різними стратегіями, залежно від типу завдання:

    • Вбудовані, неблокуючі механізми операційної системи (для вхідних та вихідних операцій у мережі)
    • Внутрішній пул потоків libuv (для доступу до файлової системи, пошуку DNS та криптографічних операцій)

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

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Вхідні та вихідні операції у мережі: примітиви операційної системи

    Сучасні операційні системи постачаються з спеціалізованими, неблокуючими API для роботи з мережевими сокетами:

    • epoll у Linux
    • kqueue у macOS та сімействі BSD
    • IOCP (порти завершення для вхідних та вихідних операцій) у Windows

    Коли додаток Node.js відкриває TCP-приймач або надсилає зовнішній HTTPS-запит, libuv не передає це завдання робочій нитці. Натомість вона безпосередньо реєструє опис файлу сокета в ядрі операційної системи, фактично прослягаючи про сповіщення, як тільки на цьому сокеті з’являться дані або він буде готовий приймати нові дані.

    З цього моменту libuv просто чекає. Саме ядро операційної системи стежить за мережевим обладнанням.

    Як тільки пакети справді надходять на мережевий інтерфейс, ядро створює подію. libuv фіксує її під час етапу опитування свого циклу подій, а потім вставляє відповідний JavaScript-колбек у чергу для виконання. V8 зрештою виймає його з черги та запускає на головній нитці.

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

    2. Введення/виведення файлів та системні операції: пул робочих потоків

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

    Причина в тому, що більшість операційних систем не мають справжнього неблокуючого API для доступу до файлової системи. У системах на основі POSIX, таких як Linux та macOS, звичайні операції з файлами блокують той поток, який їх викликає, доки пристрій зберігання фактично не поверне запитані дані.

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

    Щоб уникнути цієї проблеми, libuv підтримує внутрішній пул робочих потоків.

    Ось що відбувається, коли ваш код викликає fs.readFile():

    • Виклик у JavaScript передається через внутрішні механізми Node до libuv.
    • libuv пакує запит на зчитування файлу як одиницю роботи та додає його до внутрішньої черги завдань.
    • Один із фонових потоків у пулі бере цей запит з черги.
    • Цей потік потім безпечно виконує фактичний блокуючий системний запит — read() або write() — окремо від основного потоку.
  • Як тільки читання завершується, робоча нитка сигналізує про це циклу подій за допомогою механізму сповіщень між нитками.
  • Потім цикл подій планує виконання відповідної функції-відповіді JavaScript на головній нитці, передаючи отриманий буфер.
  • Що насправді виконується у пулі ниток?

    Чотири основні категорії завдань використовують пул ниток libuv:

    • Виклики файлової системи: кожен асинхронний метод з модуля fs, такий як fs.readFile, fs.stat та fs.writeFile.
    • Пошук у DNS: зокрема dns.lookup(), який ґрунтується на блокуючій функції C getaddrinfo(). На відміну від неї, dns.resolve() повністю омине пул ниток та безпосередньо взаємодіятиме з мережею за допомогою неблокуючих викликів.
  • Дорогі криптографічні операції: функції на кшталт crypto.pbkdf2(), crypto.scrypt() та процедури генерації ключів.
  • Процедури стиснення: асинхронні методи zlib, такі як zlib.gzip().
  • Спостереження за роботою пулу потоків

    Ви можете переконатися, що libuv використовує фоновий пул, та побачити його стандартний розмір за допомогою короткого експерименту:

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() є гарним прикладом для тестування, оскільки він навмисно виконує інтенсивні операції на процесорі для створення хешу пароля, і ця робота передається у пул потоків.

    Запустіть скрипт з вашого терміналу:

    node thread-pool-test.js
    

    Результат буде виглядати приблизно так:

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    Чому останні два завдання виконуються вдвічі довше?

    Уважно подивіться на час виконання. Перші чотири завдання закінчуються приблизно в один і той самий момент — близько 220 мс. Але п’яте та шосте завдання виконуються майже 435 мс — майже вдвічі довше.

    Причина в тому, що пул потоків libuv поставляється зі стандартною кількістю 4 потоків.

    Як тільки починається цикл, перші чотири завдання займають усі чотири доступні робочі потоки. П’яте та шосте завдання залишаються у внутрішньому черзі libuv та чекають. Лише коли одне з початкових чотирьох завдань закінчується та звільняє поток, завдання з черги можуть почати виконуватися.

    Налаштування розміру пулу за допомогою UV_THREADPOOL_SIZE

    Ви можете змінити кількість робочих потоків, які створює libuv, встановивши змінну середовища UV_THREADPOOL_SIZE перед запуском процесу Node.js. Вона приймає значення від 1 до 128.

    Спробуйте той самий скрипт знову, цього разу запитавши пул із 8 потоків:

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Результати тепер виглядають інакше:

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

    Якщо є достатньо робочих потоків, усі шість завдань виконуються одночасно, замість того щоб очікувати у черзі.

    Важлива обмеження: ви не можете змінити розмір пулу потоків безпосередньо у вашому скрипті, встановивши process.env.UV_THREADPOOL_SIZE = 8. libuv зчитує та фіксує це значення ще до того, як почне виконуватися ваш JavaScript-код, тому змінювати цю змінну середовища можна лише на рівні шеллу або за допомогою менеджера процесів, який запускає Node, а не безпосередньо всередині самого додатку.

    Поширена проблема у продакшені: спільне використання потоків для різних завдань

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

    • Хвиля входів спричиняє кілька одночасних викликів до crypto.pbkdf2 для перевірки паролів.
    • Усі чотири потоки libuv зараз повністю зайняті обчисленням хешів паролів.
    • У ту саму мить якась інша частина додатку викликає fs.readFile() для завантаження шаблону електронного листа або dns.lookup() для визначення імені хоста вашої бази даних.
    • Обидві ці операції мусять чекати своєї черги в черзі.

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

    Як усунути конкуренцію в пулі потоків:

    • Додайте більше потоків до пулу: якщо ваш сервіс виконує багато операцій введення-виведення файлів чи криптографічних обчислень, підвищення значення UV_THREADPOOL_SIZE до 16 або 32 може зменшити конкуренцію, за умови, що обчислювальна машина має достатню кількість процесорних ресурсів для цього.
    • Виведіть кастомні завдання, залежні від процесора, за межі пулу: для завдань, якими ви керуєте, наприклад створення звітів чи обробка зображень, не намагайтеся передавати їх через libuv. Натомість використовуйте модуль worker_threads, який створює окремі інстанції V8 на власних потоках операційної системи.
    • Уникайте використання dns.lookup(), якщо це можливо: віддавайте перевагу dns.resolve4() або налаштовуйте пули з’єднань, які використовують прямі IP-адреси, щоб резолюція мережевих імен не використовувала обмежені ресурси працівників libuv.

    Які помилки часто роблять розробники

    Помилка перша: припущення, що async/await автоматично перекладає роботу на інший процес

    Додавання префікса async до функції не створює для неї фонової нитки. async/await — це лише більш зручна синтаксисна структура, побудована на основі Promises. Якщо тіло функції містить синхронний цикл або важкі обчислення, цей код все одно виконується безпосередньо на основній нитці JavaScript, і це продовжуватиме блокувати ваш сервер під час його виконання.

    Помилка друга: плутанина між пулом ниток libuv та worker_threads

    • Пул ниток libuv керується внутрішньо за допомогою нативного коду на мові C. Він обробляє вбудовані операції, такі як fs, crypto та zlib. У вас немає можливості додати до цього пулу власні довільні функції на JavaScript.
  • Модуль worker_threads, доступний з Node.js 10.5, є API рівня JavaScript. Він дозволяє виконувати власний код паралельно, причому кожен робітник має власний незалежний двигун V8 та цикл подій.
  • Третя помилка: занадто великий пул потоків

    Є спокуса просто встановити UV_THREADPOOL_SIZE=128 скрізь та вважати, що чим більше, тим краще. Але потоки мають реальні витрати. Кожен з них потребує пам’яті для власної стеку виконання, і коли у вас сотні потоків конкурують за лише два чи чотири ядра CPU, операційна система починає витрачати значну кількість часу лише на їх перемикання.

    Розумною відправною точкою є налаштування розміру пулу відповідно до кількості логічних ядер CPU у випадку, коли навантаження залежить від CPU, наприклад для криптографії чи стиснення, або використання кількості, що вдвічі-учетверо перевищує кількість ядер, коли робота переважно очікує на операції введення-виведення з диска.

    Підсумок

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

    • Виконання JavaScript залишається однопотоковим: логіка додатку виконується крок за кроком, що дозволяє уникнути ситуацій змагання та необхідності в блокуваннях.
    • Мережеві операції виконуються через механізми рівня ОС у libuv: сокети керуються системами опитування ядра, такими як epoll, kqueue або IOCP, і зовсім не використовують робочі потоки.
    • Доступ до файлів, пошук у DNS та криптографічні операції ґрунтуються на пулі потоків: чотири фонові C-потоки обробляють ці блокуючі запити, тож основний поток залишається вільним для прийому нових запитів.

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

    Пов’язана література

  • Node.js Streams Explained: Fixing Out-of-Memory File Crashes — Дізнайтеся, чому завантаження цілих файлів у пам’ять спричиняє збої серверів Node.js, та як потоки для читання, запису, двостороннього обміну та перетворень вирішують цю проблему за допомогою механізму зворотного тиску.
  • Taming Node.js Concurrency: Avoiding API Meltdowns with p-map and Bottleneck — Дізнайтеся, як поєднання p-map та Bottleneck у Node.js допомагає уникнути помилок обмеження швидкості та перевантаження системи шляхом контролю конкурентності та часу обробки запитів.
  • Node.js 26: Temporal API, Map Upserts та Undici 8 — пояснення — Пояснює основні зміни в Node.js 26, спрямовані на роботу з бекендом, включаючи стабільний Temporal API, вбудовані методи Map upsert, покращення продуктивності Undici 8 та зміни, які можуть спричинити проблеми під час оновлення, що потребують перевірки.
  • Розуміння замикань у JavaScript та циклу подій — Дізнайтеся, як замикання зберігають змінні зовнішнього контексту, та як цикл подій організовує виконання стеку викликів, мікрозавдань та макрозавдань за допомогою практичних прикладів коду.