open atlas
↑ К треку
Внутренности движка JavaScript JSE · 07 · 02

Микрозадачи против макрозадач: порядок

За оборот цикла выполняется одна макрозадача; вся очередь микрозадач осушается после каждой макрозадачи и каждый раз, когда пустеет стек JS. Микрозадача, поставившая микрозадачу, выполняется в том же осушении — корень starvation. Плюс порядок process.nextTick в Node.

JSE Middle ◷ 13 min
Уровень
ОсновыJuniorMiddleSenior

Джуниор просит вас «починить баг», где это печатает 1 4 3 2 вместо 1 2 3 4. Никакого бага нет. Этот порядок — контракт: колбэк setTimeout ждёт своей очереди как макрозадача, пока Promise.then прыгает вперёд как микрозадача. Если вы не можете предсказать это чередование на лету, вы не сможете рассуждать о гонке в проде — и уж точно не объясните, почему тугая цепочка Promise только что заморозила вкладку.

Две очереди, два ритма

Предыдущий урок зафиксировал, кто чем владеет: хост владеет циклом и очередями макрозадач; движок владеет очередью микрозадач. Этот урок делает порядок точным, потому что две очереди осушаются с совершенно разными ритмами.

  • Макрозадачи (спецификация называет их просто tasks) — за оборот цикла выполняется ровно одна. Источники: колбэки setTimeout/setInterval, колбэки завершения I/O, сообщения MessageChannel/postMessage, сработавшие DOM-события, разобранные куски HTML. Выполнив одну, цикл не запускает следующую макрозадачу немедленно.
  • Микрозадачився очередь осушается после каждой макрозадачи и снова каждый раз, когда пустеет стек вызовов JavaScript. Источники: реакции Promise.then/.catch/.finally, jobs от queueMicrotask, продолжения await, колбэки MutationObserver.

Асимметрия — это вся суть. Макрозадачи нормированы — одна за оборот — чтобы цикл мог рендерить и обрабатывать ввод между ними. Микрозадачи исчерпывающи — осушаются до пустоты — чтобы целостная единица асинхронного продолжения (цепочка Promise) завершилась атомарно прежде, чем случится что-либо ещё. Браузерный обзор (browser/01-event-loop/04-microtask-starvation) показывает этот режим отказа на уровне рантайма; здесь мы привязываем его к механике очередей.

Канонический пример порядка

console.log("1: sync start");

setTimeout(() => console.log("4: macrotask (setTimeout)"), 0);

Promise.resolve()
  .then(() => console.log("3a: microtask one"))
  .then(() => console.log("3b: microtask two"));

queueMicrotask(() => console.log("3c: queueMicrotask"));

console.log("2: sync end");
// Вывод: 1 → 2 → 3a → 3c → 3b → 4

Пройдём точно. Синхронный скрипт и есть текущая макрозадача. Он сперва исполняется до конца: 1, затем 2. По пути он запланировал макрозадачу (setTimeout) и микрозадачи (.then и queueMicrotask). Когда стек скрипта пустеет, движок осушает очередь микрозадач в порядке FIFO: первый .then (3a) и queueMicrotask (3c) были поставлены во время синхронного прогона, поэтому идут первыми; обработчик 3a возвращается, что теперь урегулирует сцепленный Promise и ставит 3b — добавленный в то же осушение, — поэтому 3b выполняется до конца осушения. Только когда очередь микрозадач наконец пуста, хост выбирает следующую макрозадачу: 4.

Микрозадача против макрозадачи кратко
Макрозадач за оборот цикла
ровно 1
Микрозадач за чекпоинт
все, до пустоты
Порядок осушения внутри очереди
FIFO
Рендер может вклиниться между
только макрозадачами
queueMicrotask против Promise.then
одна очередь
Node: process.nextTick против Promise
nextTick первым

Почему микрозадача, ставящая микрозадачу, опасна

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

function spin() {
  Promise.resolve().then(spin); // перевзводится внутри того же чекпоинта
}
spin(); // замерзает: чекпоинт никогда не завершается, движок никогда не возвращается

Это microtask starvation (голодание микрозадач). Хост никогда не получает поток назад (движок застрял внутри PerformMicrotaskCheckpoint из урока 1), поэтому ни одна макрозадача не выполняется, ни один кадр не рисуется, ввод не обрабатывается. Сравните с самозацикленной макрозадачей — setTimeout(spin, 0) — которая перевзводится на очереди хоста, поэтому движок возвращается между итерациями и страница остаётся отзывчивой (просто занятой). Лекарство от starvation — всегда вставить в цепочку yield на уровне макрозадачи.

Специфика Node: process.nextTick и пофазное осушение

