Skip to main content

Що таке libuv і як він забезпечує асинхронний ввід/вивід у Node.js?

libuv - це крос-платформна C-бібліотека, яка дає Node.js event loop, пул потоків і асинхронні I/O-інтерфейси, загортаючи OS-механізми epoll на Linux, kqueue на macOS і IOCP на Windows в один уніфікований API.

Теорія

TL;DR

  • libuv знаходиться між JS-кодом Node.js і ОС, приховуючи різниці між платформами за одним API
  • Event loop опитує OS handles щодо завершення I/O без виділення потоку на кожен запит
  • Блокуючі операції (файловий I/O, DNS, crypto) йдуть у пул потоків - за замовчуванням 4 потоки
  • Мережевий I/O (TCP, UDP) минає пул потоків і використовує OS async polling напряму
  • Повільні відповіді під навантаженням crypto або файловим I/O - першим перевіряй UV_THREADPOOL_SIZE

Швидкий приклад

javascript
const fs = require('fs'); // fs використовує libuv під капотом fs.readFile('data.txt', (err, data) => { if (err) throw err; console.log('Файл прочитано:', data.length, 'байт'); // спрацює після завершення I/O }); console.log('Це виконується першим'); // event loop продовжує роботу одразу // Виведення: // Це виконується першим // Файл прочитано: 1048576 байт

fs.readFile передає роботу у пул потоків libuv і одразу повертається. Callback спрацьовує пізніше, коли ОС закінчує читання. JS-потік ніколи не чекає.

Як libuv вписується в Node.js

JS-модулі Node.js (fs, net, crypto) викликають C++ bindings, які викликають функції libuv. Event loop libuv (uv_run) далі або реєструє операцію в OS poller для мережевого I/O, або кладе її в пул потоків для блокуючих операцій.

Node.js (JS / V8) | C++ Bindings | libuv event loop (uv_run) | | OS pollers Thread pool epoll / kqueue (4 потоки за замовчуванням) IOCP (Windows) fs, dns, crypto, zlib

JS-потік циклічно перевіряє, чи є завершена робота, і відправляє callbacks. Він не сидить в очікуванні диску чи мережі.

Пул потоків vs OS polling

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

OS polling (без потоків):

  • TCP/UDP сокети: http.get, net.connect, net.createServer
  • Pipes, signals, таймери

Пул потоків (4 потоки за замовчуванням):

  • Файлова система: fs.readFile, fs.writeFile, fs.stat
  • Crypto: crypto.pbkdf2, crypto.scrypt
  • DNS: dns.lookup (але не dns.resolve)
  • Стиснення: zlib.gzip, zlib.deflate

Мережевий I/O проходить через epoll/kqueue/IOCP без виділення потоку на з'єднання. Саме тому Node.js обслуговує тисячі одночасних HTTP-з'єднань в одному потоці. Про те, як callbacks проходять через фази, детальніше у статті як працює event loop в Node.js.

Фази event loop

Event loop libuv виконується фазами при кожній ітерації. З режимом UV_RUN_DEFAULT цикл такий:

  1. Timers - запускає callbacks setTimeout і setInterval, чий час настав
  2. Pending callbacks - I/O callbacks, відкладені з попередньої ітерації
  3. Idle / Prepare - для внутрішнього використання libuv
  4. Poll - блокується в очікуванні I/O-подій; відправляє готові I/O callbacks
  5. Check - виконує callbacks setImmediate
  6. Close callbacks - cleanup для закритих handles: socket.destroy() тощо

Фаза poll - місце, де loop насправді зупиняється. Якщо є активні I/O-операції, loop чекає тут до сигналу ОС або наближення таймера. setImmediate виконується у фазі check, тому завжди спрацьовує після I/O callbacks тієї ж ітерації.

Handles і requests

libuv використовує дві внутрішні абстракції, які варто знати на senior-рівні.

Handles - довгоживучі об'єкти: TCP-сервер, таймер, watcher файлів. Вони зберігаються між ітераціями loop і тримають процес живим.

