Микрозадачи: process.nextTick и промисы

Урок разбирает два сорта микрозадач Node — process.nextTick и промисы — их порядок и риск «голодания» цикла.

Микрозадача — колбэк, который Node выполняет между фазами событийного цикла, опустошая всю очередь микрозадач до конца, прежде чем перейти к следующей фазе.

В прошлом уроке мы увидели шесть фаз. Но между ними прячется отдельный механизм — микрозадачи. Это колбэки промисов (.then, .catch, .finally, продолжения await) и колбэки process.nextTick. Они не привязаны к фазам и выполняются «в зазорах» — сразу после текущего колбэка и после каждой фазы, причём очередь опустошается полностью, а не по одному элементу.

Различают макрозадачи (macrotask) и микрозадачи (microtask). Макрозадача — это единица работы фазы: один колбэк таймера, одно I/O-событие, один setImmediate. Микрозадача — то, что выполняется между макрозадачами. Правило простое: после каждой макрозадачи Node опустошает всю очередь микрозадач, и лишь затем берёт следующую макрозадачу.

Зачем это на практике

Промисы — основа всего асинхронного кода (async/await — синтаксический сахар над ними). Понимать, что продолжение await — это микрозадача, значит понимать, в какой момент возобновится ваша функция: не «сразу», а после текущего синхронного блока, но раньше любого таймера. Это объясняет неочевидный порядок логов и помогает не наступать на гонки.

А process.nextTick — мощный, но опасный инструмент. Его очередь имеет приоритет даже над промисами, и при неаккуратном использовании им легко «заморить» событийный цикл, не дав ему дойти до I/O.

Две очереди и их приоритет

Микрозадач в Node фактически два сорта, и у них разный приоритет:

  1. Очередь process.nextTick — высший приоритет. Опустошается первой.
  2. Очередь промисов (микрозадачи ECMAScript) — опустошается после очереди nextTick.

Алгоритм такой: Node выполняет одну макрозадачу → затем полностью опустошает очередь nextTick → затем полностью опустошает очередь промисов → берёт следующую макрозадачу. Если внутри nextTick-колбэка добавился ещё один nextTick, он тоже выполнится в этом же «дренаже», до промисов.

Этот скрипт использует process.nextTick (Node-API), поэтому он помечен language-text и в браузере не запускается — но порядок в выводе точный.

console.log('A: синхронно');

Promise.resolve().then(() => console.log('B: промис 1'));

process.nextTick(() => console.log('C: nextTick 1'));

Promise.resolve().then(() => console.log('D: промис 2'));

process.nextTick(() => console.log('E: nextTick 2'));

console.log('F: синхронно');

Вывод:

A: синхронно
F: синхронно
C: nextTick 1
E: nextTick 2
B: промис 1
D: промис 2

Сначала весь синхронный код (A, F). Потом опустошается очередь nextTick целиком (C, E) — обе записи раньше любого промиса. И только затем очередь промисов (B, D). Порядок внутри каждой очереди — FIFO, в каком зарегистрировали, в таком и сработали.

Запомните образ: после каждой макрозадачи Node «дренирует» микрозадачи в два захода — сначала вычерпывает до дна ведро nextTick, затем до дна ведро промисов. Только когда оба ведра пусты, цикл берёт следующую макрозадачу. Если во время дренажа в любое из вёдер подливают новые колбэки, они тоже попадут в текущий заход — поэтому полное опустошение может затянуться, и об этом риске поговорим ниже.

async/await — это микрозадачи

Когда функция доходит до await, она приостанавливается, а всё, что после await, превращается в микрозадачу-продолжение. Это значит: код после await выполнится позже текущего синхронного блока, но раньше таймеров. Чистый JS-пример (без Node-API) можно даже запустить в браузере — здесь он исполнимый.

async function main() {
  console.log('1: до await');
  await Promise.resolve();
  console.log('3: после await (микрозадача)');
}

console.log('0: старт');
main();
Promise.resolve().then(() => console.log('4: отдельный промис'));
console.log('2: конец синхронного кода');

Вывод:

0: старт
1: до await
2: конец синхронного кода
3: после await (микрозадача)
4: отдельный промис

Строки 0, 1, 2 — синхронные (вызов main() исполняется до первого await синхронно). Продолжение после await (строка 3) и отдельный промис (строка 4) — микрозадачи, выполняются после синхронного блока в порядке регистрации.

Как это работает под капотом

Очередь промисов — часть движка V8 (это стандарт ECMAScript: «микрозадачи JobQueue»). Очередь nextTick — изобретение Node поверх V8. Node устроил так, что свою очередь nextTick он опустошает до того, как отдать управление V8 на промисы, — отсюда приоритет nextTick.

Опустошение «до конца» — критичная деталь. Если nextTick-колбэк добавляет новый nextTick, тот тоже попадёт в текущий дренаж. Значит, бесконечно подкладывая nextTick, можно навсегда застрять в опустошении этой очереди и никогда не дойти до фаз цикла — ни таймеры, ни I/O не выполнятся. Это и есть голодание (starvation) событийного цикла.

Частые ошибки

Рекурсивный nextTick в горячем коде. Цикл function loop(){ process.nextTick(loop); } заблокирует цикл навсегда: фаза I/O никогда не настанет. Для повторяющейся отложенной работы берите setImmediate — он отдаёт управление циклу между итерациями.

Ждать, что промис выполнится «сразу». Даже Promise.resolve().then(...) — это микрозадача, она сработает после текущего синхронного блока, а не в строке вызова.

Смешивать nextTick и промисы и удивляться порядку. Помните: весь nextTick идёт раньше любых промисов в рамках одного дренажа. Если порядок важен — не полагайтесь на интуицию, держите в голове два уровня приоритета.

Откладывать тяжёлую работу через микрозадачи. Микрозадачи не дают циклу «вдохнуть» — между ними он не обслуживает I/O. Тяжёлую периодическую работу разбивайте через setImmediate или setTimeout, а не nextTick/промисы.

Итоги

  • Микрозадачи выполняются между макрозадачами; очередь опустошается полностью, а не по одной.
  • Два сорта микрозадач: process.nextTick (высший приоритет) и промисы — nextTick всегда раньше.
  • Продолжение await — это микрозадача: код после await идёт после синхронного блока, но раньше таймеров.
  • Бесконечный рекурсивный nextTick морит цикл голодом — I/O и таймеры не доходят до выполнения.
  • Для отложенной повторяющейся работы используйте setImmediate, а не nextTick: он отдаёт управление циклу.
Проверьте себя
1. Что выполнится первым, если в один синхронный блок добавить и process.nextTick, и Promise.resolve().then?
AКолбэк промиса — он стандартный для JS
BКолбэк process.nextTick — его очередь имеет приоритет над промисами
CТот, что зарегистрирован последним
DОба одновременно, порядок не определён
2. Почему бесконечный рекурсивный process.nextTick опасен?
AОн переполняет стек вызовов и роняет процесс
BОн опустошается до конца на каждом дренаже, поэтому цикл никогда не дойдёт до фаз I/O и таймеров (голодание)
CОн медленнее setTimeout и тратит память
DОн работает в отдельном потоке и вызывает гонки
3. Где выполнится код, идущий сразу после строки `await Promise.resolve();`?
AСинхронно, в той же строке
BВ фазе timers следующего оборота цикла
CКак микрозадача — после текущего синхронного блока, но раньше любого setTimeout
DВ фазе check вместе с setImmediate