Пул потоков 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)} мс`,
);
}Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Пул 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 при полностью занятом общем пуле?