Kodokon kodokon.com

Цикл событий: стек вызовов, микрозадачи, макрозадачи

Разберись, как движок на самом деле планирует синхронный код, микрозадачи и макрозадачи.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

JavaScript выполняет твой код в одном потоке, но цикл событий не описан в ECMAScript: то, как он работает, задаёт спецификация HTML (и libuv на стороне Node.js). Движок держит стек вызовов для кода, который выполняется прямо сейчас, очередь макрозадач (таймеры, ввод-вывод, события) и очередь микрозадач (Promise.prototype.then, queueMicrotask, MutationObserver). Золотое правило: после каждой макрозадачи очередь микрозадач опустошается полностью, и только потом управление возвращается наружу.

JAVASCRIPT
console.log("script start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));

console.log("script end");
// script start, script end,
// promise 1, promise 2, timeout
Микрозадачи всегда выполняются раньше макрозадач.

Первоначальный скрипт сам по себе является макрозадачей: пока он не закончится, ничего другого не выполняется. Как только стек вызовов пустеет, движок опустошает очередь микрозадач, включая микрозадачи, добавленные во время опустошения. Эта деталь объясняет, почему два сцепленных вызова then выполняются раньше setTimeout(fn, 0): каждый then планирует новую микрозадачу, а управление не возвращается наружу, пока очередь не опустеет.

JAVASCRIPT
setTimeout(() => console.log("macro 1"), 0);
setTimeout(() => console.log("macro 2"), 0);

queueMicrotask(() => {
  console.log("micro 1");
  queueMicrotask(() => console.log("micro 2"));
});
// micro 1, micro 2, macro 1, macro 2
Вложенная микрозадача выполняется раньше любой макрозадачи.

Ключевое слово await опирается на этот механизм: оно приостанавливает асинхронную функцию, возвращает управление вызывающему коду, а затем планирует возобновление как микрозадачу. Даже await null вызывает эту приостановку, потому что значение оборачивается в уже разрешённый промис. Именно поэтому код, стоящий после await, никогда не выполняется синхронно.

JAVASCRIPT
async function main() {
  console.log("before await");
  await null;
  console.log("after await");
}

main();
console.log("sync code");
// before await, sync code, after await
await приостанавливает функцию и переносит остаток на потом.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Скрипт синхронно выводит C, планирует A через setTimeout(fn, 0) и B через Promise.resolve().then. В каком порядке появятся буквы?
    • A, B, C
    • C, A, B
    • C, B, A
    • B, C, A
  2. Когда опустошается очередь микрозадач?
    • После каждой макрозадачи, полностью, включая микрозадачи, добавленные во время опустошения
    • Между двумя макрозадачами выполняется только одна микрозадача
    • Каждые 16 мс, прямо перед отрисовкой страницы
    • Только после того, как обработаны все макрозадачи
  3. Что на самом деле делает await под капотом?
    • Он блокирует поток, пока промис не разрешится
    • Он приостанавливает асинхронную функцию и планирует её возобновление как микрозадачу
    • Он превращает остаток функции в макрозадачу
    • Он ничего не делает, если ожидаемое значение не является промисом