Очередь callbacks
Пять нулевых таймеров показывают, как один тяжёлый callback задерживает всю очередь.
Временная шкала
Разбираем: Очередь callbacks
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Представьте очередь к одному окну. Даже если пять человек уже готовы обслуживаться, кассир работает только с одним. Если первый клиент занимает окно надолго, ожидание увеличивается у всех остальных.
Очередь хранит готовую работу, но не исполняет её. Исполнителем остаётся JavaScript call stack. После каждого callback Node также даёт приоритет nextTick и microtasks, поэтому разные категории работы могут вклиниваться между соседними timer callbacks. Показатель лаборатории измеряется от момента регистрации таймера и включает не только чистое ожидание в очереди.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Queue
Структура ожидания. Наличие элемента в очереди ещё не означает, что он уже выполняется.
Lag
Задержка относительно ожидаемого момента. В этой лаборатории показано полное время от регистрации, поэтому в него входит минимальный timer threshold и ожидание стека.
Run-to-completion
Текущий callback выполняется до возврата; Event Loop не вытесняет его другим JavaScript-кодом.
Starvation
Ситуация, когда одна приоритетная очередь постоянно пополняется и не даёт другим фазам получить время.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Пять регистраций
Все setTimeout(0) создаются в одном коротком синхронном цикле.
- 02Timers становятся готовы
Их минимальный deadline проходит, но стек может быть занят.
- 03Первый callback блокирует
CPU-цикл удерживает единственный главный JS-стек примерно 260 мс.
- 04Приоритетные очереди
nextTick и Promise из первого callback выполняются перед вторым таймером.
- 05Очередь разгружается
Оставшиеся callbacks быстро выполняются один за другим.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
setTimeout(0) не готов в ту же наносекунду
Ноль задаёт минимальный порог, а не немедленный вызов. Таймер должен стать готовым, Event Loop — дойти до timers, а стек — освободиться.
Число в trace — не чистое queue wait
Эксперимент считает миллисекунды от общей регистрации. Это наглядная latency, но для точного времени ожидания именно в очереди понадобилось бы отдельно зафиксировать момент готовности каждого таймера.
FIFO здесь контролируемый, а не глобальный
Пять одинаковых таймеров создаются одним циклом и обычно обрабатываются в порядке регистрации. Это нельзя переносить на callbacks из разных фаз, I/O-источников или потоков.
Microtasks могут вызвать starvation
После callback #1 Node обработает созданные nextTick и Promise перед timer #2. Если nextTick бесконечно добавляет новый nextTick, Event Loop долго не доберётся до остальных фаз.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
for (let i = 1; i <= 5; i++) {
setTimeout(() => {
if (i === 1) heavyCpuWork(260);
console.log('timer', i);
}, 0);
}Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
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-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
Готовые callbacks начинают работать одновременно.
Готовность означает право ждать исполнения, а не параллельный JS.
Все очереди строго глобально FIFO.
FIFO применимо внутри конкретных структур, но категории работы имеют разные правила и приоритеты.
Задержка таймера показывает неточность часов.
Чаще она показывает занятый стек, насыщенную фазу или нагрузку процесса.
Пять просроченных таймеров означают пять параллельных исполнителей.
Они лишь становятся кандидатами на выполнение; один главный JavaScript-стек всё равно обрабатывает callbacks последовательно.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему callback №2 получает задержку около 260 мс?
- Может ли Promise внутри callback №1 выполниться раньше callback №2?