Если вы читаете Node-кодовую базу и process.nextTick срабатывает раньше Promise.resolve().then — это не баг и не причуда, а намеренная вторая очередь до Promise. Node усложняет картину двумя дополнительными правилами. Во-первых, колбэки process.nextTick выполняются в очереди, которая отдельна от очереди микрозадач Promise и осушается до неё. Поэтому в Node process.nextTick всегда опережает Promise.resolve().then. Во-вторых, цикл Node работает фазами (timers → pending callbacks → poll → check → close), и исторически Node осушал микрозадачи между фазами, а не после каждого отдельного колбэка — хотя начиная с Node 11 он осушает микрозадачи после каждого отдельного колбэка макрозадачи, приближаясь к браузерной семантике.

// В Node:
setTimeout(() => console.log("timeout"), 0);     // фаза timers (макрозадача)
setImmediate(() => console.log("immediate"));     // фаза check (макрозадача)
Promise.resolve().then(() => console.log("promise")); // микрозадача
process.nextTick(() => console.log("nextTick"));  // очередь nextTick — первая

// Порядок: nextTick → promise → (timeout|immediate, порядок между этими
// двумя не гарантирован из главного модуля)

Практический сеньорный вывод: никогда не полагайтесь на порядок setTimeout против setImmediate с верхнего уровня, но полагайтесь на то, что process.nextTick выполняется до микрозадач Promise. И осторожно: у process.nextTick та же опасность starvation, что и у цикла микрозадач — рекурсивный nextTick морит голодом цикл фаз ровно так же, как рекурсивный .then морит браузер.

Викторина

Внутри колбэка `setTimeout` вы запускаете `Promise.resolve().then(work)` и также планируете ещё один `setTimeout(other, 0)`. В каком порядке выполнятся `work` и `other`?

Викторина

В Node вы планируете `process.nextTick(a)` и `Promise.resolve().then(b)` из одного синхронного блока. Что выполнится первым и почему?

Расставь шаги по порядку

Расставьте вывод канонического примера: синхронный скрипт планирует один setTimeout, цепочку .then из двух звеньев и один queueMicrotask. Перетащите строки лога в порядке их печати.

  1. 1 1: sync start (синхронно, текущая макрозадача)
  2. 2 2: sync end (синхронно, всё ещё та же макрозадача)
  3. 3 3a: первый обработчик .then (микрозадача, поставлена при синхронном прогоне)
  4. 4 3c: queueMicrotask (микрозадача, поставлена при синхронном прогоне)
  5. 5 3b: второй обработчик .then (микрозадача, поставлена 3a в том же осушении)
  6. 6 4: колбэк setTimeout (следующая макрозадача, после осушения очереди)
Граничные случаи

Продолжения await — тоже микрозадачи, поэтому подчиняются тому же правилу исчерпывающего осушения. Функция, которая await’ит в цикле, будет чередовать свои возобновления с каждой другой ожидающей микрозадачей прежде, чем выполнится любая макрозадача. Вот почему смешивание await внутри горячего, ощущающегося синхронным цикла может тихо отложить рендер гораздо дольше, чем вы ожидаете — await’ы держат очередь микрозадач непустой. Урок 04 делает отображение await-в-микрозадачу явным.

Вспомните перед уходом
  1. 01
    Сформулируйте точное правило осушения для макрозадач против микрозадач и назовите источники каждой.
  2. 02
    Проследите, почему `1, setTimeout(4), Promise.then(3a).then(3b), queueMicrotask(3c), 2` печатает 1 2 3a 3c 3b 4.
  3. 03
    Чем порядок Node отличается от браузера и каково практическое правило?
Итог

Макрозадачи и микрозадачи осушаются в противоположных ритмах. За оборот цикла выполняется ровно одна макрозадача — setTimeout, завершения I/O, message-события, DOM-события — после чего цикл может рендерить и обрабатывать ввод. Вся очередь микрозадач, напротив, осушается FIFO до пустоты после каждой макрозадачи и каждый раз, когда пустеет стек JS — реакции Promise, queueMicrotask, продолжения await, MutationObserver. Поскольку осушение исчерпывающее, микрозадача, поставившая другую микрозадачу, выполняется в том же осушении, а не в более позднем обороте; повторённое, это microtask starvation, которое замораживает страницу, потому что движок никогда не завершает чекпоинт и не возвращает поток хосту. Самозацикленная макрозадача, напротив, возвращает поток каждый оборот и остаётся отзывчивой. Node добавляет очередь process.nextTick, осушаемую до очереди Promise (поэтому nextTick выигрывает), и пофазный цикл; начиная с Node 11 микрозадачи осушаются после каждого колбэка макрозадачи, близко к браузерному поведению. Канонический пример порядка — 1 2 3a 3c 3b 4 — это мышечная память, нужная каждому сеньору, чтобы рассуждать об асинхронных гонках. Теперь, когда встретишь вкладку, зависшую без видимой нагрузки на CPU, — первым делом спроси: не крутится ли что-то в очереди микрозадач и не отдаёт ли поток хосту?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки
открывает
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.