Пул потоков libuv
Шесть PBKDF2-задач показывают очередь нативного thread pool и возврат результатов в Event Loop.
Временная шкала
Разбираем: Пул потоков libuv
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Это небольшая кухня за залом. Официант — главный JavaScript-поток — быстро передаёт шесть заказов. На кухне только четыре повара, поэтому два заказа ждут, даже если официант свободен и продолжает принимать гостей.
Thread pool libuv выполняет определённые нативные операции, для которых нет удобной неблокирующей готовности ОС или которые вычислительно дороги. JavaScript приложения не исполняется внутри этого пула. Пул общий для процесса, а после завершения нативной функции libuv возвращает callback в Event Loop.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Native operation
Код на C/C++ внутри Node или библиотеки, а не JavaScript приложения.
Thread pool
Фиксированное число переиспользуемых потоков и очередь заданий перед ними.
UV_THREADPOOL_SIZE
Переменная окружения, задающая размер общего пула при старте Node-процесса.
PBKDF2
Вычислительно дорогая функция получения ключа; здесь она создаёт заметную работу для пула.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Шесть отправок
JS быстро вызывает асинхронный pbkdf2 шесть раз.
- 02Очередь пула
Свободные native threads забирают задания, остальные ожидают.
- 03Main свободен
Event Loop может обслуживать HTTP, пока нативные потоки считают.
- 04Первая волна
При стандартном пуле близко завершаются примерно первые четыре задачи.
- 05Callbacks результатов
Завершение каждого native job возвращается в основной Event Loop.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Размер пула — не число физических ядер
UV_THREADPOOL_SIZE=4 разрешает до четырёх pool jobs, но ОС и CPU решают, сколько работы реально идёт одновременно. На загруженной или малоядерной машине красивых «волн» может не быть.
Переменную задают до запуска Node
Пул инициализируется заранее, поэтому UV_THREADPOOL_SIZE следует передавать окружением процессу или контейнеру. Изменение process.env после начала работы не является надёжной перенастройкой.
Свободный Event Loop ещё может тормозить
Main thread не выполняет PBKDF2, но native threads конкурируют за тот же CPU. Кроме того, занятый общий pool задерживает другие использующие его fs, crypto и некоторые DNS-операции.
Волны — наблюдение, а не контракт
Одинаковое число итераций не гарантирует строгий порядок завершения. Планировщик ОС, частоты CPU и другая нагрузка могут смешать callbacks.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
for (let i = 0; i < 6; i++) {
crypto.pbkdf2('secret', 'salt', 120_000, 32, 'sha256',
() => console.log('ready', i)
);
}Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import { pbkdf2 } from 'node:crypto';
import { performance } from 'node:perf_hooks';
async function libuvThreadPool(emit) {
const jobs = 6;
const iterations = 120_000;
const startedAt = performance.now();
emit(
'call-stack',
'sync',
`Синхронно запускаем ${jobs} вызовов crypto.pbkdf2`,
);
emit(
'libuv',
'info',
`UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE ?? '4 (по умолчанию)'}`,
);
const tasks = Array.from({ length: jobs }, (_, index) => {
const job = index + 1;
emit('libuv-queue', 'schedule', `PBKDF2 #${job} отправлен в пул`);
return new Promise((resolve, reject) => {
pbkdf2('node-loop-lab', `salt-${job}`, iterations, 32, 'sha256', (error) => {
if (error) {
reject(error);
return;
}
emit(
'poll',
'callback',
`PBKDF2 #${job} вернулся через ${Math.round(performance.now() - startedAt)} мс`,
);
resolve();
});
});
});
emit(
'call-stack',
'info',
'Главный JS-поток сразу свободен; вычисления идут в пуле libuv',
);
await Promise.all(tasks);
emit(
'result',
'result',
`Все ${jobs} задач завершены за ${Math.round(performance.now() - startedAt)} мс`,
);
}Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Массовый PBKDF2 вытесняет файловые операции из libuv pool
После импорта пользователей сервис одновременно пересчитывает 200 password hashes. В том же процессе находятся загрузка конфигурации, fs и dns.lookup.
Все 200 native jobs ставятся в общий libuv pool. Первые четыре занимают threads, остальные образуют очередь, а несвязанные fs/DNS-задачи получают неожиданную задержку.
async function rehashUsers(users) {
await Promise.all(
users.map((user) =>
pbkdf2Async(user.password, user.salt),
),
);
}Promise.all не ограничивает регистрацию. Он мгновенно отправляет весь batch в конечный native pool и создаёт head-of-line blocking для других API.
import pLimit from 'p-limit';
const nativeLimit = pLimit(4);
async function rehashUsers(users) {
const results = [];
for (const chunk of chunks(users, 25)) {
results.push(...await Promise.all(
chunk.map((user) =>
nativeLimit(() =>
pbkdf2Async(user.password, user.salt),
),
),
));
}
return results;
}Backpressure ограничивает число одновременно отправленных jobs и память batch. Для большой миграции ещё лучше отдельный worker service, чтобы общий pool HTTP-процесса не участвовал.
Что делают непривычные вызовы из обоих фрагментов кода.
pbkdf2Async(...)- Promise-обёртка над crypto.pbkdf2: native-вычисление выполняется в общем libuv thread pool.
users.map(...)- Синхронно обходит весь массив и сразу создаёт по Promise на пользователя; сам map не ограничивает concurrency.
pLimit(4)- Создаёт ограничитель, который одновременно запускает не более четырёх переданных ему async-функций.
chunks(users, 25)- Учебный helper, разбивающий большой массив на части по 25 элементов, чтобы ограничить временные Promise и память.
Популярные заблуждения
Миф слева, корректная модель справа.
Пул libuv и Worker Threads — одно и то же.
В libuv pool работает нативная функция API; Worker исполняет ваш JavaScript в отдельном isolate.
Чем больше UV_THREADPOOL_SIZE, тем быстрее.
После насыщения CPU дополнительные потоки увеличивают конкуренцию и переключения контекста.
Любой асинхронный Node API использует pool.
Сетевые сокеты обычно используют механизмы готовности ОС без потока на каждую операцию.
Свободный main thread означает, что нагрузка не влияет на HTTP.
CPU contention и общий pool всё равно способны увеличить latency, даже когда JavaScript не заблокирован.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему пятая задача может ждать, хотя Event Loop свободен?
- Что произойдёт с fs.readFile при полностью занятом общем пуле?