V8, libuv и thread pool

Вопрос «из чего вообще состоит Node.js» отсеивает тех, кто воспринимает рантайм как чёрный ящик.

V8 — движок Google, который компилирует и исполняет JavaScript и управляет памятью. libuv — библиотека на C, дающая event loop, асинхронный ввод-вывод и пул потоков. Node — это связка из них плюс собственные модули (http, fs, crypto) и стандартная библиотека на JavaScript.

Что проверяет интервьюер

Главным образом — знаете ли вы про thread pool и его размер по умолчанию. Это не эрудиция ради эрудиции: незнание приводит к реальному инциденту, когда сервис с интенсивным хешированием паролей упирается в четыре потока и отвечает всё медленнее, хотя процессор загружен наполовину.

Что делает V8

V8 берёт исходный JavaScript, разбирает его и запускает интерпретатором Ignition, попутно собирая статистику. Горячие функции переводит в машинный код оптимизирующий компилятор TurboFan. Он же управляет кучей и сборкой мусора: молодое поколение (scavenger) собирается часто и дёшево, старое — реже и дороже. Размер старого поколения по умолчанию ограничен (порядка 1,5–4 ГБ в зависимости от версии и платформы) и меняется флагом --max-old-space-size. Важно: ни event loop, ни файлов, ни сети в V8 нет — это чистый движок языка, тот же самый, что в Chrome.

Что делает libuv

libuv отвечает за всё, чего в языке нет: цикл событий с фазами, работу с сокетами, файлами, таймерами, процессами. Для сети она использует механизмы уведомлений ядра — epoll, kqueue, IOCP. Для операций, у которых нет нормального асинхронного API на уровне ОС, libuv держит пул потоков: там операция выполняется блокирующе, но в стороне от главного потока.

Что попадает в thread pool, а что нет

ОперацияКуда идёт
fs.readFile, fs.writeFile и прочий файловый ввод-выводthread pool
crypto.pbkdf2, scrypt, randomBytes, bcrypt (асинхронные версии)thread pool
zlib.gzip и сжатие вообщеthread pool
dns.lookup (и любое разрешение имён через getaddrinfo)thread pool
TCP, HTTP, запросы к базе по сетиepoll/kqueue, без потоков
dns.resolve (напрямую к DNS-серверу)сеть, без потоков

Формулировка, которую ждут: «сетевые операции асинхронны на уровне ядра, поэтому потоки им не нужны; файлы и криптография потоков требуют».

Ловушка с размером пула

const crypto = require('node:crypto');

const start = Date.now();
for (let i = 0; i < 5; i++) {
  crypto.pbkdf2('пароль', 'соль', 200000, 64, 'sha512', () => {
    console.log('задача', i, 'завершена через', Date.now() - start, 'мс');
  });
}

Примерный вывод (числа зависят от машины):

задача 0 завершена через 210 мс
задача 1 завершена через 212 мс
задача 2 завершена через 213 мс
задача 3 завершена через 215 мс
задача 4 завершена через 420 мс

Первые четыре задачи выполняются одновременно — по одной на каждый поток пула. Пятая ждёт освобождения потока и завершается примерно вдвое позже. Размер пула меняется переменной окружения до запуска процесса:

UV_THREADPOOL_SIZE=8 node server.js

Но увеличивать вслепую бессмысленно: потоков больше, чем ядер, — только рост переключений контекста. И ещё одна деталь для сильного ответа: dns.lookup тоже сидит в этом пуле, поэтому шквал файловых операций способен затормозить резолвинг имён и, как следствие, исходящие HTTP-запросы — связь совершенно неочевидная, пока не знаешь про общий пул.

Как всё складывается вместе

  1. Ваш код (V8) вызывает fs.readFile.
  2. Node передаёт задачу libuv, та ставит её в thread pool и немедленно возвращает управление.
  3. Event loop продолжает работать: принимает запросы, выполняет таймеры.
  4. Поток пула дочитал файл — libuv кладёт колбэк в очередь фазы poll.
  5. Цикл доходит до этой фазы и выполняет колбэк уже в главном потоке, в V8.

Типичные ошибки кандидатов

  • Говорят, что event loop находится в V8.
  • Уверены, что HTTP-запросы занимают потоки из thread pool.
  • Не знают размера пула по умолчанию (четыре) и переменной UV_THREADPOOL_SIZE.
  • Путают thread pool libuv с worker_threads — это разные механизмы: первый обслуживает I/O внутри рантайма, второй вы создаёте сами для своего JS-кода.

Как ответить кратко

V8 исполняет JavaScript и управляет памятью, libuv даёт event loop и асинхронный ввод-вывод. Сеть асинхронна на уровне ядра через epoll/kqueue и потоков не требует, а файловые операции, zlib, crypto и dns.lookup идут в thread pool libuv, где по умолчанию четыре потока. Размер задаётся переменной UV_THREADPOOL_SIZE до старта процесса. Пул libuv не надо путать с worker_threads: первый — внутренний механизм рантайма, второй — инструмент для вашего собственного CPU-кода.

Проверьте себя
1. Какая из операций НЕ использует thread pool libuv?
Afs.readFile
Bcrypto.pbkdf2
CHTTP-запрос к внешнему API
Dzlib.gzip
2. Сколько потоков в thread pool libuv по умолчанию?
A1
B4
Cпо числу ядер процессора
D128
3. За что отвечает V8, а за что libuv?
AV8 — event loop и I/O, libuv — исполнение JavaScript
BV8 — исполнение JavaScript и управление памятью, libuv — event loop и асинхронный ввод-вывод
CОба делают одно и то же, libuv нужна только для Windows
DV8 — сборка мусора, libuv — компиляция JavaScript в машинный код