Микрозадачи и макрозадачи

асинхронность≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

После того как синхронный код досчитал до конца, движок не хватает первую попавшуюся отложенную функцию — он смотрит в две очереди, и они не равны в правах. Разберёмся, почему Promise.then всегда обгоняет setTimeout(fn, 0), даже если таймер поставлен раньше.

Разделы темы:·

JavaScript выполняется в одном потоке: пока работает функция, ничто её не прервёт. Всё отложенное — таймеры, ответы сети, реакции на промисы — складывается в очереди и ждёт, когда поток освободится. Очередей две, и это ключ ко всему поведению асинхронного кода.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Ноль в setTimeout ничего не ускоряет: он лишь означает «поставь в конец макроочереди». А микрозадача успевает проскочить раньше, потому что её разбирают сразу после текущего куска синхронного кода.

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

Поэтому setTimeout(fn, 0) — это не «выполни немедленно», а «выполни в следующей свободной макрозадаче, когда получится».

Запоминать нужно не так много. Всё, что связано с промисами, — микрозадачи. Всё остальное, что приходит «извне», — макрозадачи.

Что вызывает колбэкОчередь
.then / .catch / .finallyмикрозадача
продолжение функции после awaitмикрозадача
queueMicrotaskмикрозадача
MutationObserverмикрозадача
setTimeout / setIntervalмакрозадача
обработчики событий: клик, загрузка, сообщениемакрозадача
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Классика, которую спрашивают почти дословно. Прежде чем читать дальше, попробуйте выписать порядок сами.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Читается это в три прохода. Сначала синхронный код сверху вниз — печатаются 1 и 6, а таймеры и промисы только записываются в очереди. Затем опустошается микроочередь в порядке постановки — 3 и 5. И лишь потом идут макрозадачи, тоже по порядку, — 2 и 4.

Между двумя макрозадачами микроочередь всегда опустошается до дна. Одна фраза — и половина таких задач решается в уме.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

С async/await работает то же правило, просто менее заметно. Начало функции выполняется синхронно, а всё, что стоит после await, движок оформляет как микрозадачу — даже если ждать нечего и справа от await стоит обычный null.

Если нужна именно микрозадача, а промис не при чём, есть прямой способ — queueMicrotask(fn). Он кладёт функцию в ту же очередь, что и реакции промисов, без лишней обёртки и без создания объекта Promise.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Зачем это нужно на практике? Чтобы функция вела себя предсказуемо. Библиотечная функция, которая иногда вызывает колбэк сразу, а иногда — позже, порождает плавающие баги. Отложив вызов в микроочередь, вы гарантируете: колбэк всегда асинхронный, но при этом отработает до отрисовки и до любого таймера.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

У приоритета есть обратная сторона. Пока микроочередь не опустеет, браузер не перейдёт к следующей макрозадаче и не перерисует страницу. Микрозадача, которая бесконечно ставит саму себя, намертво вешает вкладку — таймеры не сработают, клики не обработаются, интерфейс замрёт. Конечное число повторов безопасно, бесконечное — нет.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Если нужно перемолоть много данных, режьте работу на порции и раскладывайте их по макрозадачам через setTimeout: между ними браузер успеет отрисовать кадр и ответить на действия пользователя. Микрозадачи — для коротких довесков, а не для тяжёлых циклов.

Отрисовка — не задача из очереди, а отдельная фаза цикла событий. Браузер вставляет её между макрозадачами, примерно 60 раз в секунду, и только когда микроочередь пуста. Отсюда следствие: изменения, сделанные в микрозадаче, попадут в тот же кадр, что и изменения из породившей её макрозадачи.

Для кода, который должен выполниться прямо перед ближайшим кадром, есть свой механизм — requestAnimationFrame. Он не относится ни к микро-, ни к макрозадачам и живёт в собственной фазе.

  • Отложенные функции ждут в двух очередях: макрозадачи — таймеры и события, микрозадачи — реакции промисов.
  • Движок выполняет одну макрозадачу, затем опустошает микроочередь целиком, включая задачи, добавленные по ходу разбора.
  • Поэтому .then всегда обгоняет setTimeout(fn, 0), а продолжение кода после await — это тоже микрозадача.
  • queueMicrotask(fn) кладёт функцию в микроочередь напрямую — удобно, чтобы сделать вызов колбэка стабильно асинхронным.
  • Бесконечная цепочка микрозадач блокирует отрисовку и события: тяжёлую работу режут на порции и разносят по макрозадачам.

1/3В скрипте подряд идут три строки: setTimeout с console.log(1) и задержкой 0, затем Promise.resolve().then с console.log(2), затем обычный console.log(3). Что окажется в консоли?

2/3Чем queueMicrotask(fn) отличается от setTimeout(fn, 0)?

3/3Микрозадача во время выполнения добавляет в очередь ещё одну микрозадачу. Когда та выполнится?

Предскажи порядок вывода

Здесь таймер порождает микрозадачу, а микрозадача — таймер. Выпишите порядок вывода на бумаге и только потом запускайте.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Правильный порядок: A F D B C E.

Разбор по шагам:

  1. Синхронный проход печатает A и F. Таймер с B встаёт в макроочередь, реакция промиса с D — в микроочередь.
  2. Синхронный код кончился — разбирается микроочередь. Печатается D, и внутри ставится второй таймер, с E. Он попадает в макроочередь после таймера с B.
  3. Первая макрозадача: печатается B и ставится микрозадача с C.
  4. Макрозадача закончилась — снова опустошается микроочередь, печатается C. Это ключевой момент: C идёт раньше E, хотя таймер с E был поставлен раньше.
  5. Последняя макрозадача печатает E.

Асинхронно, но раньше таймеров

Функция notify вызывает колбэк синхронно — из-за этого вызывающий код может получить результат раньше, чем закончит собственную настройку. Сделайте вызов гарантированно асинхронным, но так, чтобы колбэк всё равно опережал любой setTimeout.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Достаточно завернуть вызов в queueMicrotask:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Колбэк уходит в микроочередь: он больше не выполняется посреди чужого синхронного кода, но и не откладывается до следующей макрозадачи. Вариант с Promise.resolve().then(() => handler("готово")) даёт тот же порядок, просто создаёт лишний объект промиса. А вот setTimeout(..., 0) задачу не решает: тогда колбэк встанет в макроочередь после уже заведённого таймера и «готово» напечатается последним.