Технологии

Event Loop в JavaScript: как работает и что спрашивают на собеседовании

Стек вызовов, микро- и макрозадачи, промисы и async/await: как устроен Event Loop и как уверенно решать задачи на порядок вывода на собеседовании.

Event Loop - механизм, который позволяет однопоточному JavaScript выполнять асинхронный код. Движок выполняет синхронный код из стека вызовов, а когда стек пуст, сначала обрабатывает все микрозадачи (колбэки промисов, продолжения после await), затем браузер может обновить отрисовку, и только после этого берётся следующая макрозадача - например, колбэк setTimeout или обработчик события. Поэтому Promise.then всегда срабатывает раньше setTimeout с нулевой задержкой, запланированного в том же синхронном блоке.

Вопрос про Event Loop звучит почти на каждом собеседовании frontend- и backend-разработчика на JavaScript. Обычно он оформлен как задача: дан код с console.log, setTimeout, промисами и async-функциями, нужно назвать порядок вывода и объяснить его. Разберём механизм по частям и алгоритм, по которому такие задачи решаются без угадывания.

Однопоточность и стек вызовов

JavaScript-код выполняется в одном потоке: в каждый момент времени работает одна функция. Стек вызовов хранит цепочку вызванных функций. Когда функция вызывается, она помещается на вершину стека, когда завершается - снимается с него. Пока стек не пуст, ничего другого движок не выполняет: ни колбэки таймеров, ни обработчики кликов.

Отсюда следствие, которое важно понимать: долгая синхронная операция, например тяжёлый цикл, блокирует всё. Страница не реагирует на клики и не перерисовывается, пока стек не освободится. Асинхронность в JavaScript - не параллельное выполнение кода, а способ отложить работу, чтобы не держать стек занятым.

Кто выполняет асинхронную работу

Таймеры, сетевые запросы и события обрабатывает окружение - браузер или Node.js. Когда вы вызываете setTimeout или fetch, окружение берёт ожидание на себя, а JavaScript продолжает выполнять следующий код. Когда таймер истёк или ответ пришёл, окружение ставит соответствующий колбэк в очередь. Event Loop забирает задачи из очередей и выполняет их, когда стек пуст.

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

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

Макрозадачи

Макрозадачи (в спецификации - просто задачи): колбэки setTimeout и setInterval, обработчики событий пользователя, сообщения через postMessage и MessageChannel, выполнение основного скрипта. За один шаг цикла выполняется одна макрозадача.

Микрозадачи

Микрозадачи: колбэки then, catch и finally у промисов, продолжение async-функции после await, queueMicrotask и MutationObserver. Очередь микрозадач обрабатывается полностью после завершения каждой макрозадачи - включая микрозадачи, которые были добавлены во время этой обработки.

Один шаг цикла

  1. Выполнить одну макрозадачу - на первом шаге это сам скрипт.
  2. Выполнить все микрозадачи, пока очередь не опустеет.
  3. Браузер при необходимости обновляет отрисовку: выполняет колбэки requestAnimationFrame, пересчитывает стили и раскладку, рисует кадр.
  4. Перейти к следующей макрозадаче.

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

Промисы и async/await

Частая ловушка - считать, что всё, связанное с промисом, асинхронно. Функция-исполнитель, которую передают в new Promise, выполняется синхронно, сразу при создании промиса. Асинхронны только колбэки then, catch и finally: они попадают в очередь микрозадач, когда промис уже выполнен.

Async-функция до первого await работает как обычная синхронная функция. Встретив await, она приостанавливается, а её продолжение будет поставлено в очередь микрозадач, когда ожидаемый промис завершится. Если ждать нечего - например, await стоит перед обычным значением, - продолжение всё равно откладывается микрозадачей, а не выполняется сразу.

Поэтому в коде «async-функция выводит a, затем await, затем выводит b; вызываем её и сразу выводим c» порядок будет a, c, b. Буква a выводится синхронно при вызове, c - синхронно после возврата из функции, а b - микрозадачей, когда синхронный код закончился.

Как решать задачи на порядок вывода

Задачи выглядят запутанными, но решаются одним алгоритмом. Проговаривайте его вслух - интервьюеру важен ход рассуждения, а не только итоговая последовательность.

  1. Пройдите код сверху вниз как синхронный. Всё, что выполняется сразу, выпишите в порядке появления: console.log вне колбэков, тела исполнителей промисов, код async-функций до первого await.
  2. Отметьте, что ушло в очереди. Колбэки then и продолжения после await - в микрозадачи, setTimeout - в макрозадачи. Порядок внутри очереди - порядок постановки.
  3. Выполните все микрозадачи. Если микрозадача планирует новую - например, then возвращает следующий then, - новая становится в конец той же очереди и выполняется в этом же проходе.
  4. Возьмите следующую макрозадачу и снова обработайте все микрозадачи после неё.

