Очередь 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 параллельно: каждый ждёт свободный стек',
);
}Live trace инструментирован самим приложением: строки и timestamps фиксируются при реальных вызовах emit(...), а названия source/lane задаёт сценарий. Это не profiler V8/libuv и не прямой снимок их внутренних очередей. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Как учебная ошибка превращается в инцидент
Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.
Синхронный 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 и передаёт его контроллеру.
Популярные заблуждения
Миф слева, корректная модель справа.
Готовые callbacks начинают работать одновременно.
Готовность означает право ждать исполнения, а не параллельный JS.
Все очереди строго глобально FIFO.
FIFO применимо внутри конкретных структур, но категории работы имеют разные правила и приоритеты.
Задержка таймера показывает неточность часов.
Чаще она показывает занятый стек, насыщенную фазу или нагрузку процесса.
Пять просроченных таймеров означают пять параллельных исполнителей.
Они лишь становятся кандидатами на выполнение; один главный JavaScript-стек всё равно обрабатывает callbacks последовательно.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему callback №2 получает задержку около 260 мс?
- Может ли Promise внутри callback №1 выполниться раньше callback №2?