Macrotask, microtask и process.nextTick
Задача «напишите порядок вывода» встречается почти на каждом интервью. Разбираем правило, по которому этот порядок вычисляется.
Macrotask — задача, которую забирает одна из фаз event loop (таймер, I/O-колбэк,
setImmediate). Microtask — задача из очереди промисов (.then, продолжение послеawait,queueMicrotask). После каждой macrotask очередь микрозадач опустошается полностью.
Что проверяет интервьюер
Проверяют не память, а модель в голове. Кандидат, который понимает разницу между очередями, не будет удивляться, почему логи в проде идут «не в том порядке», почему await внутри цикла превращает параллельную работу в последовательную и почему рекурсивный process.nextTick подвешивает сервер без единой ошибки в логах.
Правило из четырёх пунктов
- Сначала до конца выполняется весь синхронный код текущего стека.
- Затем полностью опустошается очередь
process.nextTick— включая колбэки, добавленные во время её обработки. - Затем полностью опустошается очередь микрозадач:
.then,catch,finally, продолженияawait,queueMicrotask. - И только потом event loop берёт одну macrotask из очередной фазы — и после неё пункты 2 и 3 повторяются.
Обратите внимание: process.nextTick — не часть стандарта промисов и не часть фаз цикла. Это отдельная очередь Node с наивысшим приоритетом.
Каноническая задача
console.log('1: синхронный старт');
setTimeout(() => console.log('2: setTimeout 0'), 0);
setImmediate(() => console.log('3: setImmediate'));
Promise.resolve().then(() => console.log('4: promise.then'));
process.nextTick(() => console.log('5: nextTick'));
queueMicrotask(() => console.log('6: queueMicrotask'));
console.log('7: синхронный конец');
Вывод:
1: синхронный старт
7: синхронный конец
5: nextTick
4: promise.then
6: queueMicrotask
2: setTimeout 0
3: setImmediate
Разбор по шагам: строки 1 и 7 — синхронный код, он всегда первый. Дальше идёт nextTick — очередь с наивысшим приоритетом. Затем микрозадачи промисов в порядке регистрации: then зарегистрирован раньше queueMicrotask, поэтому и выполнится раньше. И только после этого — macrotask'и.
Важная оговорка, которую стоит проговорить вслух: порядок последних двух строк в главном модуле не гарантирован — таймер с нулевой задержкой может как успеть, так и не успеть созреть к фазе timers. Если бы весь этот код лежал внутри I/O-колбэка, setImmediate гарантированно шёл бы раньше setTimeout. Кандидат, который сам делает эту оговорку, сразу выделяется.
Ловушка: nextTick голодает цикл
process.nextTick(() => {
console.log('nextTick 1');
process.nextTick(() => console.log('nextTick 2'));
});
Promise.resolve().then(() => console.log('promise'));
Вывод:
nextTick 1
nextTick 2
promise
Колбэк, добавленный во время обработки очереди nextTick, попадает в ту же порцию и выполняется до промисов. Отсюда следует опасный сценарий: если функция рекурсивно ставит саму себя через process.nextTick, event loop не дойдёт до фазы poll никогда — сервер перестанет отвечать, при этом CPU будет на 100%, а в логах не будет ни одной ошибки. С setImmediate так не выйдет: он ставит колбэк в фазу check следующей итерации, и цикл успевает обслужить I/O.
Await — это тоже микрозадача
async function main() {
console.log('A');
await null; // await превращает остаток функции в микрозадачу
console.log('C');
}
main();
console.log('B');
Вывод:
A
B
C
Всё, что стоит после await, — это фактически содержимое .then. Поэтому синхронная строка B печатается раньше, даже несмотря на то что await null ничего не ждёт по существу.
Типичные ошибки кандидатов
- Считают
process.nextTickсинонимомsetImmediate. Названия сбивают с толку:nextTickвыполняется раньше, аsetImmediate— позже, чем можно подумать по имени. - Уверенно называют детерминированный порядок
setTimeoutиsetImmediateв главном модуле. - Забывают, что микрозадачи опустошаются полностью, а не по одной штуке между macrotask'ами.
- Не понимают, что код после
await— это микрозадача, и ждут его немедленного выполнения.
Как ответить кратко
Сначала выполняется весь синхронный код. Потом целиком очередь
process.nextTick, потом целиком очередь микрозадач промисов, и только затем event loop берёт следующую macrotask — таймер, I/O-колбэк илиsetImmediate. После каждой macrotask микрозадачи снова опустошаются полностью. Код послеawait— это микрозадача. А рекурсивныйprocess.nextTickспособен полностью заморозить цикл, поэтому для «отложить на потом» безопаснееsetImmediate.