Разбор классического примера

Пусть код выводит 1, затем ставит setTimeout с выводом 2, затем Promise.resolve().then с выводом 3, затем выводит 4. Синхронно выполняются выводы 1 и 4. В очереди микрозадач оказывается вывод 3, в очереди макрозадач - вывод 2. После синхронного кода выполняются микрозадачи, затем таймер. Итог: 1, 4, 3, 2.

Усложнённый вариант: внутри колбэка setTimeout создаётся промис с then. Этот then выполнится сразу после завершения колбэка таймера - раньше, чем следующий setTimeout, даже если тот был запланирован с той же нулевой задержкой. Причина в том, что после каждой макрозадачи очередь микрозадач обрабатывается полностью.

Две цепочки промисов

Ещё один частый вариант: две независимые цепочки, каждая из двух then. Первая выводит A1, затем A2, вторая - B1, затем B2. Результат - A1, B1, A2, B2. Каждый then ставит следующий колбэк в очередь только после своего выполнения, поэтому шаги двух цепочек чередуются, а не выполняются одна цепочка целиком за другой.

Ошибки в асинхронном коде

Исключение внутри колбэка промиса или async-функции не прерывает остальную программу, а переводит промис в состояние rejected. Если такой промис никто не обработал через catch или try/catch вокруг await, браузер генерирует событие unhandledrejection и пишет ошибку в консоль, а современные версии Node.js по умолчанию завершают процесс. Поэтому каждая асинхронная цепочка должна заканчиваться обработкой ошибки.

Отдельная ловушка - try/catch вокруг кода, который только планирует асинхронную работу. Если внутри try вызвать setTimeout, а ошибка произойдёт в его колбэке, catch её не поймает: к моменту выполнения колбэка блок try давно завершился и снят со стека. Ловить ошибку нужно там, где выполняется асинхронный код, - внутри колбэка или вокруг await.

Асинхронность в React

Понимание цикла событий помогает объяснить поведение React. Вызов функции обновления состояния не меняет значение переменной мгновенно: React планирует повторный рендер, а начиная с 18-й версии объединяет несколько обновлений в один рендер даже внутри промисов и таймеров. Поэтому сразу после вызова обновления в том же обработчике переменная состояния содержит старое значение.

useEffect обычно выполняется после того, как браузер отрисовал изменения, а useLayoutEffect - после изменения DOM, но до отрисовки. Отсюда практическое правило: измерять размеры элементов, чтобы избежать мигания, - в useLayoutEffect, а запросы и подписки - в useEffect. Вопросы на эту тему часто встречаются рядом с вопросами про Event Loop, подробнее - в статье вопросы на собеседовании по React.

Как не блокировать интерфейс

Если нужно обработать большой объём данных в браузере, есть три типовых подхода. Первый - разбить работу на части и выполнять их отдельными макрозадачами, например через setTimeout, чтобы между частями браузер успевал обработать клики и отрисовать кадр. Второй - вынести вычисления в Web Worker, который работает в отдельном потоке и общается с основным через сообщения. Третий - уменьшить саму работу: виртуализировать длинный список, отображая только видимые элементы, или перенести обработку на сервер.

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

setTimeout, requestAnimationFrame и задержки

Задержка у setTimeout - минимальное время ожидания, а не точное. Колбэк не выполнится, пока занят стек и не обработаны микрозадачи, а у глубоко вложенных таймеров браузер может увеличивать минимальную задержку. Поэтому setTimeout не подходит для точных измерений времени и плавной анимации.

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

Event Loop в Node.js

На backend-собеседованиях по Node.js вопрос обычно углубляют. Цикл событий в Node.js реализован библиотекой libuv и разделён на фазы: таймеры (setTimeout и setInterval), обработка отложенных системных колбэков, poll - ожидание и обработка ввода-вывода, check - колбэки setImmediate, и фаза закрытия соединений.

  • process.nextTick - отдельная очередь, которая обрабатывается раньше очереди промисов. Злоупотребление ею может задерживать ввод-вывод.
  • setImmediate выполняется в фазе check, после обработки ввода-вывода.
  • setTimeout с нулевой задержкой и setImmediate в основном модуле могут сработать в любом порядке, а внутри колбэка ввода-вывода setImmediate всегда выполнится раньше.
  • Тяжёлые вычисления блокируют весь сервер: для них используют worker_threads или выносят работу в отдельные сервисы.

Подробнее о том, что ещё нужно знать backend-разработчику, - в статье роадмап backend-разработчика на Node.js.

