NNODE LOOP LABruntime observatoryNNEON · Статьи на 90 языках
CONNECTING
v24.18.0linux/x64
Один стек, много готовых задач
03

Очередь callbacks

Пять нулевых таймеров показывают, как один тяжёлый callback задерживает всю очередь.

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

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

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

Разбираем: Очередь callbacks

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

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

Представьте очередь к одному окну. Даже если пять человек уже готовы обслуживаться, кассир работает только с одним. Если первый клиент занимает окно надолго, ожидание увеличивается у всех остальных.

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

Очередь хранит готовую работу, но не исполняет её. Исполнителем остаётся JavaScript call stack. После каждого callback Node также даёт приоритет nextTick и microtasks, поэтому разные категории работы могут вклиниваться между соседними timer callbacks. Показатель лаборатории измеряется от момента регистрации таймера и включает не только чистое ожидание в очереди.

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

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

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

01

Queue

Структура ожидания. Наличие элемента в очереди ещё не означает, что он уже выполняется.

02

Lag

Задержка относительно ожидаемого момента. В этой лаборатории показано полное время от регистрации, поэтому в него входит минимальный timer threshold и ожидание стека.

03

Run-to-completion

Текущий callback выполняется до возврата; Event Loop не вытесняет его другим JavaScript-кодом.

04

Starvation

Ситуация, когда одна приоритетная очередь постоянно пополняется и не даёт другим фазам получить время.

02 · МЕХАНИКА

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

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

  1. 01
    Пять регистраций

    Все setTimeout(0) создаются в одном коротком синхронном цикле.

  2. 02
    Timers становятся готовы

    Их минимальный deadline проходит, но стек может быть занят.

  3. 03
    Первый callback блокирует

    CPU-цикл удерживает единственный главный JS-стек примерно 260 мс.

  4. 04
    Приоритетные очереди

    nextTick и Promise из первого callback выполняются перед вторым таймером.

  5. 05
    Очередь разгружается

    Оставшиеся callbacks быстро выполняются один за другим.

03 · КОНТЕКСТ

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

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

01

setTimeout(0) не готов в ту же наносекунду

Ноль задаёт минимальный порог, а не немедленный вызов. Таймер должен стать готовым, Event Loop — дойти до timers, а стек — освободиться.

02

Число в trace — не чистое queue wait

Эксперимент считает миллисекунды от общей регистрации. Это наглядная latency, но для точного времени ожидания именно в очереди понадобилось бы отдельно зафиксировать момент готовности каждого таймера.

03

FIFO здесь контролируемый, а не глобальный

Пять одинаковых таймеров создаются одним циклом и обычно обрабатываются в порядке регистрации. Это нельзя переносить на callbacks из разных фаз, I/O-источников или потоков.

04

Microtasks могут вызвать starvation

После callback #1 Node обработает созданные nextTick и Promise перед timer #2. Если nextTick бесконечно добавляет новый nextTick, Event Loop долго не доберётся до остальных фаз.

01
Теория

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

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

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

03
Runtime-код

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

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

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

src/demos.js · учебный фрагментJavaScript
for (let i = 1; i <= 5; i++) {
  setTimeout(() => {
    if (i === 1) heavyCpuWork(260);
    console.log('timer', i);
  }, 0);
}
05 · Runtime-код

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

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

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

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

src/demos.js
сценарий74 строк
import { performance } from 'node:perf_hooks';

function blockMainThread(durationMs) {
  const startedAt = performance.now();
  let iterations = 0;

  // Намеренная блокировка для учебного сценария. В production так делать нельзя.
  while (performance.now() - startedAt < durationMs) {
    iterations += Math.sqrt((iterations % 10_000) + 1);
  }

  return Math.round(iterations);
}

async function callbackQueue(emit) {
  const scheduledAt = performance.now();
  emit('call-stack', 'sync', 'Одновременно ставим пять setTimeout(0)');

  await new Promise((resolve) => {
    let completed = 0;

    for (let index = 1; index <= 5; index += 1) {
      emit('timers', 'schedule', `Таймер #${index} зарегистрирован`);

      setTimeout(() => {
        const lag = Math.round(performance.now() - scheduledAt);
        emit(
          'timers-queue',
          'callback',
          `Callback #${index} взят из очереди (через ${lag} мс)`,
        );

        if (index === 1) {
          emit(
            'call-stack',
            'warning',
            'Callback #1 занимает главный поток на 260 мс',
          );
          blockMainThread(260);
          emit('call-stack', 'sync', 'Callback #1 освободил стек');

          process.nextTick(() => {
            emit(
              'nextTick',
              'callback',
              'nextTick из callback #1 вклинился перед следующим таймером',
            );
          });

          Promise.resolve().then(() => {
            emit(
              'microtasks',
              'callback',
              'Promise из callback #1 тоже выполнен перед следующим таймером',
            );
          });
        }

        completed += 1;
        if (completed === 5) {
          // setImmediate даёт nextTick/Promise последнего callback'а выполниться
          // до завершения учебного сценария.
          setImmediate(resolve);
        }
      }, 0);
    }
  });

  emit(
    'result',
    'result',
    'Очередь не исполняет callbacks параллельно: каждый ждёт свободный стек',
  );
}

Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · НЕ ПЕРЕПУТАЙТЕ

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

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

МИФ

Готовые callbacks начинают работать одновременно.

НА САМОМ ДЕЛЕ

Готовность означает право ждать исполнения, а не параллельный JS.

МИФ

Все очереди строго глобально FIFO.

НА САМОМ ДЕЛЕ

FIFO применимо внутри конкретных структур, но категории работы имеют разные правила и приоритеты.

МИФ

Задержка таймера показывает неточность часов.

НА САМОМ ДЕЛЕ

Чаще она показывает занятый стек, насыщенную фазу или нагрузку процесса.

МИФ

Пять просроченных таймеров означают пять параллельных исполнителей.

НА САМОМ ДЕЛЕ

Они лишь становятся кандидатами на выполнение; один главный JavaScript-стек всё равно обрабатывает callbacks последовательно.

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

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

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

  1. Почему callback №2 получает задержку около 260 мс?
  2. Может ли Promise внутри callback №1 выполниться раньше callback №2?