Requests - одноразові операції: читання файлу, запис у сокет, DNS-запит. Завершуються один раз і зникають.

javascript
const net = require('net'); const server = net.createServer(() => {}); // Handle - живе до server.close() server.listen(3000); const fs = require('fs'); fs.readFile('./config.json', cb); // Request - одноразовий, не тримає процес живим

Коли дебажиш "чому скрипт завис" - виклич process._getActiveHandles(). TCP-сервер або забутий таймер одразу видно. Детальніше про цей патерн у lifecycle процесу Node.js.

Типові помилки

Sync crypto в обробниках запитів

javascript
// Блокує всі інші запити на ~300мс app.post('/login', (req, res) => { const hash = crypto.pbkdf2Sync(req.body.password, salt, 100000, 512, 'sha512'); // Поки виконується цей рядок, жодні інші запити не обробляються res.send('ok'); });

pbkdf2Sync тримає JS-потік до завершення. При 10 паралельних логінах вони обробляються послідовно. Використовуй async-варіант, який іде в пул потоків:

javascript
crypto.pbkdf2(req.body.password, salt, 100000, 512, 'sha512', (err, hash) => { res.send('ok'); });

Ігнорування виснаження пулу потоків

Чотири потоки здаються достатніми, поки не запустиш хешування паролів під навантаженням. Кожен виклик pbkdf2 займає 200-300мс. При дефолтному пулі 5-й паралельний запит чекає в черзі за першими чотирма. Я бачив, як це додавало дві секунди до p99 latency на сервісі, який виглядав цілком здоровим. Підвищуй UV_THREADPOOL_SIZE до старту процесу:

javascript
// Через env variable перед запуском node: // UV_THREADPOOL_SIZE=32 node server.js // Або на самому початку entry file, до будь-яких require() process.env.UV_THREADPOOL_SIZE = '32';

Встановлення UV_THREADPOOL_SIZE занадто пізно

javascript
// НЕПРАВИЛЬНО - пул може бути вже ініціалізований setTimeout(() => { process.env.UV_THREADPOOL_SIZE = '64'; }, 0);

Пул ініціалізується при першому використанні. Будь-який require модулів crypto, dns або fs до встановлення змінної фіксує дефолтні 4 потоки.

Пошук проблем event loop у V8

Коли API "рандомно зависає", розробники зазвичай дивляться на garbage collection або V8. Але V8 виконує JavaScript, а libuv керує event loop - це різні речі. Реальна причина зазвичай одна з: насичений пул потоків затримує callbacks, тривала sync-операція блокує JS-потік, або handle тримає loop від завершення. Профілюй через clinic.js або 0x. Додай console.log(process.hrtime.bigint()) біля підозрілих callbacks - одразу видно, де зникає час.

Де зустрічається в продакшені

  • Маршрути Express.js з fs.readFile або res.sendFile: читання диску через пул потоків libuv, TCP-відповідь через OS polling
  • Next.js використовує chokidar для hot module replacement; chokidar обгортає file watcher handles libuv
  • node-redis передає TCP-дані через handles libuv без пулу потоків
  • Виклики AWS SDK до S3 або DynamoDB - мережевий I/O, обробляється OS polling напряму
  • Масштабне хешування паролів через crypto.pbkdf2 на рівні продакшн-логінів вимагає підняття UV_THREADPOOL_SIZE

Follow-up питання

Q: Що відбувається з операціями пулу потоків, коли всі 4 потоки зайняті?
A: Нові запити ставляться в чергу всередині libuv і чекають, поки звільниться потік. Затримка зростає пропорційно глибині черги. 5-й паралельний виклик pbkdf2 займає приблизно в 2 рази більше часу ніж перші чотири.

Q: Чому мережевий I/O не використовує пул потоків?
A: TCP і UDP нативно асинхронні на рівні ОС. epoll, kqueue і IOCP підтримують polling тисяч сокетів без блокування потоку. libuv реєструє сокет у OS poller і отримує сповіщення, коли приходять дані. Потоки для цього не потрібні.

