Порядок Event Loop
Сравните sync-код, nextTick, microtasks, timers, poll и check в одном живом запуске.
Временная шкала
Разбираем: Порядок Event Loop
Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.
Представьте одного повара и несколько полок с заказами разного приоритета. Записи в блокноте делаются сверху вниз, но это ещё не порядок приготовления: закончив текущее действие, повар выбирает следующую работу по полке и контексту. В обычном callback Node сначала проверяет nextTick, затем microtasks и после этого продолжает фазы Event Loop.
JavaScript-код одного Node-процесса обычно исполняется в одном главном потоке по правилу run-to-completion: начатая функция не прерывается другим callback. Строки вызова выполняются сверху вниз, но nextTick, then, setTimeout и setImmediate на своих строках лишь регистрируют функции для будущего выполнения. Event Loop выбирает callback после освобождения стека; между разными очередями приоритет важнее визуального порядка строк.
Термины этого эксперимента
Сначала поймите слова — затем порядок выполнения.
Call Stack
Стек вызываемых сейчас функций. Пока он не пуст, другой callback не начнёт выполнять JavaScript.
Callback
Функция, которую runtime вызовет позже после события: таймера, I/O, сообщения Worker и т. п.
Microtask
Приоритетное продолжение Promise или queueMicrotask. Очередь очищается между callbacks и фазами.
Фаза
Этап оборота libuv Event Loop. Для лаборатории важны timers, poll и check.
Регистрация
Синхронный момент, когда runtime получает callback и условия его будущего запуска. Регистрация ещё не является выполнением callback.
Что происходит по шагам
Каждый шаг соответствует наблюдаемому состоянию runtime.
- 01Выполняется sync-код
Строки читаются сверху вниз: console.log печатает сразу, а остальные вызовы регистрируют callbacks.
- 02Стек освобождается
Node получает возможность выбрать отложенную работу.
- 03Очистка nextTick
В контексте этого HTTP-эксперимента process.nextTick имеет специальную очередь Node и выполняется перед Promise.
- 04Очистка microtasks
Выполняются Promise.then и queueMicrotask, включая добавленные ими новые microtasks.
- 05Продолжение фаз
Event Loop переходит к готовым timers, I/O в poll и setImmediate в check.
Где результат требует оговорки
Эти детали объясняют, почему похожий код иногда даёт другой trace.
Порядок строк — это порядок регистрации
Сначала действительно выполняется строка nextTick, затем строка Promise и так далее. Но тела стрелочных функций выполнятся позже. Поэтому порядок строк не равен итоговому порядку console.log.
Внутри одной очереди важен FIFO
Два Promise.then, зарегистрированные подряд, обычно выполнятся в порядке регистрации. Но nextTick и Promise находятся в разных очередях, поэтому приоритет очереди может обойти более раннюю строку.
Timer против immediate зависит от места
В main-модуле setTimeout(0) и setImmediate могут поменяться местами. Если оба созданы внутри одного I/O callback, setImmediate выполняется раньше. Начиная с Node 20 расположение timers относительно poll также изменилось.
У top-level ESM есть исключение
ES-модуль сам вычисляется как microtask, поэтому в standalone ESM-файле Promise может оказаться раньше nextTick. Лаборатория регистрирует их из HTTP callback и показывает обычный порядок nextTick → Promise.
Сначала разберитесь, какие части Node участвуют в выполнении.
Затем уберите служебные детали и рассмотрите только главную идею.
После этого сопоставьте модель с кодом, который создаёт live trace.
Минимальная модель без служебного кода
console.log('sync'); // Выполняется сейчас
// Эти строки регистрируют callbacks сверху вниз:
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('Promise'));
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
// Их тела выполнятся позже по правилам очередей и фаз.Полный код, который выполняет сценарий
Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.
Код сформирован из реальной серверной функции. Для сценариев с отдельным процессом или Worker показаны все участвующие файлы.
import { readFile } from 'node:fs';
import { fileURLToPath } from 'node:url';
const packageJsonPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
const sleep = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
async function eventLoopOrder(emit) {
emit('call-stack', 'sync', 'Синхронный код начал выполняться');
const outerCallbacks = [];
const waitForOuter = new Promise((resolve) => {
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) resolve();
};
process.nextTick(() => {
outerCallbacks.push('process.nextTick');
emit('nextTick', 'callback', 'process.nextTick callback');
done();
});
Promise.resolve().then(() => {
outerCallbacks.push('Promise.then');
emit('microtasks', 'callback', 'Promise.then microtask');
done();
});
queueMicrotask(() => {
outerCallbacks.push('queueMicrotask');
emit('microtasks', 'callback', 'queueMicrotask callback');
done();
});
setTimeout(() => {
outerCallbacks.push('setTimeout(0)');
emit('timers', 'callback', 'setTimeout(0) callback');
done();
}, 0);
setImmediate(() => {
outerCallbacks.push('setImmediate');
emit('check', 'callback', 'setImmediate callback');
done();
});
});
emit(
'call-stack',
'schedule',
'Callbacks зарегистрированы; синхронный стек сейчас освободится',
);
await waitForOuter;
// Ждём обе макрозадачи. Эксперимент запускается из HTTP callback, поэтому
// здесь важен runtime-контекст, а не только порядок строк в сниппете.
while (
!outerCallbacks.includes('setTimeout(0)') ||
!outerCallbacks.includes('setImmediate')
) {
await sleep(1);
}
emit('result', 'result', `Первый раунд: ${outerCallbacks.join(' → ')}`);
emit('poll', 'schedule', 'Запускаем fs.readFile и переходим к I/O-раунду');
await new Promise((resolve, reject) => {
readFile(packageJsonPath, 'utf8', (error) => {
if (error) {
reject(error);
return;
}
emit('poll', 'callback', 'Callback fs.readFile: сейчас мы внутри poll-фазы');
const ioOrder = [];
let completed = 0;
const done = () => {
completed += 1;
if (completed === 4) {
emit('result', 'result', `Внутри I/O: ${ioOrder.join(' → ')}`);
resolve();
}
};
process.nextTick(() => {
ioOrder.push('nextTick');
emit('nextTick', 'callback', 'nextTick, созданный внутри I/O');
done();
});
Promise.resolve().then(() => {
ioOrder.push('Promise');
emit('microtasks', 'callback', 'Promise, созданный внутри I/O');
done();
});
setImmediate(() => {
ioOrder.push('setImmediate');
emit('check', 'callback', 'setImmediate, созданный внутри I/O');
done();
});
setTimeout(() => {
ioOrder.push('setTimeout');
emit('timers', 'callback', 'setTimeout(0), созданный внутри I/O');
done();
}, 0);
});
});
}Именно вызовы emit(...) превращаются в строки live trace. await и Promise удерживают HTTP-поток открытым до завершения сценария.
Популярные заблуждения
Миф слева, корректная модель справа.
В Node существует одна общая очередь событий.
Очередей и фаз несколько, между ними есть правила приоритета.
setTimeout(fn, 0) выполняет fn немедленно.
Ноль означает минимальную задержку; callback ещё должен дождаться подходящей фазы и свободного стека.
Асинхронная функция может прервать текущий JavaScript.
Нет: callback начнётся только после завершения текущего участка кода.
Если строка записана выше, её callback обязательно сработает раньше.
Это верно лишь при совместимых условиях, например внутри одной FIFO-очереди. Между очередями сначала применяются их приоритеты и правила фаз.
process.nextTick всегда и везде раньше Promise.
Так происходит в обычных callbacks и CommonJS. При вычислении top-level ESM Promise/microtask может получить преимущество.
Ответьте своими словами
Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.
- Почему Promise не выполняется в момент вызова Promise.resolve()?
- Что произойдёт с таймером, если текущая функция работает пять секунд?
- Когда порядок строк снова становится порядком callbacks, а когда его переопределяет очередь?