Що таке 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
Швидкий приклад
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, zlibJS-потік циклічно перевіряє, чи є завершена робота, і відправляє 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 цикл такий:
- Timers - запускає callbacks
setTimeoutіsetInterval, чий час настав - Pending callbacks - I/O callbacks, відкладені з попередньої ітерації
- Idle / Prepare - для внутрішнього використання libuv
- Poll - блокується в очікуванні I/O-подій; відправляє готові I/O callbacks
- Check - виконує callbacks
setImmediate - 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-запит. Завершуються один раз і зникають.
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 в обробниках запитів
// Блокує всі інші запити на ~300мс
app.post('/login', (req, res) => {
const hash = crypto.pbkdf2Sync(req.body.password, salt, 100000, 512, 'sha512');
// Поки виконується цей рядок, жодні інші запити не обробляються
res.send('ok');
});pbkdf2Sync тримає JS-потік до завершення. При 10 паралельних логінах вони обробляються послідовно. Використовуй async-варіант, який іде в пул потоків:
crypto.pbkdf2(req.body.password, salt, 100000, 512, 'sha512', (err, hash) => {
res.send('ok');
});Ігнорування виснаження пулу потоків
Чотири потоки здаються достатніми, поки не запустиш хешування паролів під навантаженням. Кожен виклик pbkdf2 займає 200-300мс. При дефолтному пулі 5-й паралельний запит чекає в черзі за першими чотирма. Я бачив, як це додавало дві секунди до p99 latency на сервісі, який виглядав цілком здоровим. Підвищуй UV_THREADPOOL_SIZE до старту процесу:
// Через env variable перед запуском node:
// UV_THREADPOOL_SIZE=32 node server.js
// Або на самому початку entry file, до будь-яких require()
process.env.UV_THREADPOOL_SIZE = '32';Встановлення UV_THREADPOOL_SIZE занадто пізно
// НЕПРАВИЛЬНО - пул може бути вже ініціалізований
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
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-сервері
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 і час завершення процесу
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 економить години при дебагінгу "чому мій скрипт завис після завершення роботи."
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.