RRUNTIME LABruntime observatoryNNEON · Статьи на 90 языкахПозвонить
CONNECTING
v24.19.0linux/x64
Скрытый параллелизм
05

Пул потоков libuv

Шесть PBKDF2-задач показывают очередь нативного thread pool и возврат результатов в Event Loop.

PROCESS IDтекущий сервер
UPTIMEпосле запуска
LOOP DELAY P95perf_hooks
UTILIZATIONevent loop
HTTP ROUNDTRIPbrowser → server
LIVE TRACE

Временная шкала

ГОТОВ
0 ms
События появятся здесьЗапустите выбранный сценарий
#ВРЕМЯИСТОЧНИКСОБЫТИЕ
Ожидаю запуск эксперимента…
ГЛАВА 05
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: Пул потоков libuv

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Это небольшая кухня за залом. Официант — главный JavaScript-поток — быстро передаёт шесть заказов. На кухне только четыре повара, поэтому два заказа ждут, даже если официант свободен и продолжает принимать гостей.

ТЕХНИЧЕСКАЯ ОСНОВА

Thread pool libuv выполняет определённые нативные операции, для которых нет удобной неблокирующей готовности ОС или которые вычислительно дороги. JavaScript приложения не исполняется внутри этого пула. Пул общий для процесса, а после завершения нативной функции libuv возвращает callback в Event Loop.

Зачем это знатьОбщий ограниченный пул может стать скрытым bottleneck: тяжёлая crypto-задача способна увеличить задержку независимого fs или DNS-вызова.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01ВАШ JS-КОДfunctions · callbacks
02NODE APIsfs · crypto · timers
03V8 + LIBUVheap · loop · pool
04ОПЕРАЦИОННАЯ СИСТЕМАI/O · threads · memory
01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

Native operation

Код на C/C++ внутри Node или библиотеки, а не JavaScript приложения.

02

Thread pool

Фиксированное число переиспользуемых потоков и очередь заданий перед ними.

03

UV_THREADPOOL_SIZE

Переменная окружения, задающая размер общего пула при старте Node-процесса.

04

PBKDF2

Вычислительно дорогая функция получения ключа; здесь она создаёт заметную работу для пула.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Шесть отправок

    JS быстро вызывает асинхронный pbkdf2 шесть раз.

  2. 02
    Очередь пула

    Свободные native threads забирают задания, остальные ожидают.

  3. 03
    Main свободен

    Event Loop может обслуживать HTTP, пока нативные потоки считают.

  4. 04
    Первая волна

    При стандартном пуле близко завершаются примерно первые четыре задачи.

  5. 05
    Callbacks результатов

    Завершение каждого native job возвращается в основной Event Loop.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

Размер пула — не число физических ядер

UV_THREADPOOL_SIZE=4 разрешает до четырёх pool jobs, но ОС и CPU решают, сколько работы реально идёт одновременно. На загруженной или малоядерной машине красивых «волн» может не быть.

02

Переменную задают до запуска Node

Пул инициализируется заранее, поэтому UV_THREADPOOL_SIZE следует передавать окружением процессу или контейнеру. Изменение process.env после начала работы не является надёжной перенастройкой.

03

Свободный Event Loop ещё может тормозить

Main thread не выполняет PBKDF2, но native threads конкурируют за тот же CPU. Кроме того, занятый общий pool задерживает другие использующие его fs, crypto и некоторые DNS-операции.

04

Волны — наблюдение, а не контракт

Одинаковое число итераций не гарантирует строгий порядок завершения. Планировщик ОС, частоты CPU и другая нагрузка могут смешать callbacks.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/demos.js · учебный фрагментJavaScript
for (let i = 0; i < 6; i++) {
  crypto.pbkdf2('secret', 'salt', 120_000, 32, 'sha256',
    () => console.log('ready', i)
  );
}
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.

src/demos.js
сценарий52 строк
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-поток открытым до завершения сценария.

06 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Массовый 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 и память.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

UV_THREADPOOL_SIZE можно настраивать после измерений, но увеличение размера не заменяет admission control. Главная production-гарантия — ограниченное число работ на конечный ресурс.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • fs.readFile и dns.lookup медленные только во время password batch.
  • Завершения PBKDF2 приходят волнами размера UV_THREADPOOL_SIZE.
  • Рост UV_THREADPOOL_SIZE перемещает bottleneck в CPU.
07 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Пул libuv и Worker Threads — одно и то же.

НА САМОМ ДЕЛЕ

В libuv pool работает нативная функция API; Worker исполняет ваш JavaScript в отдельном isolate.

МИФ

Чем больше UV_THREADPOOL_SIZE, тем быстрее.

НА САМОМ ДЕЛЕ

После насыщения CPU дополнительные потоки увеличивают конкуренцию и переключения контекста.

МИФ

Любой асинхронный Node API использует pool.

НА САМОМ ДЕЛЕ

Сетевые сокеты обычно используют механизмы готовности ОС без потока на каждую операцию.

МИФ

Свободный main thread означает, что нагрузка не влияет на HTTP.

НА САМОМ ДЕЛЕ

CPU contention и общий pool всё равно способны увеличить latency, даже когда JavaScript не заблокирован.

08 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Почему пятая задача может ждать, хотя Event Loop свободен?
  2. Что произойдёт с fs.readFile при полностью занятом общем пуле?