RRUNTIME LABruntime observatoryNNEON · Статьи на 90 языкахПозвонить
CONNECTING
v24.19.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 параллельно: каждый ждёт свободный стек',
  );
}

Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.

06 · PRODUCTION-КЕЙСЫ

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

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

КЕЙС 01

Синхронный password hash задерживает все запросы процесса

В endpoint регистрации используется bcrypt.hashSync. На тестовой машине один вызов кажется приемлемым, но под параллельной регистрацией callbacks остальных маршрутов ждут свободный стек.

КОНТЕКСТ ИНЦИДЕНТА

Готовые timers, HTTP callbacks и healthcheck не могут выполняться, пока hashSync держит main thread. Autoscaling реагирует поздно, потому что CPU и Event Loop lag уже подняли tail latency.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Post()
  create(@Body() input: CreateUserDto) {
    const passwordHash = bcrypt.hashSync(
      input.password,
      12,
    );

    return this.users.create({
      email: input.email,
      passwordHash,
    });
  }
}

Синхронная CPU/native операция выполняется внутри текущего callback по run-to-completion. Очередь готовых HTTP callbacks не является параллельным executor.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
@Controller('users')
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Post()
  async create(@Body() input: CreateUserDto) {
    const passwordHash = await bcrypt.hash(
      input.password,
      12,
    );

    return this.users.create({
      email: input.email,
      passwordHash,
    });
  }
}

Асинхронный bcrypt делегирует работу native pool и освобождает main stack. На входе всё равно нужен rate limit, потому что pool и CPU остаются конечными ресурсами.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

@Controller / @Post
Nest-декораторы: связывают класс с группой маршрутов, а метод — с HTTP POST endpoint.
@Body() input
Nest извлекает тело HTTP-запроса и передаёт его параметром метода. DTO описывает ожидаемую структуру данных.
bcrypt.hashSync(...)
Синхронно вычисляет password hash и до возврата удерживает главный JavaScript-поток.
cost = 12
Параметр стоимости bcrypt: каждый следующий шаг экспоненциально увеличивает объём вычислений, поэтому значение выбирают по измерениям.
await bcrypt.hash(...)
Асинхронная версия передаёт тяжёлую native-работу в thread pool и возвращает Promise с готовым hash.
constructor(private users: UsersService)
Constructor injection Nest: IoC-контейнер создаёт UsersService и передаёт его контроллеру.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Исправление сохраняет отзывчивость Event Loop, но не создаёт бесконечную мощность. Для дорогих вычислений контролируют очередь, pool saturation и максимальное число одновременных регистраций.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Event Loop delay растёт одновременно с регистрационным трафиком.
  • Даже /health отвечает медленно при свободной базе данных.
  • CPU высокий, а число активных запросов растёт волнами.
07 · НЕ ПЕРЕПУТАЙТЕ

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

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

МИФ

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

НА САМОМ ДЕЛЕ

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

МИФ

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

НА САМОМ ДЕЛЕ

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

МИФ

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

НА САМОМ ДЕЛЕ

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

МИФ

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

НА САМОМ ДЕЛЕ

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

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

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

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

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