Macrotask, microtask и process.nextTick

Задача «напишите порядок вывода» встречается почти на каждом интервью. Разбираем правило, по которому этот порядок вычисляется.

Macrotask — задача, которую забирает одна из фаз event loop (таймер, I/O-колбэк, setImmediate). Microtask — задача из очереди промисов (.then, продолжение после await, queueMicrotask). После каждой macrotask очередь микрозадач опустошается полностью.

Что проверяет интервьюер

Проверяют не память, а модель в голове. Кандидат, который понимает разницу между очередями, не будет удивляться, почему логи в проде идут «не в том порядке», почему await внутри цикла превращает параллельную работу в последовательную и почему рекурсивный process.nextTick подвешивает сервер без единой ошибки в логах.

Правило из четырёх пунктов

  1. Сначала до конца выполняется весь синхронный код текущего стека.
  2. Затем полностью опустошается очередь process.nextTick — включая колбэки, добавленные во время её обработки.
  3. Затем полностью опустошается очередь микрозадач: .then, catch, finally, продолжения await, queueMicrotask.
  4. И только потом 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.

Проверьте себя
1. Какая очередь обрабатывается раньше остальных после завершения синхронного кода?
AОчередь setImmediate
BОчередь process.nextTick
CОчередь микрозадач промисов
DОчередь таймеров
2. Сколько микрозадач выполняется между двумя macrotask?
AРовно одна
BНе более четырёх, дальше управление возвращается циклу
CВся очередь целиком, включая микрозадачи, добавленные по ходу
DНи одной, микрозадачи обрабатываются только в конце итерации
3. Чем опасен рекурсивный вызов process.nextTick внутри самого себя?
AНичем, Node ограничивает глубину рекурсии
BПереполнится стек и процесс упадёт с понятной ошибкой
CEvent loop никогда не дойдёт до фазы poll: сервер перестанет принимать запросы без единой ошибки в логах
DКолбэки просто выполнятся в следующей итерации цикла