Микрозадачи и макрозадачи
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
После того как синхронный код досчитал до конца, движок не хватает первую попавшуюся отложенную функцию — он смотрит в две очереди, и они не равны в правах. Разберёмся, почему Promise.then всегда обгоняет setTimeout(fn, 0), даже если таймер поставлен раньше.
JavaScript выполняется в одном потоке: пока работает функция, ничто её не прервёт. Всё отложенное — таймеры, ответы сети, реакции на промисы — складывается в очереди и ждёт, когда поток освободится. Очередей две, и это ключ ко всему поведению асинхронного кода.
Макрозадачи — крупные самостоятельные порции работы: колбэк setTimeout, обработчик клика, разбор пришедшего скрипта. Микрозадачи — мелкие довески к только что закончившейся работе: реакции промисов. Правило простое: движок берёт одну макрозадачу, доводит её до конца, а потом опустошает микроочередь целиком — до последней задачи, включая те, что появились по ходу дела. Только после этого наступает очередь следующей макрозадачи.
Ноль в setTimeout ничего не ускоряет: он лишь означает «поставь в конец макроочереди». А микрозадача успевает проскочить раньше, потому что её разбирают сразу после текущего куска синхронного кода.
Даже если бы других задач не было, ноль остался бы условностью. Браузер вправе отложить таймер: спецификация разрешает ограничить задержку снизу, и с седьмого таймера в цепочке вложенных друг в друга она принудительно становится минимум 4 мс (подробно — в разделе о нулевой и минимальной задержке). Плюс сам колбэк не запустится, пока поток занят — таймер обещает «не раньше чем», но никогда «ровно через».
Поэтому setTimeout(fn, 0) — это не «выполни немедленно», а «выполни в следующей свободной макрозадаче, когда получится».
Запоминать нужно не так много. Всё, что связано с промисами, — микрозадачи. Всё остальное, что приходит «извне», — макрозадачи.
| Что вызывает колбэк | Очередь |
|---|---|
.then / .catch / .finally | микрозадача |
продолжение функции после await | микрозадача |
queueMicrotask | микрозадача |
MutationObserver | микрозадача |
setTimeout / setInterval | макрозадача |
| обработчики событий: клик, загрузка, сообщение | макрозадача |
Каждое звено цепочки — отдельная микрозадача, и в очередь оно встаёт только после того, как отработало предыдущее. Поэтому соседний промис вклинивается между шагами. А микрозадача, порождённая другой микрозадачей, всё равно успевает выполниться до ближайшего таймера — очередь опустошается до дна.
Классика, которую спрашивают почти дословно. Прежде чем читать дальше, попробуйте выписать порядок сами.
Читается это в три прохода. Сначала синхронный код сверху вниз — печатаются 1 и 6, а таймеры и промисы только записываются в очереди. Затем опустошается микроочередь в порядке постановки — 3 и 5. И лишь потом идут макрозадачи, тоже по порядку, — 2 и 4.
Между двумя макрозадачами микроочередь всегда опустошается до дна. Одна фраза — и половина таких задач решается в уме.
С async/await работает то же правило, просто менее заметно. Начало функции выполняется синхронно, а всё, что стоит после await, движок оформляет как микрозадачу — даже если ждать нечего и справа от await стоит обычный null.
Если нужна именно микрозадача, а промис не при чём, есть прямой способ — queueMicrotask(fn). Он кладёт функцию в ту же очередь, что и реакции промисов, без лишней обёртки и без создания объекта Promise.
Зачем это нужно на практике? Чтобы функция вела себя предсказуемо. Библиотечная функция, которая иногда вызывает колбэк сразу, а иногда — позже, порождает плавающие баги. Отложив вызов в микроочередь, вы гарантируете: колбэк всегда асинхронный, но при этом отработает до отрисовки и до любого таймера.
У приоритета есть обратная сторона. Пока микроочередь не опустеет, браузер не перейдёт к следующей макрозадаче и не перерисует страницу. Микрозадача, которая бесконечно ставит саму себя, намертво вешает вкладку — таймеры не сработают, клики не обработаются, интерфейс замрёт. Конечное число повторов безопасно, бесконечное — нет.
Если нужно перемолоть много данных, режьте работу на порции и раскладывайте их по макрозадачам через 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Микрозадача во время выполнения добавляет в очередь ещё одну микрозадачу. Когда та выполнится?
Предскажи порядок вывода
Здесь таймер порождает микрозадачу, а микрозадача — таймер. Выпишите порядок вывода на бумаге и только потом запускайте.
Правильный порядок: A F D B C E.
Разбор по шагам:
- Синхронный проход печатает
AиF. Таймер сBвстаёт в макроочередь, реакция промиса сD— в микроочередь. - Синхронный код кончился — разбирается микроочередь. Печатается
D, и внутри ставится второй таймер, сE. Он попадает в макроочередь после таймера сB. - Первая макрозадача: печатается
Bи ставится микрозадача сC. - Макрозадача закончилась — снова опустошается микроочередь, печатается
C. Это ключевой момент:Cидёт раньшеE, хотя таймер сEбыл поставлен раньше. - Последняя макрозадача печатает
E.
Асинхронно, но раньше таймеров
Функция notify вызывает колбэк синхронно — из-за этого вызывающий код может получить результат раньше, чем закончит собственную настройку. Сделайте вызов гарантированно асинхронным, но так, чтобы колбэк всё равно опережал любой setTimeout.
Достаточно завернуть вызов в queueMicrotask:
Колбэк уходит в микроочередь: он больше не выполняется посреди чужого синхронного кода, но и не откладывается до следующей макрозадачи. Вариант с Promise.resolve().then(() => handler("готово")) даёт тот же порядок, просто создаёт лишний объект промиса. А вот setTimeout(..., 0) задачу не решает: тогда колбэк встанет в макроочередь после уже заведённого таймера и «готово» напечатается последним.