Что спрашивают на собеседовании

  1. Как JavaScript выполняет асинхронный код, если он однопоточный?
  2. Чем микрозадачи отличаются от макрозадач? Приведите примеры каждых.
  3. Какой порядок вывода у данного кода и почему?
  4. Почему setTimeout с нулевой задержкой срабатывает не сразу?
  5. Что происходит с async-функцией при встрече await?
  6. Как долгий синхронный код влияет на интерфейс и что с этим делать?
  7. Для backend: что такое process.nextTick и setImmediate, и в каком порядке они выполняются?

Как отвечать, чтобы вас запомнили

Начните с короткой модели: стек вызовов, очередь макрозадач, очередь микрозадач и правило «после каждой макрозадачи - все микрозадачи». Затем решайте задачу по шагам, проговаривая, куда уходит каждый колбэк. Если сомневаетесь в пограничном случае, скажите об этом прямо и объясните, как бы проверили поведение, - это ценится больше уверенной ошибки.

Хороший знак для интервьюера - связь теории с практикой: почему тяжёлые вычисления стоит разбивать на части или выносить в Web Worker, почему обновление состояния в React не применяется мгновенно, как избежать зависания интерфейса при обработке большого списка. Больше о том, как держаться во время решения задач, - в статье лайвкодинг на собеседовании.

Типичные ошибки в ответах

  • «setTimeout с нулём выполняется сразу». Нет: колбэк ждёт окончания синхронного кода и всех микрозадач.
  • «Всё внутри промиса асинхронно». Исполнитель new Promise выполняется синхронно, асинхронны только then, catch и finally.
  • «await блокирует поток». Приостанавливается только текущая async-функция, остальной код продолжает выполняться.
  • «Микрозадачи выполняются по одной между макрозадачами». После каждой макрозадачи очередь микрозадач обрабатывается целиком.
  • «JavaScript многопоточный, раз есть асинхронность». Код выполняется в одном потоке; ожиданием таймеров и сети занимается окружение.

Как закрепить тему

  • Напишите пять-шесть собственных задач на порядок вывода, сначала предскажите результат, потом проверьте в консоли.
  • Объясните механизм вслух, как на собеседовании, и запишите себя - так видны паузы и неточности.
  • Посмотрите, как асинхронность проявляется в вашем проекте: запросы, таймеры, обработчики событий.

Асинхронность и Event Loop - обязательная часть программы треков Frontend на React и Backend на Node.js: на менторстве эти темы отрабатываются на задачах и мок-собеседованиях, пока объяснение не станет уверенным.

Вопросы по теме

Почему setTimeout с нулевой задержкой выполняется не сразу?
setTimeout ставит колбэк в очередь макрозадач, а не выполняет его немедленно. Колбэк запустится только после того, как завершится текущий синхронный код и выполнятся все микрозадачи, например колбэки промисов. Нулевая задержка означает «как можно раньше», а не «прямо сейчас».
Что выполнится раньше: Promise.then или setTimeout?
Колбэк Promise.then выполнится раньше, если оба запланированы в одном синхронном блоке. then попадает в очередь микрозадач, которая полностью обрабатывается до перехода к следующей макрозадаче, а setTimeout - в очередь макрозадач.
Блокирует ли await выполнение всей программы?
Нет. await приостанавливает только текущую async-функцию: остаток её кода будет выполнен микрозадачей после завершения промиса. Всё это время поток свободен и выполняет другой код - остальную синхронную часть программы и другие задачи.
Можно ли заблокировать страницу микрозадачами?
Да. Очередь микрозадач обрабатывается до конца, включая микрозадачи, добавленные во время обработки. Если микрозадачи бесконечно порождают новые, браузер не перейдёт ни к отрисовке, ни к следующей макрозадаче, и страница перестанет отвечать.
Чем Event Loop в Node.js отличается от браузерного?
В Node.js цикл разделён на фазы: таймеры, обработка ввода-вывода, фаза check для setImmediate и другие. Там есть отдельная очередь process.nextTick, которая обрабатывается раньше очереди промисов. Общий принцип тот же: синхронный код, затем микрозадачи, затем следующая задача цикла.

Читайте также

Все статьи

Обсудим вашу подготовку?

Напишите, что уже умеете и с чем возникли трудности. Если есть проект, пришлите ссылку на него.

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

Что не получается, что уже пробовали. От 100 знаков, сейчас 0.
Где работали или учились, сколько лет, какой стек, сколько откликов и собеседований. От 100 знаков, сейчас 0.
PDF или DOCX, до 5 МБ. Файл видит только администратор.
GitHub, hh, LinkedIn, сайт. Можно несколько, через пробел или с новой строки.