Микрозадачи: 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 фактически два сорта, и у них разный приоритет:
- Очередь
process.nextTick— высший приоритет. Опустошается первой. - Очередь промисов (микрозадачи 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: он отдаёт управление циклу.