Q: В якому порядку виконуються фази event loop?
A: Timers, pending callbacks, idle/prepare, poll, check, close callbacks. Фаза poll блокується в очікуванні I/O. setImmediate виконується у фазі check, тому завжди спрацьовує після I/O callbacks тієї ж poll-ітерації.

Q: Як дебажити event loop, який не завершується?
A: Виклич process._getActiveHandles() і process._getActiveRequests() - побачиш, що тримає його живим. Типово це забутий таймер або відкритий TCP-сокет. У продакшені clinic.js дає flamegraph використання event loop по фазах.

Q (senior): Яка різниця між UV_RUN_DEFAULT, UV_RUN_ONCE і UV_RUN_NOWAIT?
A: UV_RUN_DEFAULT запускає loop до тих пір, поки не залишиться активних handles або requests, блокуючись у poll за потреби. UV_RUN_ONCE виконує одну ітерацію і блокується у poll максимум один раз. UV_RUN_NOWAIT виконує одну ітерацію, але не блокується у poll навіть якщо немає готових подій. Node використовує UV_RUN_DEFAULT. Вбудовування libuv в Electron або шар сумісності Deno іноді вимагає UV_RUN_NOWAIT, щоб не стопорити хост-loop.

Приклади

Базовий: порядок виконання при неблокуючому I/O

javascript
const fs = require('fs'); console.log('1: до readFile'); fs.readFile('./package.json', 'utf8', (err, data) => { if (err) throw err; console.log('3: розмір файлу:', data.length, 'символів'); }); console.log('2: після виклику readFile'); // Виведення: // 1: до readFile // 2: після виклику readFile // 3: розмір файлу: 432 символи

readFile реєструє request у libuv і повертається без очікування. JS-потік доходить до рядка 2 до того, як диск прочитано. Коли ОС сигналізує про завершення, libuv ставить callback в чергу і event loop його виконує. Цей порядок - основа всіх async-патернів у Node.js.

Середній: виснаження пулу потоків в HTTP-сервері

javascript
const crypto = require('crypto'); const http = require('http'); // UV_THREADPOOL_SIZE = 4 за замовчуванням // 5 паралельних викликів pbkdf2 на запит насичують пул const server = http.createServer((req, res) => { const start = Date.now(); let count = 0; for (let i = 0; i < 5; i++) { crypto.pbkdf2('secret', 'salt', 100000, 512, 'sha512', () => { count++; if (count === 5) { res.end(`Зайняло ${Date.now() - start}мс`); } }); } }); server.listen(3000); // Перші 4 виклики pbkdf2 виконуються паралельно в 4 потоках // 5-й чекає в черзі - загальний час приблизно подвоюється // Рішення: UV_THREADPOOL_SIZE=8 node server.js

Звернись до цього endpoint і побачиш стрибок часу на 5-му завданні. Подвоєння розміру пулу вдвічі скорочує очікування. Це найпоширеніша продакшн-проблема, безпосередньо пов'язана з пулом потоків libuv.

Просунутий: lifecycle handles і час завершення процесу

javascript
const net = require('net'); const fs = require('fs'); // Handle тримає процес живим const server = net.createServer(() => {}); server.listen(4000); // Request сам по собі не тримає процес живим fs.readFile('./data.txt', () => { console.log('файл прочитано'); }); // Без server.close() процес ніколи не завершиться: // TCP handle досі активний у event loop libuv setTimeout(() => { server.close(() => { console.log('сервер закрито - процес може завершитись'); }); }, 2000); console.log('активних handles:', process._getActiveHandles().length); // 2: сервер + таймер

libuv тримає uv_run активним поки є активні handles. Читання файлу - це request, не handle, тому воно не блокує вихід. TCP-сервер - це handle. Закрий його і loop завершиться. Розуміння різниці між handles і requests економить години при дебагінгу "чому мій скрипт завис після завершення роботи."

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?