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

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